diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2025-04-24 14:23:45 -0400 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2025-04-24 14:23:45 -0400 |
| commit | a4dd19fdd427a5adc8fa80871d1c742aa9708730 (patch) | |
| tree | 3983a0e772922042043026b4af168e8dd3525fb2 /packages/frontend/src/components | |
| parent | Merge branch 'develop' into merge/2025-03-24 (diff) | |
| parent | enhance(backend): DB note (userId) インデクス -> (userId, id) 複合イ... (diff) | |
| download | sharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.tar.gz sharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.tar.bz2 sharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.zip | |
merge upstream again
Diffstat (limited to 'packages/frontend/src/components')
20 files changed, 815 insertions, 71 deletions
diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index 00bf8e68d9..b3331d742b 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -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, }); } } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 321b043d9e..6608eeaa47 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -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; diff --git a/packages/frontend/src/components/MkChatHistories.stories.impl.ts b/packages/frontend/src/components/MkChatHistories.stories.impl.ts new file mode 100644 index 0000000000..8268adc36f --- /dev/null +++ b/packages/frontend/src/components/MkChatHistories.stories.impl.ts @@ -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>; diff --git a/packages/frontend/src/components/MkChatHistories.vue b/packages/frontend/src/components/MkChatHistories.vue new file mode 100644 index 0000000000..c508ea8451 --- /dev/null +++ b/packages/frontend/src/components/MkChatHistories.vue @@ -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> diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a99ab3bd09..d79ecc7302 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -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 { diff --git a/packages/frontend/src/components/MkDisableSection.stories.impl.ts b/packages/frontend/src/components/MkDisableSection.stories.impl.ts new file mode 100644 index 0000000000..78e556c63e --- /dev/null +++ b/packages/frontend/src/components/MkDisableSection.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDisableSection from './MkDisableSection.vue'; +void MkDisableSection; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 218ce9d93a..cb7c270e91 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -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, diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 5231a577d7..c228853bea 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -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)); diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 8be4792cf8..c2fc967e1d 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -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); diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 6c52db004b..5edae908b0 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -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; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index a4591a35f7..5b2a8dfc5a 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -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; } diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 453a6c83b3..55ee054285 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -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> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 01bc80f397..c530effe4a 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -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; diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkSwiper.vue index 1d0ffaea11..1d0ffaea11 100644 --- a/packages/frontend/src/components/MkHorizontalSwipe.vue +++ b/packages/frontend/src/components/MkSwiper.vue diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue new file mode 100644 index 0000000000..a1f30100d0 --- /dev/null +++ b/packages/frontend/src/components/MkTabs.vue @@ -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 { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + 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> diff --git a/packages/frontend/src/components/MkThemePreview.vue b/packages/frontend/src/components/MkThemePreview.vue index 013ab9d6a4..cc4254a2f6 100644 --- a/packages/frontend/src/components/MkThemePreview.vue +++ b/packages/frontend/src/components/MkThemePreview.vue @@ -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)', diff --git a/packages/frontend/src/components/MkTl.vue b/packages/frontend/src/components/MkTl.vue new file mode 100644 index 0000000000..95cc4d2a2a --- /dev/null +++ b/packages/frontend/src/components/MkTl.vue @@ -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> diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue index fd67464234..b66a33f644 100644 --- a/packages/frontend/src/components/SkUserRecentNotes.vue +++ b/packages/frontend/src/components/SkUserRecentNotes.vue @@ -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)), ]) diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index da518829e2..42bb49e8d9 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -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, diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index cd289d04fb..85e61fd532 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -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' }); |