merge upstream again

This commit is contained in:
Hazelnoot 2025-04-24 14:23:45 -04:00
commit a4dd19fdd4
167 changed files with 6779 additions and 3952 deletions

View file

@ -157,7 +157,7 @@ async function init() {
const accounts = await getAccounts();
const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id));
const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id));
if (accountIdsToFetch.length > 0) {
const usersRes = await misskeyApi('users/show', {
@ -169,7 +169,7 @@ async function init() {
users.value.set(user.id, {
...user,
token: accounts.find(a => a.user.id === user.id)!.token,
token: accounts.find(a => a.id === user.id)!.token,
});
}
}

View file

@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</li>
<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
</ol>
<ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list">
<ol v-else-if="type === 'hashtag' && hashtags.length > 0" ref="suggests" :class="$style.list">
<li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
<span class="name">{{ hashtag }}</span>
</li>
</ol>
<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
<ol v-else-if="type === 'emoji' || type === 'emojiComplete' && emojis.length > 0" ref="suggests" :class="$style.list">
<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
</li>
</ol>
<ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list">
<ol v-else-if="type === 'mfmTag' && mfmTags.length > 0" ref="suggests" :class="$style.list">
<li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown">
<span>{{ tag }}</span>
</li>
</ol>
<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list">
<ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list">
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
<span>{{ param }}</span>
</li>
@ -58,12 +58,44 @@ import { store } from '@/store.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { searchEmoji } from '@/utility/search-emoji.js';
import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js';
import { prefer } from '@/preferences.js';
export type CompleteInfo = {
user: {
payload: any;
query: string | null;
},
hashtag: {
payload: string;
query: string;
},
// `:emo` -> `:emoji:` or some unicode emoji
emoji: {
payload: string;
query: string;
},
// like emoji but for `:emoji:` -> unicode emoji
emojiComplete: {
payload: string;
query: string;
},
mfmTag: {
payload: string;
query: string;
},
mfmParam: {
payload: string;
query: {
tag: string;
params: string[];
};
},
};
const lib = emojilist.filter(x => x.category !== 'flags');
const emojiDb = computed(() => {
const unicodeEmojiDB = computed(() => {
//#region Unicode Emoji
const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : prefer.r.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath;
@ -87,6 +119,12 @@ const emojiDb = computed(() => {
}
unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
return unicodeEmojiDB;
});
const emojiDb = computed(() => {
//#region Unicode Emoji
//#endregion
//#region Custom Emoji
@ -114,7 +152,7 @@ const emojiDb = computed(() => {
customEmojiDB.sort((a, b) => a.name.length - b.name.length);
//#endregion
return markRaw([...customEmojiDB, ...unicodeEmojiDB]);
return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]);
});
export default {
@ -123,18 +161,23 @@ export default {
};
</script>
<script lang="ts" setup>
const props = defineProps<{
type: string;
q: any;
textarea: HTMLTextAreaElement;
<script lang="ts" setup generic="T extends keyof CompleteInfo">
type PropsType<T extends keyof CompleteInfo> = {
type: T;
q: CompleteInfo[T]['query'];
// HTMLTextAreaElement | HTMLInputElement addEventListener/removeEventListener
textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement;
close: () => void;
x: number;
y: number;
}>();
};
//const props = defineProps<PropsType<keyof CompleteInfo>>();
// discriminated union
// https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions
const props = defineProps<PropsType<'user'> | PropsType<'hashtag'> | PropsType<'emoji'> | PropsType<'emojiComplete'> | PropsType<'mfmTag'> | PropsType<'mfmParam'>>();
const emit = defineEmits<{
(event: 'done', value: { type: string; value: any }): void;
<T extends keyof CompleteInfo>(event: 'done', value: { type: T; value: CompleteInfo[T]['payload'] }): void;
(event: 'closed'): void;
}>();
@ -151,10 +194,10 @@ const mfmParams = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
function complete(type: string, value: any) {
function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
emit('done', { type, value });
emit('closed');
if (type === 'emoji') {
if (type === 'emoji' || type === 'emojiComplete') {
let recents = store.s.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value);
@ -243,6 +286,8 @@ function exec() {
}
emojis.value = searchEmoji(props.q.normalize('NFC').toLowerCase(), emojiDb.value);
} else if (props.type === 'emojiComplete') {
emojis.value = searchEmojiExact(props.q.normalize('NFC').toLowerCase(), unicodeEmojiDB.value);
} else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS;

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { http, HttpResponse } from 'msw';
import { action } from '@storybook/addon-actions';
import { chatMessage } from '../../.storybook/fakes';
import MkChatHistories from './MkChatHistories.vue';
import type { StoryObj } from '@storybook/vue3';
import type * as Misskey from 'misskey-js';
export const Default = {
render(args) {
return {
components: {
MkChatHistories,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChatHistories v-bind="props" />',
};
},
parameters: {
layout: 'centered',
msw: {
handlers: [
http.post('/api/chat/history', async ({ request }) => {
const body = await request.json() as Misskey.entities.ChatHistoryRequest;
action('POST /api/chat/history')(body);
return HttpResponse.json([chatMessage(body.room)]);
}),
],
},
},
} satisfies StoryObj<typeof MkChatHistories>;

View file

@ -0,0 +1,208 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="history.length > 0" class="_gaps_s">
<MkA
v-for="item in history"
:key="item.id"
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
class="_panel"
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
>
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
<div :class="$style.messageBody">
<header v-if="item.message.toRoom" :class="$style.messageHeader">
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<header v-else :class="$style.messageHeader">
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
</div>
</MkA>
</div>
<div v-if="!initializing && history.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkLoading v-if="initializing"/>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const history = ref<{
id: string;
message: Misskey.entities.ChatMessage;
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
isMe: boolean;
}[]>([]);
const initializing = ref(true);
const fetching = ref(false);
async function fetchHistory() {
if (fetching.value) return;
fetching.value = true;
const [userMessages, roomMessages] = await Promise.all([
misskeyApi('chat/history', { room: false }),
misskeyApi('chat/history', { room: true }),
]);
history.value = [...userMessages, ...roomMessages]
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map(m => ({
id: m.id,
message: m,
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
isMe: m.fromUserId === $i.id,
}));
fetching.value = false;
initializing.value = false;
}
let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
useInterval(() => {
// TODO: DOM
if (!window.document.hidden && isActivated) {
fetchHistory();
}
}, 1000 * 10, {
immediate: false,
afterMounted: true,
});
onActivated(() => {
fetchHistory();
});
onMounted(() => {
fetchHistory();
});
</script>
<style lang="scss" module>
.message {
position: relative;
display: flex;
padding: 16px 24px;
&.isRead,
&.isMe {
opacity: 0.8;
}
&:not(.isMe):not(.isRead) {
&::before {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 100%;
background-color: var(--MI_THEME-accent);
}
}
}
@container (max-width: 500px) {
.message {
font-size: 90%;
padding: 14px 20px;
}
}
@container (max-width: 450px) {
.message {
font-size: 80%;
padding: 12px 16px;
}
}
.messageAvatar {
width: 50px;
height: 50px;
margin: 0 16px 0 0;
}
@container (max-width: 500px) {
.messageAvatar {
width: 45px;
height: 45px;
}
}
@container (max-width: 450px) {
.messageAvatar {
width: 40px;
height: 40px;
}
}
.messageBody {
flex: 1;
min-width: 0;
}
.messageHeader {
display: flex;
align-items: center;
margin-bottom: 2px;
white-space: nowrap;
overflow: clip;
}
.messageHeaderName {
margin: 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1em;
font-weight: bold;
}
.messageHeaderUsername {
margin: 0 8px;
}
.messageHeaderTime {
margin-left: auto;
}
.messageBodyText {
overflow: hidden;
overflow-wrap: break-word;
font-size: 1.1em;
}
.youSaid {
font-weight: bold;
margin-right: 0.5em;
}
</style>

View file

@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #fallback>
<MkLoading/>
</template>
<XCode v-if="show && lang" :code="code" :lang="lang"/>
<pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
<XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/>
<pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
<div :class="$style.codePlaceholderContainer">
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
@ -70,11 +70,9 @@ function copy() {
.codeBlockFallbackRoot {
display: block;
overflow-wrap: anywhere;
background: var(--MI_THEME-bg);
padding: 1em;
margin: .5em 0;
margin: 0;
overflow: auto;
border-radius: var(--MI-radius-sm);
}
.codeBlockFallbackCode {

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDisableSection from './MkDisableSection.vue';
void MkDisableSection;

View file

@ -640,13 +640,13 @@ function getMenu() {
text: i18n.ts.upload + ' (' + i18n.ts.compress + ')',
icon: 'ti ti-upload',
action: () => {
chooseFileFromPc(true, { keepOriginal: false });
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false });
},
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: () => {
chooseFileFromPc(true, { keepOriginal: true });
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true });
},
}, {
text: i18n.ts.fromUrl,

View file

@ -38,15 +38,26 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<KeepAlive>
<div v-show="opened">
<MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
<slot></slot>
</MkSpacer>
<div v-else>
<slot></slot>
</div>
<div v-if="$slots.footer" :class="$style.footer">
<slot name="footer"></slot>
</div>
<MkStickyContainer>
<template #header>
<div v-if="$slots.header" :class="$style.inBodyHeader">
<slot name="header"></slot>
</div>
</template>
<MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
<slot></slot>
</MkSpacer>
<div v-else>
<slot></slot>
</div>
<template #footer>
<div v-if="$slots.footer" :class="$style.inBodyFooter">
<slot name="footer"></slot>
</div>
</template>
</MkStickyContainer>
</div>
</KeepAlive>
</Transition>
@ -230,14 +241,21 @@ onMounted(() => {
&.bgSame {
background: var(--MI_THEME-bg);
.inBodyHeader {
background: color(from var(--MI_THEME-bg) srgb r g b / 0.75);
}
}
}
.footer {
position: sticky !important;
z-index: 1;
bottom: var(--MI-stickyBottom, 0px);
left: 0;
.inBodyHeader {
background: color(from var(--MI_THEME-panel) srgb r g b / 0.75);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.inBodyFooter {
padding: 12px;
background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));

View file

@ -31,9 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
<span style="opacity: 0.7;">{{ item.text }}</span>
</span>
<div v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label]">
<span>{{ item.text }}</span>
</div>
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
<span><MkEllipsis/></span>
@ -619,12 +619,6 @@ onBeforeUnmount(() => {
--menuActiveBg: var(--MI_THEME-accentedBg);
}
&.label {
pointer-events: none;
font-size: 0.7em;
padding-bottom: 4px;
}
&.pending {
pointer-events: none;
opacity: 0.7;
@ -694,6 +688,19 @@ onBeforeUnmount(() => {
font-size: 12px;
}
.label {
position: relative;
padding: 6px 16px;
box-sizing: border-box;
white-space: nowrap;
font-size: 0.7em;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.7;
pointer-events: none;
}
.divider {
margin: 8px 0;
border-top: solid 0.5px var(--MI_THEME-divider);

View file

@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notes }">
<component
:is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]"
:is="prefer.s.animation ? TransitionGroup : 'div'"
:class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
@ -22,14 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass=" $style.transition_x_move"
tag="div"
>
<template v-for="note in notes" :key="note.id">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]">
<template v-for="(note, i) in notes" :key="note.id">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
</div>
</div>
<DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/>
<DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
</template>
@ -74,6 +75,11 @@ defineExpose({
position: absolute;
}
.reverse {
display: flex;
flex-direction: column-reverse;
}
.root {
container-type: inline-size;

View file

@ -346,6 +346,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
right: -2px;
width: 20px;
height: 20px;
line-height: 20px;
box-sizing: border-box;
border-radius: var(--MI-radius-full);
background: var(--MI_THEME-panel);
@ -360,73 +361,61 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
}
.t_follow, .t_followRequestAccepted, .t_receiveFollowRequest {
padding: 3px;
background: var(--eventFollow);
pointer-events: none;
}
.t_renote {
padding: 3px;
background: var(--eventRenote);
pointer-events: none;
}
.t_quote {
padding: 3px;
background: var(--eventRenote);
pointer-events: none;
}
.t_reply {
padding: 3px;
background: var(--eventReply);
pointer-events: none;
}
.t_mention {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_pollEnded {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_achievementEarned {
padding: 3px;
background: var(--eventAchievement);
pointer-events: none;
}
.t_exportCompleted {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_roleAssigned {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_login {
padding: 3px;
background: var(--eventLogin);
pointer-events: none;
}
.t_createToken {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_chatRoomInvitationReceived {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}

View file

@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
tag="div"
>
<template v-for="(notification, i) in notifications" :key="notification.id">
<DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true"/>
<DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</template>
</component>
</template>

View file

@ -956,7 +956,7 @@ async function post(ev?: MouseEvent) {
if (postAccount.value) {
const storedAccounts = await getAccounts();
token = storedAccounts.find(x => x.user.id === postAccount.value?.id)?.token;
token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token;
}
posting.value = true;

View file

@ -0,0 +1,235 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.tabs">
<div :class="$style.tabsInner">
<button
v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]"
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"
>
<div :class="$style.tabInner">
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
<div
v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)"
:class="$style.tabTitle"
>
{{ t.title }}
</div>
<Transition
v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave"
@afterLeave="afterLeave"
>
<div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div>
</Transition>
</div>
</button>
</div>
<div
ref="tabHighlightEl"
:class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]"
></div>
</div>
</template>
<script lang="ts">
export type Tab = {
key: string;
onClick?: (ev: MouseEvent) => void;
} & (
| {
iconOnly?: false;
title: string;
icon?: string;
}
| {
iconOnly: true;
icon: string;
}
);
</script>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
tabs?: Tab[];
tab?: string;
}>(), {
tabs: () => ([] as Tab[]),
});
const emit = defineEmits<{
(ev: 'update:tab', key: string);
(ev: 'tabClick', key: string);
}>();
const tabHighlightEl = useTemplateRef('tabHighlightEl');
const tabRefs: Record<string, HTMLElement | null> = {};
function onTabMousedown(tab: Tab, ev: MouseEvent): void {
// mousedownonClick
if (tab.key) {
emit('update:tab', tab.key);
}
}
function onTabClick(t: Tab, ev: MouseEvent): void {
emit('tabClick', t.key);
if (t.onClick) {
ev.preventDefault();
ev.stopPropagation();
t.onClick(ev);
}
if (t.key) {
emit('update:tab', t.key);
}
}
function renderTab() {
const tabEl = props.tab ? tabRefs[props.tab] : undefined;
if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) {
// offsetWidth offsetLeft getBoundingClientRect 使
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
const parentRect = tabHighlightEl.value.parentElement.getBoundingClientRect();
const rect = tabEl.getBoundingClientRect();
tabHighlightEl.value.style.width = rect.width + 'px';
tabHighlightEl.value.style.left = (rect.left - parentRect.left + tabHighlightEl.value.parentElement.scrollLeft) + 'px';
}
}
let entering = false;
async function enter(el: Element) {
if (!(el instanceof HTMLElement)) return;
entering = true;
const elementWidth = el.getBoundingClientRect().width;
el.style.width = '0';
el.style.paddingLeft = '0';
el.offsetWidth; // reflow
el.style.width = `${elementWidth}px`;
el.style.paddingLeft = '';
nextTick(() => {
entering = false;
});
window.setTimeout(renderTab, 170);
}
function afterEnter(el: Element) {
if (!(el instanceof HTMLElement)) return;
// element.style.width = '';
}
async function leave(el: Element) {
if (!(el instanceof HTMLElement)) return;
const elementWidth = el.getBoundingClientRect().width;
el.style.width = `${elementWidth}px`;
el.style.paddingLeft = '';
el.offsetWidth; // reflow
el.style.width = '0';
el.style.paddingLeft = '0';
}
function afterLeave(el: Element) {
if (!(el instanceof HTMLElement)) return;
el.style.width = '';
}
onMounted(() => {
watch([() => props.tab, () => props.tabs], () => {
nextTick(() => {
if (entering) return;
renderTab();
});
}, {
immediate: true,
});
});
onUnmounted(() => {
});
</script>
<style lang="scss" module>
.tabs {
--height: 40px;
display: block;
position: relative;
margin: 0;
height: var(--height);
font-size: 85%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabsInner {
display: inline-block;
height: var(--height);
white-space: nowrap;
}
.tab {
display: inline-block;
position: relative;
padding: 0 10px;
height: 100%;
font-weight: normal;
opacity: 0.7;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
&.animate {
transition: opacity 0.2s ease;
}
}
.tabInner {
display: flex;
align-items: center;
}
.tabIcon + .tabTitle {
padding-left: 4px;
}
.tabTitle {
overflow: hidden;
&.animate {
transition: width .15s linear, padding-left .15s linear;
}
}
.tabHighlight {
position: absolute;
bottom: 0;
height: 3px;
background: var(--MI_THEME-accent);
border-radius: 999px;
transition: none;
pointer-events: none;
&.animate {
transition: width 0.15s ease, left 0.15s ease;
}
}
</style>

View file

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<g fill-rule="evenodd">
<rect width="200" height="150" :fill="themeVariables.bg"/>
<rect width="64" height="150" :fill="themeVariables.navBg"/>
<rect x="64" width="136" height="41" :fill="themeVariables.bg"/>
<rect x="64" width="136" height="41" :fill="themeVariables.pageHeaderBg"/>
<path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel"/>
</g>
<circle cx="32" cy="83" r="21" :fill="themeVariables.accentedBg"/>
@ -62,6 +62,7 @@ const themeVariables = ref<{
accent: string;
accentedBg: string;
navBg: string;
pageHeaderBg: string;
success: string;
warn: string;
error: string;
@ -76,6 +77,7 @@ const themeVariables = ref<{
accent: 'var(--MI_THEME-accent)',
accentedBg: 'var(--MI_THEME-accentedBg)',
navBg: 'var(--MI_THEME-navBg)',
pageHeaderBg: 'var(--MI_THEME-pageHeaderBg)',
success: 'var(--MI_THEME-success)',
warn: 'var(--MI_THEME-warn)',
error: 'var(--MI_THEME-error)',
@ -104,6 +106,7 @@ watch(() => props.theme, (theme) => {
accent: compiled.accent ?? 'var(--MI_THEME-accent)',
accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)',
navBg: compiled.navBg ?? 'var(--MI_THEME-navBg)',
pageHeaderBg: compiled.pageHeaderBg ?? 'var(--MI_THEME-pageHeaderBg)',
success: compiled.success ?? 'var(--MI_THEME-success)',
warn: compiled.warn ?? 'var(--MI_THEME-warn)',
error: compiled.error ?? 'var(--MI_THEME-error)',

View file

@ -0,0 +1,173 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.items">
<template v-for="(item, i) in items" :key="item.id">
<div :class="$style.left">
<slot v-if="item.type === 'event'" name="left" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot>
</div>
<div :class="[$style.center, item.type === 'date' ? $style.date : '']">
<div :class="$style.centerLine"></div>
<div :class="$style.centerPoint"></div>
</div>
<div :class="$style.right">
<slot v-if="item.type === 'event'" name="right" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot>
<div v-else :class="$style.dateLabel"><i class="ti ti-chevron-up"></i> {{ item.prevText }}</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{
events: {
id: string;
timestamp: number;
data: any;
}[];
}>();
const events = computed(() => {
return props.events.toSorted((a, b) => b.timestamp - a.timestamp);
});
function getDateText(dateInstance: Date) {
const year = dateInstance.getFullYear();
const month = dateInstance.getMonth() + 1;
const date = dateInstance.getDate();
const hour = dateInstance.getHours();
return `${year.toString()}/${month.toString()}/${date.toString()} ${hour.toString().padStart(2, '0')}:00:00`;
}
const items = computed<({
id: string;
type: 'event';
timestamp: number;
delta: number;
data: any;
} | {
id: string;
type: 'date';
prev: Date;
prevText: string;
next: Date | null;
nextText: string;
})[]>(() => {
const results = [];
for (let i = 0; i < events.value.length; i++) {
const item = events.value[i];
const date = new Date(item.timestamp);
const nextDate = events.value[i + 1] ? new Date(events.value[i + 1].timestamp) : null;
results.push({
id: item.id,
type: 'event',
timestamp: item.timestamp,
delta: i === events.value.length - 1 ? 0 : item.timestamp - events.value[i + 1].timestamp,
data: item.data,
});
if (
i !== events.value.length - 1 &&
nextDate != null && (
date.getFullYear() !== nextDate.getFullYear() ||
date.getMonth() !== nextDate.getMonth() ||
date.getDate() !== nextDate.getDate() ||
date.getHours() !== nextDate.getHours()
)
) {
results.push({
id: `date-${item.id}`,
type: 'date',
prev: date,
prevText: getDateText(date),
next: nextDate,
nextText: getDateText(nextDate),
});
}
}
return results;
});
</script>
<style lang="scss" module>
.root {
}
.items {
display: grid;
grid-template-columns: max-content 18px 1fr;
gap: 0 8px;
}
.item {
}
.center {
position: relative;
&.date {
.centerPoint::before {
position: absolute;
content: "";
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: 7px;
height: 7px;
background: var(--MI_THEME-bg);
border-radius: 50%;
}
}
}
.centerLine {
position: absolute;
top: 0;
left: 0;
right: 0;
margin: auto;
width: 3px;
height: 100%;
background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%);
}
.centerPoint {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: 13px;
height: 13px;
background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%);
border-radius: 50%;
}
.left {
min-width: 0;
align-self: center;
justify-self: right;
}
.right {
min-width: 0;
align-self: center;
}
.dateLabel {
opacity: 0.7;
font-size: 90%;
padding: 4px;
margin: 8px 0;
}
</style>

View file

@ -67,7 +67,7 @@ async function reload(): Promise<void> {
// An additional request is needed to "upgrade" the object.
misskeyApi('users/show', { userId: props.userId }),
// Wait for 1 second to match the animation effects in MkHorizontalSwipe, MkPullToRefresh, and MkPagination.
// Wait for 1 second to match the animation effects in MkSwiper, MkPullToRefresh, and MkPagination.
// Otherwise, the page appears to load "backwards".
new Promise(resolve => window.setTimeout(resolve, 1000)),
])

View file

@ -140,11 +140,18 @@ onUnmounted(() => {
<style lang="scss" module>
.root {
background: color(from var(--MI_THEME-bg) srgb r g b / 0.75);
background: color(from var(--MI_THEME-pageHeaderBg) srgb r g b / 0.75);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-bottom: solid 0.5px var(--MI_THEME-divider);
border-bottom: solid 0.5px transparent;
width: 100%;
color: var(--MI_THEME-pageHeaderFg);
}
@container style(--MI_THEME-pageHeaderBg: var(--MI_THEME-bg)) {
.root {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
}
.upper,

View file

@ -20,6 +20,7 @@ import { useTemplateRef } from 'vue';
import { scrollInContainer } from '@@/js/scroll.js';
import type { PageHeaderItem } from '@/types/page-header.js';
import type { Tab } from './MkPageHeader.tabs.vue';
import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
const props = withDefaults(defineProps<{
tabs?: Tab[];
@ -36,6 +37,8 @@ const props = withDefaults(defineProps<{
const tab = defineModel<string>('tab');
const rootEl = useTemplateRef('rootEl');
useScrollPositionKeeper(rootEl);
defineExpose({
scrollToTop: () => {
if (rootEl.value) scrollInContainer(rootEl.value, { top: 0, behavior: 'smooth' });