summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-04-24 14:23:45 -0400
committerHazelnoot <acomputerdog@gmail.com>2025-04-24 14:23:45 -0400
commita4dd19fdd427a5adc8fa80871d1c742aa9708730 (patch)
tree3983a0e772922042043026b4af168e8dd3525fb2 /packages/frontend/src/components
parentMerge branch 'develop' into merge/2025-03-24 (diff)
parentenhance(backend): DB note (userId) インデクス -> (userId, id) 複合イ... (diff)
downloadsharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.tar.gz
sharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.tar.bz2
sharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.zip
merge upstream again
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkAuthConfirm.vue4
-rw-r--r--packages/frontend/src/components/MkAutocomplete.vue77
-rw-r--r--packages/frontend/src/components/MkChatHistories.stories.impl.ts45
-rw-r--r--packages/frontend/src/components/MkChatHistories.vue208
-rw-r--r--packages/frontend/src/components/MkCode.vue8
-rw-r--r--packages/frontend/src/components/MkDisableSection.stories.impl.ts7
-rw-r--r--packages/frontend/src/components/MkDrive.vue4
-rw-r--r--packages/frontend/src/components/MkFolder.vue46
-rw-r--r--packages/frontend/src/components/MkMenu.vue25
-rw-r--r--packages/frontend/src/components/MkNotes.vue14
-rw-r--r--packages/frontend/src/components/MkNotification.vue13
-rw-r--r--packages/frontend/src/components/MkNotifications.vue4
-rw-r--r--packages/frontend/src/components/MkPostForm.vue2
-rw-r--r--packages/frontend/src/components/MkSwiper.vue (renamed from packages/frontend/src/components/MkHorizontalSwipe.vue)0
-rw-r--r--packages/frontend/src/components/MkTabs.vue235
-rw-r--r--packages/frontend/src/components/MkThemePreview.vue5
-rw-r--r--packages/frontend/src/components/MkTl.vue173
-rw-r--r--packages/frontend/src/components/SkUserRecentNotes.vue2
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue11
-rw-r--r--packages/frontend/src/components/global/PageWithHeader.vue3
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' });