From 907d519da36c550dde0a39970057bce22ffdcc5f Mon Sep 17 00:00:00 2001 From: syuilo <Syuilotan@yahoo.co.jp> Date: Sun, 17 Sep 2023 10:33:33 +0900 Subject: [PATCH] enhance(frontend): improve note detail page --- CHANGELOG.md | 5 +- locales/index.d.ts | 4 + locales/ja-JP.yml | 32 +++-- packages/frontend/src/components/MkNote.vue | 18 +-- .../src/components/MkNoteDetailed.vue | 133 ++++++++++++++++-- .../src/components/MkReactedUsersDialog.vue | 104 -------------- .../src/components/MkRenotedUsersDialog.vue | 71 ---------- .../frontend/src/scripts/get-note-menu.ts | 20 --- 8 files changed, 152 insertions(+), 235 deletions(-) delete mode 100644 packages/frontend/src/components/MkReactedUsersDialog.vue delete mode 100644 packages/frontend/src/components/MkRenotedUsersDialog.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index eea57a30f5..34a2f88a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ - ローカリゼーションの更新 ### Client +- ノート詳細ページを改修 + - 読み込み時のパフォーマンスが向上しました + - リノート一覧、リアクション一覧がタブとして追加されました + - ノートのメニューからは当該項目は消えました - プロフィールにその人が作ったPlayの一覧出せるように - メニューのスイッチの動作を改善 - 絵文字ピッカーの検索の表示件数を100件に増加 @@ -48,7 +52,6 @@ - `$[rainbow ]`記法が、動きのあるMFMが無効になっていても使用できるようになりました - Playの操作を行うAPI TokenをAPIコンソールから発行できるように - リアクションの表示サイズをより大きくできるように -- ノート詳細ページ読み込み時のパフォーマンスを改善 - タイムラインでリスト/アンテナ選択時のパフォーマンスを改善 - 「Moderation note」、「Add moderation note」をローカライズできるように - 新しい実績を追加 diff --git a/locales/index.d.ts b/locales/index.d.ts index 94d9657ac8..ac714258e2 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1110,6 +1110,10 @@ export interface Locale { "pastAnnouncements": string; "youHaveUnreadAnnouncements": string; "useSecurityKey": string; + "replies": string; + "renotes": string; + "loadReplies": string; + "loadConversation": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 743814d339..d97b09f63c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -15,7 +15,7 @@ gotIt: "わかった" cancel: "キャンセル" noThankYou: "やめておく" enterUsername: "ユーザー名を入力" -renotedBy: "{user}がRenote" +renotedBy: "{user}がリノート" noNotes: "ノートはありません" noNotifications: "通知はありません" instance: "サーバー" @@ -45,10 +45,10 @@ pin: "ピン留め" unpin: "ピン留め解除" copyContent: "内容をコピー" copyLink: "リンクをコピー" -copyLinkRenote: "Renoteのリンクをコピー" +copyLinkRenote: "リノートのリンクをコピー" delete: "削除" deleteAndEdit: "削除して編集" -deleteAndEditConfirm: "このノートを削除してもう一度編集しますか?このノートへのリアクション、Renote、返信も全て削除されます。" +deleteAndEditConfirm: "このノートを削除してもう一度編集しますか?このノートへのリアクション、リノート、返信も全て削除されます。" addToList: "リストに追加" addToAntenna: "アンテナに追加" sendMessage: "メッセージを送信" @@ -105,13 +105,13 @@ followRequests: "フォロー申請" unfollow: "フォロー解除" followRequestPending: "フォロー許可待ち" enterEmoji: "絵文字を入力" -renote: "Renote" -unrenote: "Renote解除" -renoted: "Renoteしました。" -cantRenote: "この投稿はRenoteできません。" -cantReRenote: "RenoteをRenoteすることはできません。" +renote: "リノート" +unrenote: "リノート解除" +renoted: "リノートしました。" +cantRenote: "この投稿はリノートできません。" +cantReRenote: "リノートをリノートすることはできません。" quote: "引用" -inChannelRenote: "チャンネル内Renote" +inChannelRenote: "チャンネル内リノート" inChannelQuote: "チャンネル内引用" pinnedNote: "ピン留めされたノート" pinned: "ピン留め" @@ -657,7 +657,7 @@ behavior: "動作" sample: "サンプル" abuseReports: "通報" reportAbuse: "通報" -reportAbuseRenote: "Renoteを通報" +reportAbuseRenote: "リノートを通報" reportAbuseOf: "{name}を通報する" fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。" abuseReported: "内容が送信されました。ご報告ありがとうございました。" @@ -691,9 +691,9 @@ manageAccessTokens: "アクセストークンの管理" accountInfo: "アカウント情報" notesCount: "ノートの数" repliesCount: "返信した数" -renotesCount: "Renoteした数" +renotesCount: "リノートした数" repliedCount: "返信された数" -renotedCount: "Renoteされた数" +renotedCount: "リノートされた数" followingCount: "フォロー数" followersCount: "フォロワー数" sentReactionsCount: "リアクションした数" @@ -989,7 +989,7 @@ thisPostMayBeAnnoying: "この投稿は迷惑になる可能性があります thisPostMayBeAnnoyingHome: "ホームに投稿" thisPostMayBeAnnoyingCancel: "やめる" thisPostMayBeAnnoyingIgnore: "このまま投稿" -collapseRenotes: "見たことのあるRenoteを省略して表示" +collapseRenotes: "見たことのあるリノートを省略して表示" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。" copyErrorInfo: "エラー情報をコピー" @@ -1037,7 +1037,7 @@ forceShowAds: "常に広告を表示する" addMemo: "メモを追加" editMemo: "メモを編集" reactionsList: "リアクション一覧" -renotesList: "Renote一覧" +renotesList: "リノート一覧" notificationDisplay: "通知の表示" leftTop: "左上" rightTop: "右上" @@ -1107,6 +1107,10 @@ currentAnnouncements: "現在のお知らせ" pastAnnouncements: "過去のお知らせ" youHaveUnreadAnnouncements: "未読のお知らせがあります。" useSecurityKey: "ブラウザまたはデバイスの指示に従って、セキュリティキーまたはパスキーを使用してください。" +replies: "返信" +renotes: "リノート" +loadReplies: "返信を見る" +loadConversation: "会話を見る" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index bedacbce2a..fdf22c5995 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -86,9 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkReactionsViewer :note="appearNote" :maxNumber="16"> <template #more> - <button class="_button" :class="$style.reactionDetailsButton" @click="showReactions"> - {{ i18n.ts.more }} - </button> + <div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div> </template> </MkReactionsViewer> <footer :class="$style.footer"> @@ -457,7 +455,7 @@ function showRenoteMenu(viaKeyboard = false): void { } else { os.popupMenu([ getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), - null, + null, getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, ], renoteTime.value, { @@ -488,12 +486,6 @@ function readPromo() { }); isDeleted.value = true; } - -function showReactions(): void { - os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), { - noteId: appearNote.id, - }, {}, 'closed'); -} </script> <style lang="scss" module> @@ -941,7 +933,7 @@ function showReactions(): void { opacity: 0.7; } -.reactionDetailsButton { +.reactionOmitted { display: inline-block; height: 32px; margin: 2px; @@ -950,9 +942,5 @@ function showReactions(): void { border-radius: 4px; background: transparent; opacity: .8; - - &:hover { - background: var(--X5); - } } </style> diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index f7578dd390..086667127b 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -11,7 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only v-hotkey="keymap" :class="$style.root" > - <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/> + <div v-if="appearNote.reply.replyId"> + <div v-if="!conversationLoaded" style="padding: 16px"> + <MkButton style="margin: 0 auto;" primary rounded @click="loadConversation">{{ i18n.ts.loadConversation }}</MkButton> + </div> + <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/> + </div> <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="isRenote" :class="$style.renote"> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> @@ -125,7 +130,43 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </footer> </article> - <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/> + <div :class="$style.tabs"> + <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ti ti-arrow-back-up"></i> {{ i18n.ts.replies }}</button> + <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ti ti-repeat"></i> {{ i18n.ts.renotes }}</button> + <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ti ti-icons"></i> {{ i18n.ts.reactions }}</button> + </div> + <div> + <div v-if="tab === 'replies'" :class="$style.tab_replies"> + <div v-if="!repliesLoaded" style="padding: 16px"> + <MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton> + </div> + <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/> + </div> + <div v-else-if="tab === 'renotes'" :class="$style.tab_renotes"> + <MkPagination :pagination="renotesPagination"> + <template #default="{ items }"> + <MkA v-for="item in items" :key="item.id" :to="userPage(item.user)"> + <MkUserCardMini :user="item.user" :withChart="false"/> + </MkA> + </template> + </MkPagination> + </div> + <div v-else-if="tab === 'reactions'" :class="$style.tab_reactions"> + <div :class="$style.reactionTabs"> + <button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction"> + <MkReactionIcon :reaction="reaction"/> + <span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span> + </button> + </div> + <MkPagination :pagination="reactionsPagination"> + <template #default="{ items }"> + <MkA v-for="item in items" :key="item.id" :to="userPage(item.user)"> + <MkUserCardMini :user="item.user" :withChart="false"/> + </MkA> + </template> + </MkPagination> + </div> + </div> </div> <div v-else class="_panel" :class="$style.muted" @click="muted = false"> <I18n :src="i18n.ts.userSaysSomething" tag="small"> @@ -169,6 +210,10 @@ import { claimAchievement } from '@/scripts/achievements'; import { MenuItem } from '@/types/menu'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; +import MkButton from '@/components/MkButton.vue'; const props = defineProps<{ note: Misskey.entities.Note; @@ -224,6 +269,26 @@ const keymap = { 's': () => showContent.value !== showContent.value, }; +let tab = $ref('replies'); +let reactionTabType = $ref(null); + +const renotesPagination = $computed(() => ({ + endpoint: 'notes/renotes', + limit: 10, + params: { + noteId: appearNote.id, + }, +})); + +const reactionsPagination = $computed(() => ({ + endpoint: 'notes/reactions', + limit: 10, + params: { + noteId: appearNote.id, + type: reactionTabType, + }, +})); + useNoteCapture({ rootEl: el, note: $$(appearNote), @@ -426,14 +491,20 @@ function blur() { el.value.blur(); } -os.api('notes/children', { - noteId: appearNote.id, - limit: 30, -}).then(res => { - replies.value = res; -}); +const repliesLoaded = ref(false); +function loadReplies() { + repliesLoaded.value = true; + os.api('notes/children', { + noteId: appearNote.id, + limit: 30, + }).then(res => { + replies.value = res; + }); +} -if (appearNote.replyId) { +const conversationLoaded = ref(false); +function loadConversation() { + conversationLoaded.value = true; os.api('notes/conversation', { noteId: appearNote.replyId, }).then(res => { @@ -640,10 +711,52 @@ if (appearNote.replyId) { } } -.reply { +.reply:not(:first-child) { border-top: solid 0.5px var(--divider); } +.tabs { + border-top: solid 0.5px var(--divider); + border-bottom: solid 0.5px var(--divider); + display: flex; +} + +.tab { + flex: 1; + padding: 12px 8px; + border-top: solid 2px transparent; + border-bottom: solid 2px transparent; +} + +.tabActive { + border-bottom: solid 2px var(--accent); +} + +.tab_renotes { + padding: 16px; +} + +.tab_reactions { + padding: 16px; +} + +.reactionTabs { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.reactionTab { + padding: 4px 6px; + border: solid 1px var(--divider); + border-radius: 6px; +} + +.reactionTabActive { + border-color: var(--accent); +} + @container (max-width: 500px) { .root { font-size: 0.9em; diff --git a/packages/frontend/src/components/MkReactedUsersDialog.vue b/packages/frontend/src/components/MkReactedUsersDialog.vue deleted file mode 100644 index b5f3a634a3..0000000000 --- a/packages/frontend/src/components/MkReactedUsersDialog.vue +++ /dev/null @@ -1,104 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<MkModalWindow - ref="dialog" - :width="400" - :height="450" - @close="dialog.close()" - @closed="emit('closed')" -> - <template #header>{{ i18n.ts.reactionsList }}</template> - - <MkSpacer :marginMin="20" :marginMax="28"> - <div v-if="note" class="_gaps"> - <div v-if="reactions.length === 0" class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.nothing }}</div> - </div> - <template v-else> - <div :class="$style.tabs"> - <button v-for="reaction in reactions" :key="reaction" :class="[$style.tab, { [$style.tabActive]: tab === reaction }]" class="_button" @click="tab = reaction"> - <MkReactionIcon :reaction="reaction"/> - <span style="margin-left: 4px;">{{ note.reactions[reaction] }}</span> - </button> - </div> - <MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()"> - <MkUserCardMini :user="user" :withChart="false"/> - </MkA> - </template> - </div> - <div v-else> - <MkLoading/> - </div> - </MkSpacer> -</MkModalWindow> -</template> - -<script lang="ts" setup> -import { onMounted, watch } from 'vue'; -import * as Misskey from 'misskey-js'; -import MkModalWindow from '@/components/MkModalWindow.vue'; -import MkReactionIcon from '@/components/MkReactionIcon.vue'; -import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import { userPage } from '@/filters/user'; -import { i18n } from '@/i18n'; -import * as os from '@/os'; -import { infoImageUrl } from '@/instance'; - -const emit = defineEmits<{ - (ev: 'closed'): void, -}>(); - -const props = defineProps<{ - noteId: Misskey.entities.Note['id']; -}>(); - -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); - -let note = $ref<Misskey.entities.Note>(); -let tab = $ref<string>(); -let reactions = $ref<string[]>(); -let users = $ref(); - -watch($$(tab), async () => { - const res = await os.api('notes/reactions', { - noteId: props.noteId, - type: tab, - limit: 30, - }); - - users = res.map(x => x.user); -}); - -onMounted(() => { - os.api('notes/show', { - noteId: props.noteId, - }).then((res) => { - reactions = Object.keys(res.reactions); - tab = reactions[0]; - note = res; - }); -}); -</script> - -<style lang="scss" module> -.tabs { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.tab { - padding: 4px 6px; - border: solid 1px var(--divider); - border-radius: 6px; -} - -.tabActive { - border-color: var(--accent); -} -</style> diff --git a/packages/frontend/src/components/MkRenotedUsersDialog.vue b/packages/frontend/src/components/MkRenotedUsersDialog.vue deleted file mode 100644 index 5e6784bb9c..0000000000 --- a/packages/frontend/src/components/MkRenotedUsersDialog.vue +++ /dev/null @@ -1,71 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<MkModalWindow - ref="dialog" - :width="400" - :height="450" - @close="dialog.close()" - @closed="emit('closed')" -> - <template #header>{{ i18n.ts.renotesList }}</template> - - <MkSpacer :marginMin="20" :marginMax="28"> - <div v-if="renotes" class="_gaps"> - <div v-if="renotes.length === 0" class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.nothing }}</div> - </div> - <template v-else> - <MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()"> - <MkUserCardMini :user="user" :withChart="false"/> - </MkA> - </template> - </div> - <div v-else> - <MkLoading/> - </div> - </MkSpacer> -</MkModalWindow> -</template> - -<script lang="ts" setup> -import { onMounted } from 'vue'; -import * as Misskey from 'misskey-js'; -import MkModalWindow from '@/components/MkModalWindow.vue'; -import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import { userPage } from '@/filters/user'; -import { i18n } from '@/i18n'; -import * as os from '@/os'; -import { infoImageUrl } from '@/instance'; - -const emit = defineEmits<{ - (ev: 'closed'): void, -}>(); - -const props = defineProps<{ - noteId: Misskey.entities.Note['id']; -}>(); - -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); - -let note = $ref<Misskey.entities.Note>(); -let renotes = $ref(); -let users = $ref(); - -onMounted(async () => { - const res = await os.api('notes/renotes', { - noteId: props.noteId, - limit: 30, - }); - - renotes = res; - users = res.map(x => x.user); -}); -</script> - -<style lang="scss" module> -</style> diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 5bda993fff..d9fae946d3 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -238,18 +238,6 @@ export function getNoteMenu(props: { os.pageWindow(`/notes/${appearNote.id}`); } - function showReactions(): void { - os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), { - noteId: appearNote.id, - }, {}, 'closed'); - } - - function showRenotes(): void { - os.popup(defineAsyncComponent(() => import('@/components/MkRenotedUsersDialog.vue')), { - noteId: appearNote.id, - }, {}, 'closed'); - } - async function translate(): Promise<void> { if (props.translation.value != null) return; props.translating.value = true; @@ -279,14 +267,6 @@ export function getNoteMenu(props: { icon: 'ti ti-info-circle', text: i18n.ts.details, action: openDetail, - }, { - icon: 'ti ti-repeat', - text: i18n.ts.renotesList, - action: showRenotes, - }, { - icon: 'ti ti-icons', - text: i18n.ts.reactionsList, - action: showReactions, }, { icon: 'ti ti-copy', text: i18n.ts.copyContent,