From f0fe8eceaf863ca66a2744cabcbbca7407592124 Mon Sep 17 00:00:00 2001 From: Mar0xy <marie@kaifa.ch> Date: Sat, 2 Dec 2023 13:09:25 +0100 Subject: [PATCH] upd: add option to select between note designs Adds the ability to choose between `Sharkey` or `Misskey` --- .../src/components/MkInstanceTicker.vue | 49 +- packages/frontend/src/components/MkNote.vue | 49 +- .../src/components/MkNoteDetailed.vue | 69 +- .../frontend/src/components/MkNoteHeader.vue | 153 +-- .../frontend/src/components/MkNoteSimple.vue | 2 +- .../frontend/src/components/MkNoteSub.vue | 80 +- packages/frontend/src/components/MkNotes.vue | 20 +- .../src/components/SkInstanceTicker.vue | 82 ++ packages/frontend/src/components/SkNote.vue | 1201 +++++++++++++++++ .../src/components/SkNoteDetailed.vue | 1082 +++++++++++++++ .../frontend/src/components/SkNoteHeader.vue | 265 ++++ .../frontend/src/components/SkNoteSimple.vue | 113 ++ .../frontend/src/components/SkNoteSub.vue | 545 ++++++++ packages/frontend/src/pages/note.vue | 7 +- .../frontend/src/pages/settings/general.vue | 6 + packages/frontend/src/store.ts | 4 + 16 files changed, 3404 insertions(+), 323 deletions(-) create mode 100644 packages/frontend/src/components/SkInstanceTicker.vue create mode 100644 packages/frontend/src/components/SkNote.vue create mode 100644 packages/frontend/src/components/SkNoteDetailed.vue create mode 100644 packages/frontend/src/components/SkNoteHeader.vue create mode 100644 packages/frontend/src/components/SkNoteSimple.vue create mode 100644 packages/frontend/src/components/SkNoteSub.vue diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index 4e2856388e..f0650e48f1 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -35,48 +35,51 @@ const faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(p const themeColor = instance.themeColor ?? '#777777'; const bg = { - //background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, - background: `${themeColor}`, + background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, }; </script> <style lang="scss" module> +$height: 2ex; + .root { display: flex; align-items: center; - height: 1.5ex; - border-radius: var(--radius-xl); - margin-top: 5px; - padding: 4px; + height: $height; + border-radius: var(--radius-xs) 0 0 var(--radius-xs); overflow: clip; color: #fff; - text-shadow: -1px -1px 0 var(--bg),1px -1px 0 var(--bg),-1px 1px 0 var(--bg),1px 1px 0 var(--bg) + text-shadow: /* .866 ≈ sin(60deg) */ + 1px 0 1px #000, + .866px .5px 1px #000, + .5px .866px 1px #000, + 0 1px 1px #000, + -.5px .866px 1px #000, + -.866px .5px 1px #000, + -1px 0 1px #000, + -.866px -.5px 1px #000, + -.5px -.866px 1px #000, + 0 -1px 1px #000, + .5px -.866px 1px #000, + .866px -.5px 1px #000; + mask-image: linear-gradient(90deg, + rgb(0,0,0), + rgb(0,0,0) calc(100% - 16px), + rgba(0,0,0,0) 100% + ); } .icon { - height: 2ex; + height: $height; flex-shrink: 0; } .name { margin-left: 4px; line-height: 1; - font-size: 0.8em; + font-size: 0.9em; font-weight: bold; white-space: nowrap; - overflow: hidden; - overflow-wrap: anywhere; - max-width: 300px; - text-overflow: ellipsis; - - &::-webkit-scrollbar { - display: none; - } -} - -@container (max-width: 400px) { - .name { - max-width: 50px; - } + overflow: visible; } </style> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 869164273e..4da8f16df1 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -47,14 +47,11 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/> </div> <article v-else :class="$style.article" @contextmenu.stop="onContextmenu"> - <div style="display: flex; padding-bottom: 10px;"> - <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> - <MkAvatar :class="[$style.avatar, { [$style.avatarReplyTo]: appearNote.reply }]" :user="appearNote.user" :link="!mock" :preview="!mock"/> - <div :class="$style.main"> - <MkNoteHeader :note="appearNote" :mini="true"/> - </div> - </div> - <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> + <MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/> + <div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <MkNoteHeader :note="appearNote" :mini="true" v-on:click.stop/> + <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> @@ -63,6 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]" > <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <Mfm v-if="appearNote.text" :parsedNodes="parsed" @@ -178,6 +176,7 @@ import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import MkButton from '@/components/MkButton.vue'; import { pleaseLogin } from '@/scripts/please-login.js'; import { focusPrev, focusNext } from '@/scripts/focus.js'; @@ -831,7 +830,7 @@ function emitUpdReaction(emoji: string, delta: number) { position: relative; display: flex; align-items: center; - padding: 24px 38px 16px; + padding: 16px 32px 8px 32px; line-height: 28px; white-space: pre; color: var(--renote); @@ -883,7 +882,7 @@ function emitUpdReaction(emoji: string, delta: number) { align-items: center; line-height: 28px; white-space: pre; - padding: 8px 38px 24px; + padding: 0 32px 18px; } .collapsedRenoteTargetAvatar { @@ -910,6 +909,7 @@ function emitUpdReaction(emoji: string, delta: number) { .article { position: relative; + display: flex; padding: 28px 32px; } @@ -926,19 +926,12 @@ function emitUpdReaction(emoji: string, delta: number) { .avatar { flex-shrink: 0; display: block !important; - position: sticky !important; margin: 0 14px 0 0; width: 58px; height: 58px; position: sticky !important; top: calc(22px + var(--stickyTop, 0px)); left: 0; - transition: top 0.5s; - - &.avatarReplyTo { - position: relative !important; - top: 0 !important; - } } .main { @@ -1001,6 +994,7 @@ function emitUpdReaction(emoji: string, delta: number) { .text { overflow-wrap: break-word; + overflow: hidden; } .replyIcon { @@ -1033,8 +1027,7 @@ function emitUpdReaction(emoji: string, delta: number) { .quoteNote { padding: 16px; - // Made border solid, stylistic choice - border: solid 1px var(--renote); + border: dashed 1px var(--renote); border-radius: var(--radius-sm); overflow: clip; } @@ -1074,11 +1067,7 @@ function emitUpdReaction(emoji: string, delta: number) { } .renote { - padding: 24px 28px 16px; - } - - .collapsedRenoteTarget { - padding: 8px 28px 24px; + padding: 12px 26px 0 26px; } .article { @@ -1096,8 +1085,12 @@ function emitUpdReaction(emoji: string, delta: number) { font-size: 0.9em; } + .renote { + padding: 10px 22px 0 22px; + } + .article { - padding: 23px 25px; + padding: 20px 22px; } .footer { @@ -1107,7 +1100,7 @@ function emitUpdReaction(emoji: string, delta: number) { @container (max-width: 480px) { .renote { - padding: 20px 24px 8px; + padding: 8px 16px 0 16px; } .tip { @@ -1115,12 +1108,12 @@ function emitUpdReaction(emoji: string, delta: number) { } .collapsedRenoteTarget { - padding: 8px 24px 20px; + padding: 0 16px 9px; margin-top: 4px; } .article { - padding: 22px 24px; + padding: 14px 16px; } } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 95cbe38b1b..30198d2a6a 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -11,9 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only v-hotkey="keymap" :class="$style.root" > - <div v-if="appearNote.reply && appearNote.reply.replyId && !conversationLoaded" style="padding: 16px"> - <MkButton style="margin: 0 auto;" primary rounded @click="loadConversation">{{ i18n.ts.loadConversation }}</MkButton> + <div v-if="appearNote.reply && 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" :expandAllCws="props.expandAllCws"/> </div> + <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws"/> <div v-if="isRenote" :class="$style.renote"> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <i class="ph-rocket-launch ph-bold ph-lg" style="margin-right: 4px;"></i> @@ -39,29 +43,15 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> </div> </div> - <template v-if="appearNote.reply && appearNote.reply.replyId"> - <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :expandAllCws="props.expandAllCws"/> - </template> - <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws"/> <article :class="$style.note" @contextmenu.stop="onContextmenu"> <header :class="$style.noteHeader"> <MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/> - <div style="display: flex; align-items: center; white-space: nowrap; overflow: hidden;"> - <div :class="$style.noteHeaderBody"> - <div> - <MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)"> - <MkUserName :nowrap="false" :user="appearNote.user"/> - </MkA> - <span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span> - <span v-if="appearNote.user.badgeRoles" :class="$style.badgeRoles"> - <img v-for="role in appearNote.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> - </span> - </div> - <div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div> - </div> - </div> - <div style="display: flex; align-items: flex-end; margin-left: auto;"> - <div :class="$style.noteHeaderBody"> + <div :class="$style.noteHeaderBody"> + <div> + <MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)"> + <MkUserName :nowrap="false" :user="appearNote.user"/> + </MkA> + <span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span> <div :class="$style.noteHeaderInfo"> <span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]"> <i v-if="appearNote.visibility === 'home'" class="ph-house ph-bold ph-lg"></i> @@ -71,8 +61,9 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> </div> - <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> </div> + <div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div> + <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> </div> </header> <div :class="$style.noteContent"> @@ -82,6 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only </p> <div v-show="appearNote.cw == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <Mfm v-if="appearNote.text" :parsedNodes="parsed" @@ -472,7 +464,7 @@ function renote() { if (appearNote.channel?.isSensitive) { visibility = smallerVisibility(visibility, 'home'); } - + os.api('notes/create', { localOnly, visibility, @@ -859,19 +851,12 @@ function animatedMFM() { .noteHeaderInfo { float: right; - text-align: right; } .noteHeaderUsername { margin-bottom: 2px; line-height: 1.3; word-wrap: anywhere; - text-overflow: ellipsis; - white-space: nowrap; - - &::-webkit-scrollbar { - display: none; - } } .playMFMButton { @@ -1052,31 +1037,9 @@ function animatedMFM() { } } -.avatar { - flex-shrink: 0 !important; - display: block !important; - margin: 0 10px 0 0 !important; - width: 40px !important; - height: 40px !important; - border-radius: var(--radius-sm) !important; -} - .muted { padding: 8px; text-align: center; opacity: 0.7; } - -.badgeRoles { - margin: 0 .5em 0 0; -} - -.badgeRole { - height: 1.3em; - vertical-align: -20%; - - & + .badgeRole { - margin-left: 0.2em; - } -} </style> diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 9984c3774d..6121db3f8f 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -4,56 +4,19 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<header v-if="!classic" :class="$style.root"> - <div :class="$style.section"> - <div style="display: flex;"> - <div v-if="mock" :class="$style.name"> - <MkUserName :user="note.user"/> - </div> - <MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)"> - <MkUserName :user="note.user"/> - </MkA> - <div v-if="note.user.isBot" :class="$style.isBot">bot</div> - <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> - <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> - </div> - </div> - <div :class="$style.username"><MkAcct :user="note.user"/></div> - </div> - <div :class="$style.section"> - <div :class="$style.info"> - <div v-if="mock"> - <MkTime :time="note.createdAt" colored/> - </div> - <MkA v-else :class="$style.time" :to="notePage(note)"> - <MkTime :time="note.createdAt" colored/> - </MkA> - <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> - <i v-if="note.visibility === 'home'" class="ph-house ph-bold ph-lg"></i> - <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> - <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> - </span> - <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> - <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> - <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> - </div> - <div :class="$style.info"><MkInstanceTicker v-if="showTicker" style="cursor: pointer;" :instance="note.user.instance" @click.stop="showOnRemote()"/></div> - </div> -</header> -<header v-else :class="$style.classicRoot"> +<header :class="$style.root"> <div v-if="mock" :class="$style.name"> <MkUserName :user="note.user"/> </div> - <MkA v-else v-user-preview="note.user.id" :class="$style.classicName" :to="userPage(note.user)"> + <MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)"> <MkUserName :user="note.user"/> </MkA> <div v-if="note.user.isBot" :class="$style.isBot">bot</div> - <div :class="$style.classicUsername"><MkAcct :user="note.user"/></div> + <div :class="$style.username"><MkAcct :user="note.user"/></div> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> </div> - <MkInstanceTicker v-if="showTicker && !isMobile && defaultStore.state.showTickerOnReplies" style="cursor: pointer; max-height: 5px; top: 3px; position: relative; margin-top: 0px !important;" :instance="note.user.instance" @click.stop="showOnRemote()"/> - <div :class="$style.classicInfo"> + <div :class="$style.info"> <div v-if="mock"> <MkTime :time="note.createdAt" colored/> </div> @@ -73,29 +36,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, shallowRef, ref } from 'vue'; +import { inject, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; -import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import { popupMenu } from '@/os.js'; -import { defaultStore } from '@/store.js'; -import { useRouter } from '@/router.js'; -import { deviceKind } from '@/scripts/device-kind.js'; const props = defineProps<{ note: Misskey.entities.Note; - classic?: boolean; }>(); const menuVersionsButton = shallowRef<HTMLElement>(); -const router = useRouter(); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && props.note.user.instance); - -const MOBILE_THRESHOLD = 500; -const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); async function menuVersions(viaKeyboard = false): Promise<void> { const { menu, cleanup } = await getNoteVersionsMenu({ note: props.note, menuVersionsButton }); @@ -104,67 +57,18 @@ async function menuVersions(viaKeyboard = false): Promise<void> { }).then(focus).finally(cleanup); } -function showOnRemote() { - if (props.note.url ?? props.note.uri === undefined) router.push(notePage(props.note)); - else window.open(props.note.url ?? props.note.uri); -} - const mock = inject<boolean>('mock', false); </script> <style lang="scss" module> .root { - display: flex; - cursor: auto; /* not clickToOpen-able */ -} - -.classicRoot { display: flex; align-items: baseline; white-space: nowrap; cursor: auto; /* not clickToOpen-able */ } -.section { - align-items: flex-start; - white-space: nowrap; - flex-direction: column; - overflow: hidden; - - &:last-child { - display: flex; - align-items: flex-end; - margin-left: auto; - padding-left: 10px; - overflow: clip; - } -} - .name { - flex-shrink: 1; - display: block; - // note, these margin top values were done by hand may need futher checking if it actualy aligns pixel perfect - margin: 3px .5em 0 0; - padding: 0; - overflow: scroll; - overflow-wrap: anywhere; - font-size: 1em; - font-weight: bold; - text-decoration: none; - text-overflow: ellipsis; - max-width: 300px; - - &::-webkit-scrollbar { - display: none; - } - - &:hover { - color: var(--nameHover); - text-decoration: none; - } -} - -.classicName { flex-shrink: 1; display: block; margin: 0 .5em 0 0; @@ -191,20 +95,6 @@ const mock = inject<boolean>('mock', false); } .username { - flex-shrink: 9999999; - // note these top margins were made to align with the instance ticker - margin: 4px .5em 0 0; - overflow: hidden; - text-overflow: ellipsis; - font-size: .95em; - max-width: 300px; - - &::-webkit-scrollbar { - display: none; - } -} - -.classicUsername { flex-shrink: 9999999; margin: 0 .5em 0 0; overflow: hidden; @@ -212,34 +102,11 @@ const mock = inject<boolean>('mock', false); } .info { - &:first-child { - margin-top: 4px; - flex-shrink: 0; - margin-left: auto; - font-size: 0.9em; - } - - &:not(:first-child) { - flex-shrink: 0; - margin-left: auto; - font-size: 0.9em; - } -} - -.classicInfo { flex-shrink: 0; margin-left: auto; font-size: 0.9em; } -.time { - text-decoration: none; - - &:hover { - text-decoration: none; - } -} - .badgeRoles { margin: 0 .5em 0 0; } @@ -252,14 +119,4 @@ const mock = inject<boolean>('mock', false); margin-left: 0.2em; } } - -.danger { - color: var(--accent); - } - - @container (max-width: 500px) { - .name, .username { - max-width: 200px; - } - } </style> diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 8ebd24b322..b1d4ed3f7e 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <MkAvatar :class="$style.avatar" :user="note.user" link preview/> <div :class="$style.main"> - <MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/> + <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <div> <p v-if="note.cw != null" :class="$style.cw"> <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 822b4de83f..3e33c7aa69 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -5,12 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-if="!muted" ref="el" :class="[$style.root, { [$style.children]: depth > 1 }]"> - <div v-if="!hideLine" :class="$style.line"></div> <div :class="$style.main"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="note.user" link preview/> <div :class="$style.body"> - <MkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/> + <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <div :class="$style.content"> <p v-if="note.cw != null" :class="$style.cw"> <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/> @@ -107,7 +106,6 @@ import { getNoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); -const hideLine = computed(() => { return props.detail ? true : false; }); const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -363,7 +361,7 @@ if (props.detail) { <style lang="scss" module> .root { - padding: 28px 32px; + padding: 16px 32px; font-size: 0.9em; position: relative; @@ -373,20 +371,12 @@ if (props.detail) { } } -.line { - position: absolute; - height: 100%; - left: 60px; - // using solid instead of dotted, stylelistic choice - border-left: 2.5px solid rgb(174, 174, 174); -} - .footer { - position: relative; - z-index: 1; - margin-top: 0.4em; - width: max-content; - min-width: max-content; + position: relative; + z-index: 1; + margin-top: 0.4em; + width: max-content; + min-width: max-content; } .main { @@ -406,9 +396,9 @@ if (props.detail) { .avatar { flex-shrink: 0; display: block; - margin: 0 14px 0 0; - width: 58px; - height: 58px; + margin: 0 8px 0 0; + width: 38px; + height: 38px; border-radius: var(--radius-sm); } @@ -421,11 +411,6 @@ if (props.detail) { overflow: hidden; } -.text { - margin: 0; - padding: 0; -} - .header { margin-bottom: 2px; } @@ -445,36 +430,6 @@ if (props.detail) { } } -.reply, .more { - border-left: solid 0.5px var(--divider); - margin-top: 10px; -} - -.more { - padding: 10px 0 0 16px; -} - -@container (max-width: 580px) { - .root { - padding: 28px 26px 0; - } - - .line { - left: 50.5px; - } - - .avatar { - width: 50px; - height: 50px; - } -} - -@container (max-width: 500px) { - .root { - padding: 23px 25px; - } -} - @container (max-width: 400px) { .noteFooterButton { &:not(:last-child) { @@ -514,9 +469,9 @@ if (props.detail) { padding: 10px 0 0 16px; } -@container (max-width: 480px) { +@container (max-width: 450px) { .root { - padding: 22px 24px; + padding: 14px 16px; &.children { padding: 10px 0 0 8px; @@ -524,17 +479,6 @@ if (props.detail) { } } -@container (max-width: 450px) { - .line { - left: 46px; - } - - .avatar { - width: 46px; - height: 46px; - } -} - .muted { text-align: center; padding: 8px !important; diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 76587ce141..0d2f0020d1 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notes }"> <div :class="[$style.root, { [$style.noGap]: noGap }]"> <MkDateSeparatedList + v-if="defaultStore.state.noteDesign === 'misskey'" ref="notes" v-slot="{ item: note }" :items="notes" @@ -26,18 +27,35 @@ SPDX-License-Identifier: AGPL-3.0-only > <MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> </MkDateSeparatedList> + <MkDateSeparatedList + v-else-if="defaultStore.state.noteDesign === 'sharkey'" + ref="notes" + v-slot="{ item: note }" + :items="notes" + :direction="pagination.reversed ? 'up' : 'down'" + :reversed="pagination.reversed" + :noGap="noGap" + :ad="true" + :class="$style.notes" + > + <SkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> + </MkDateSeparatedList> </div> </template> </MkPagination> </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { shallowRef, ref } from 'vue'; import MkNote from '@/components/MkNote.vue'; +import SkNote from '@/components/SkNote.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkPagination, { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; +import { defaultStore } from '@/store.js'; + +console.log(defaultStore.state.noteDesign, defaultStore.state.noteDesign === 'sharkey'); const props = defineProps<{ pagination: Paging; diff --git a/packages/frontend/src/components/SkInstanceTicker.vue b/packages/frontend/src/components/SkInstanceTicker.vue new file mode 100644 index 0000000000..4e2856388e --- /dev/null +++ b/packages/frontend/src/components/SkInstanceTicker.vue @@ -0,0 +1,82 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" :style="bg"> + <img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/> + <div :class="$style.name">{{ instance.name }}</div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { instanceName } from '@/config.js'; +import { instance as Instance } from '@/instance.js'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; + +const props = defineProps<{ + instance?: { + faviconUrl?: string + name: string + themeColor?: string + } +}>(); + +// if no instance data is given, this is for the local instance +const instance = props.instance ?? { + name: instanceName, + themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, +}; + +const faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); + +const themeColor = instance.themeColor ?? '#777777'; + +const bg = { + //background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, + background: `${themeColor}`, +}; +</script> + +<style lang="scss" module> +.root { + display: flex; + align-items: center; + height: 1.5ex; + border-radius: var(--radius-xl); + margin-top: 5px; + padding: 4px; + overflow: clip; + color: #fff; + text-shadow: -1px -1px 0 var(--bg),1px -1px 0 var(--bg),-1px 1px 0 var(--bg),1px 1px 0 var(--bg) +} + +.icon { + height: 2ex; + flex-shrink: 0; +} + +.name { + margin-left: 4px; + line-height: 1; + font-size: 0.8em; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + overflow-wrap: anywhere; + max-width: 300px; + text-overflow: ellipsis; + + &::-webkit-scrollbar { + display: none; + } +} + +@container (max-width: 400px) { + .name { + max-width: 50px; + } +} +</style> diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue new file mode 100644 index 0000000000..924437def5 --- /dev/null +++ b/packages/frontend/src/components/SkNote.vue @@ -0,0 +1,1201 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + v-if="!muted" + v-show="!isDeleted" + ref="el" + v-hotkey="keymap" + :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" + :tabindex="!isDeleted ? '-1' : undefined" +> + <SkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> + <div v-if="pinned" :class="$style.tip"><i class="ph-push-pin ph-bold ph-lg"></i> {{ i18n.ts.pinnedNote }}</div> + <!--<div v-if="appearNote._prId_" class="tip"><i class="ph-megaphone ph-bold ph-lg"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ph-x ph-bold ph-lg"></i></button></div>--> + <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ph-lightning ph-bold ph-lg"></i> {{ i18n.ts.featured }}</div>--> + <div v-if="isRenote" :class="$style.renote"> + <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> + <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> + <i class="ph-rocket-launch ph-bold ph-lg" style="margin-right: 4px;"></i> + <I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText"> + <template #user> + <MkA v-user-preview="note.userId" :class="$style.renoteUserName" :to="userPage(note.user)"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + <div :class="$style.renoteInfo"> + <button ref="renoteTime" :class="$style.renoteTime" class="_button" @click="showRenoteMenu()"> + <i class="ph-dots-three ph-bold ph-lg" :class="$style.renoteMenu"></i> + <MkTime :time="note.createdAt"/> + </button> + <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> + <i v-if="note.visibility === 'home'" class="ph-house ph-bold ph-lg"></i> + <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> + </span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> + <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + </div> + </div> + <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget"> + <MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/> + <Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/> + </div> + <article v-else :class="$style.article" @contextmenu.stop="onContextmenu"> + <div style="display: flex; padding-bottom: 10px;"> + <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> + <MkAvatar :class="[$style.avatar, { [$style.avatarReplyTo]: appearNote.reply }]" :user="appearNote.user" :link="!mock" :preview="!mock"/> + <div :class="$style.main"> + <SkNoteHeader :note="appearNote" :mini="true"/> + </div> + </div> + <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> + <div style="container-type: inline-size;"> + <p v-if="appearNote.cw != null" :class="$style.cw"> + <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> + <MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;" v-on:click.stop/> + </p> + <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]" > + <div :class="$style.text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <Mfm + v-if="appearNote.text" + :parsedNodes="parsed" + :text="appearNote.text" + :author="appearNote.user" + :nyaize="'respect'" + :emojiUrls="appearNote.emojis" + :enableEmojiMenu="true" + :enableEmojiMenuReaction="true" + :isAnim="allowAnim" + /> + <div v-if="translating || translation" :class="$style.translation"> + <MkLoading v-if="translating" mini/> + <div v-else> + <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> + </div> + </div> + <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> + </div> + <div v-if="appearNote.files.length > 0"> + <MkMediaList :mediaList="appearNote.files" v-on:click.stop/> + </div> + <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" v-on:click.stop /> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" v-on:click.stop/> + <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> + <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" v-on:click.stop @click="collapsed = false"> + <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> + </button> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" v-on:click.stop @click="collapsed = true"> + <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> + </button> + </div> + <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> + </div> + <MkReactionsViewer :note="appearNote" :maxNumber="16" v-on:click.stop @mockUpdateMyReaction="emitUpdReaction"> + <template #more> + <div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div> + </template> + </MkReactionsViewer> + <footer :class="$style.footer"> + <button :class="$style.footerButton" class="_button" v-on:click.stop @click="reply()"> + <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> + <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p> + </button> + <button + v-if="canRenote" + ref="renoteButton" + :class="$style.footerButton" + class="_button" + :style="renoted ? 'color: var(--accent) !important;' : ''" + v-on:click.stop + @mousedown="renoted ? undoRenote(appearNote) : renote()" + > + <i class="ph-rocket-launch ph-bold ph-lg"></i> + <p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ appearNote.renoteCount }}</p> + </button> + <button v-else :class="$style.footerButton" class="_button" disabled> + <i class="ph-prohibit ph-bold ph-lg"></i> + </button> + <button + v-if="canRenote && !props.mock" + ref="quoteButton" + :class="$style.footerButton" + class="_button" + v-on:click.stop + @mousedown="quote()" + > + <i class="ph-quotes ph-bold ph-lg"></i> + </button> + <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="like()"> + <i class="ph-heart ph-bold ph-lg"></i> + </button> + <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> + <i v-else class="ph-smiley ph-bold ph-lg"></i> + </button> + <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="undoReact(appearNote)"> + <i class="ph-minus ph-bold ph-lg"></i> + </button> + <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> + <i class="ph-paperclip ph-bold ph-lg"></i> + </button> + <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> + <i class="ph-dots-three ph-bold ph-lg"></i> + </button> + </footer> + </div> + </article> +</div> +<div v-else :class="$style.muted" @click="muted = false"> + <I18n :src="i18n.ts.userSaysSomething" tag="small"> + <template #name> + <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts" setup> +import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent, watch, provide } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import SkNoteSub from '@/components/SkNoteSub.vue'; +import SkNoteHeader from '@/components/SkNoteHeader.vue'; +import SkNoteSimple from '@/components/SkNoteSimple.vue'; +import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkMediaList from '@/components/MkMediaList.vue'; +import MkCwButton from '@/components/MkCwButton.vue'; +import MkPoll from '@/components/MkPoll.vue'; +import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import MkButton from '@/components/MkButton.vue'; +import { pleaseLogin } from '@/scripts/please-login.js'; +import { focusPrev, focusNext } from '@/scripts/focus.js'; +import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { userPage } from '@/filters/user.js'; +import * as os from '@/os.js'; +import { defaultStore, noteViewInterruptors } from '@/store.js'; +import { reactionPicker } from '@/scripts/reaction-picker.js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; +import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; +import { $i } from '@/account.js'; +import { i18n } from '@/i18n.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; +import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; +import { useNoteCapture } from '@/scripts/use-note-capture.js'; +import { deepClone } from '@/scripts/clone.js'; +import { useTooltip } from '@/scripts/use-tooltip.js'; +import { claimAchievement } from '@/scripts/achievements.js'; +import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { MenuItem } from '@/types/menu.js'; +import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { shouldCollapsed } from '@/scripts/collapsed.js'; +import { useRouter } from '@/router.js'; + +const props = withDefaults(defineProps<{ + note: Misskey.entities.Note; + pinned?: boolean; + mock?: boolean; +}>(), { + mock: false, +}); + +provide('mock', props.mock); + +const emit = defineEmits<{ + (ev: 'reaction', emoji: string): void; + (ev: 'removeReaction', emoji: string): void; +}>(); + +const router = useRouter(); + +const inChannel = inject('inChannel', null); +const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); + +let note = $ref(deepClone(props.note)); + +function noteclick(id: string) { + const selection = document.getSelection(); + if (selection?.toString().length === 0) { + router.push(`/notes/${id}`); + } +} + +// plugin +if (noteViewInterruptors.length > 0) { + onMounted(async () => { + let result: Misskey.entities.Note | null = deepClone(note); + for (const interruptor of noteViewInterruptors) { + try { + result = await interruptor.handler(result); + if (result === null) { + isDeleted.value = true; + return; + } + } catch (err) { + console.error(err); + } + } + note = result; + }); +} + +const isRenote = ( + note.renote != null && + note.text == null && + note.fileIds.length === 0 && + note.poll == null +); + +const el = shallowRef<HTMLElement>(); +const menuButton = shallowRef<HTMLElement>(); +const menuVersionsButton = shallowRef<HTMLElement>(); +const renoteButton = shallowRef<HTMLElement>(); +const renoteTime = shallowRef<HTMLElement>(); +const reactButton = shallowRef<HTMLElement>(); +const quoteButton = shallowRef<HTMLElement>(); +const clipButton = shallowRef<HTMLElement>(); +const likeButton = shallowRef<HTMLElement>(); +let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); +const renoteUrl = appearNote.renote ? appearNote.renote.url : null; +const renoteUri = appearNote.renote ? appearNote.renote.uri : null; + +const isMyRenote = $i && ($i.id === note.userId); +const showContent = ref(false); +const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); +const urls = $computed(() => parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null); +const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); +const isLong = shouldCollapsed(appearNote, urls ?? []); +const collapsed = ref(appearNote.cw == null && isLong); +const isDeleted = ref(false); +const renoted = ref(false); +const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const translation = ref<any>(null); +const translating = ref(false); +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id)); +let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); +const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); + +const keymap = { + 'r': () => reply(true), + 'e|a|plus': () => react(true), + 'q': () => renoteButton.value.renote(true), + 'up|k|shift+tab': focusBefore, + 'down|j|tab': focusAfter, + 'esc': blur, + 'm|o': () => menu(true), + 's': () => showContent.value !== showContent.value, +}; + +provide('react', (reaction: string) => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); +}); + +if (props.mock) { + watch(() => props.note, (to) => { + note = deepClone(to); + }, { deep: true }); +} else { + useNoteCapture({ + rootEl: el, + note: $$(appearNote), + pureNote: $$(note), + isDeletedRef: isDeleted, + }); +} + +if (!props.mock) { + useTooltip(renoteButton, async (showing) => { + const renotes = await os.api('notes/renotes', { + noteId: appearNote.id, + limit: 11, + }); + + const users = renotes.map(x => x.user); + + if (users.length < 1) return; + + os.popup(MkUsersTooltip, { + showing, + users, + count: appearNote.renoteCount, + targetElement: renoteButton.value, + }, {}, 'closed'); + }); + + useTooltip(quoteButton, async (showing) => { + const renotes = await os.api('notes/renotes', { + noteId: appearNote.id, + limit: 11, + quote: true, + }); + + const users = renotes.map(x => x.user); + + if (users.length < 1) return; + + os.popup(MkUsersTooltip, { + showing, + users, + count: appearNote.renoteCount, + targetElement: quoteButton.value, + }, {}, 'closed'); + }); + + if ($i) { + os.api("notes/renotes", { + noteId: appearNote.id, + userId: $i.id, + limit: 1, + }).then((res) => { + renoted.value = res.length > 0; + }); + } +} + +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +// defaultStore.state.visibilityがstringなためstringも受け付けている +function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { + if (a === 'specified' || b === 'specified') return 'specified'; + if (a === 'followers' || b === 'followers') return 'followers'; + if (a === 'home' || b === 'home') return 'home'; + // if (a === 'public' || b === 'public') + return 'public'; +} + +function renote() { + pleaseLogin(); + showMovedDialog(); + + if (appearNote.channel) { + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + if (!props.mock) { + os.api('notes/create', { + renoteId: appearNote.id, + channelId: appearNote.channelId, + }).then(() => { + os.toast(i18n.ts.renoted); + renoted.value = true; + }); + } + } else if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) { + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; + const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; + + let visibility = appearNote.visibility; + visibility = smallerVisibility(visibility, configuredVisibility); + if (appearNote.channel?.isSensitive) { + visibility = smallerVisibility(visibility, 'home'); + } + + if (!props.mock) { + os.api('notes/create', { + localOnly, + visibility, + renoteId: appearNote.id, + }).then(() => { + os.toast(i18n.ts.renoted); + renoted.value = true; + }); + } + } +} + +function quote() { + pleaseLogin(); + showMovedDialog(); + if (props.mock) { + return; + } + + if (appearNote.channel) { + os.post({ + renote: appearNote, + channel: appearNote.channel, + }).then(() => { + os.api("notes/renotes", { + noteId: appearNote.id, + userId: $i.id, + limit: 1, + quote: true, + }).then((res) => { + if (!(res.length > 0)) return; + const el = quoteButton.value as HTMLElement | null | undefined; + if (el && res.length > 0) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.toast(i18n.ts.quoted); + }); + }); + } else { + os.post({ + renote: appearNote, + }).then(() => { + os.api("notes/renotes", { + noteId: appearNote.id, + userId: $i.id, + limit: 1, + quote: true, + }).then((res) => { + if (!(res.length > 0)) return; + const el = quoteButton.value as HTMLElement | null | undefined; + if (el && res.length > 0) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.toast(i18n.ts.quoted); + }); + }); + } +} + +function reply(viaKeyboard = false): void { + pleaseLogin(); + if (props.mock) { + return; + } + os.post({ + reply: appearNote, + channel: appearNote.channel, + animation: !viaKeyboard, + }, () => { + focus(); + }); +} + +function like(): void { + pleaseLogin(); + showMovedDialog(); + if (props.mock) { + return; + } + os.api('notes/like', { + noteId: appearNote.id, + override: defaultLike.value, + }); + const el = likeButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } +} + +function react(viaKeyboard = false): void { + pleaseLogin(); + showMovedDialog(); + if (appearNote.reactionAcceptance === 'likeOnly') { + if (props.mock) { + return; + } + + os.api('notes/like', { + noteId: appearNote.id, + override: defaultLike.value, + }); + const el = reactButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + } else { + blur(); + reactionPicker.show(reactButton.value, reaction => { + if (props.mock) { + emit('reaction', reaction); + return; + } + + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + }, () => { + focus(); + }); + } +} + +function undoReact(note): void { + const oldReaction = note.myReaction; + if (!oldReaction) return; + + if (props.mock) { + emit('removeReaction', oldReaction); + return; + } + + os.api('notes/reactions/delete', { + noteId: note.id, + }); +} + +function undoRenote(note) : void { + if (props.mock) { + return; + } + os.api("notes/unrenote", { + noteId: note.id + }); + os.toast(i18n.ts.rmboost); + renoted.value = false; + + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } +} + +function onContextmenu(ev: MouseEvent): void { + if (props.mock) { + return; + } + + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。 + if (el.tagName === 'AUDIO') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(ev.target)) return; + if (window.getSelection().toString() !== '') return; + + if (defaultStore.state.useReactionPickerForContextMenu) { + ev.preventDefault(); + react(); + } else { + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + os.contextMenu(menu, ev).then(focus).finally(cleanup); + } +} + +function menu(viaKeyboard = false): void { + if (props.mock) { + return; + } + + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + os.popupMenu(menu, menuButton.value, { + viaKeyboard, + }).then(focus).finally(cleanup); +} + +async function menuVersions(viaKeyboard = false): Promise<void> { + const { menu, cleanup } = await getNoteVersionsMenu({ note: note, menuVersionsButton }); + os.popupMenu(menu, menuVersionsButton.value, { + viaKeyboard, + }).then(focus).finally(cleanup); +} + +async function clip() { + if (props.mock) { + return; + } + + os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); +} + +function showRenoteMenu(viaKeyboard = false): void { + if (props.mock) { + return; + } + + function getUnrenote(): MenuItem { + return { + text: i18n.ts.unrenote, + icon: 'ph-trash ph-bold ph-lg', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: note.id, + }); + isDeleted.value = true; + }, + }; + } + + if (isMyRenote) { + pleaseLogin(); + os.popupMenu([ + getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), + null, + getUnrenote(), + ], renoteTime.value, { + viaKeyboard: viaKeyboard, + }); + } else { + os.popupMenu([ + getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), + null, + getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), + $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, + ], renoteTime.value, { + viaKeyboard: viaKeyboard, + }); + } +} + +function animatedMFM() { + if (allowAnim.value) { + allowAnim.value = false; + } else { + os.confirm({ + type: 'warning', + text: i18n.ts._animatedMFM._alert.text, + okText: i18n.ts._animatedMFM._alert.confirm, + }).then((res) => { if (!res.canceled) allowAnim.value = true; }); + } +} + +function focus() { + el.value.focus(); +} + +function blur() { + el.value.blur(); +} + +function focusBefore() { + focusPrev(el.value); +} + +function focusAfter() { + focusNext(el.value); +} + +function readPromo() { + os.api('promo/read', { + noteId: appearNote.id, + }); + isDeleted.value = true; +} + +function emitUpdReaction(emoji: string, delta: number) { + if (delta < 0) { + emit('removeReaction', emoji); + } else if (delta > 0) { + emit('reaction', emoji); + } +} +</script> + +<style lang="scss" module> +.root { + position: relative; + transition: box-shadow 0.1s ease; + font-size: 1.05em; + overflow: clip; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + .footer { + position: relative; + z-index: 1; + margin-top: 0.4em; + width: max-content; + min-width: max-content; + } + + &:hover > .article > .main > .footer > .footerButton { + opacity: 1; + } + + &.showActionsOnlyHover { + .footer { + visibility: hidden; + position: absolute; + top: 12px; + right: 12px; + padding: 0 4px; + margin-bottom: 0 !important; + background: var(--popup); + border-radius: var(--radius-sm); + box-shadow: 0px 4px 32px var(--shadow); + } + + .footerButton { + font-size: 90%; + + &:not(:last-child) { + margin-right: 0; + } + } + } + + &.showActionsOnlyHover:hover { + .footer { + visibility: visible; + } + } +} + +.tip { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; +} + +.tip + .article { + padding-top: 8px; +} + +.replyTo { + opacity: 0.7; + padding-bottom: 0; +} + +.renote { + position: relative; + display: flex; + align-items: center; + padding: 24px 38px 16px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + & + .article { + padding-top: 8px; + } + + > .colorBar { + height: calc(100% - 6px); + } +} + +.renoteAvatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; +} + +.renoteText { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.renoteUserName { + font-weight: bold; +} + +.renoteInfo { + margin-left: auto; + font-size: 0.9em; +} + +.renoteTime { + flex-shrink: 0; + color: inherit; +} + +.renoteMenu { + margin-right: 4px; +} + +.collapsedRenoteTarget { + display: flex; + align-items: center; + line-height: 28px; + white-space: pre; + padding: 8px 38px 24px; +} + +.collapsedRenoteTargetAvatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; +} + +.collapsedRenoteTargetText { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 90%; + opacity: 0.7; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +.article { + position: relative; + padding: 28px 32px; +} + +.colorBar { + position: absolute; + top: 8px; + left: 8px; + width: 5px; + height: calc(100% - 16px); + border-radius: var(--radius-ellipse); + pointer-events: none; +} + +.avatar { + flex-shrink: 0; + display: block !important; + position: sticky !important; + margin: 0 14px 0 0; + width: 58px; + height: 58px; + position: sticky !important; + top: calc(22px + var(--stickyTop, 0px)); + left: 0; + transition: top 0.5s; + + &.avatarReplyTo { + position: relative !important; + top: 0 !important; + } +} + +.main { + flex: 1; + min-width: 0; +} + +.cw { + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + +.showLess { + width: 100%; + margin-top: 14px; + position: sticky; + bottom: calc(var(--stickyBottom, 0px) - 100px); +} + +.showLessLabel { + display: inline-block; + background: var(--popup); + padding: 6px 10px; + font-size: 0.8em; + border-radius: var(--radius-ellipse); + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} + +.contentCollapsed { + position: relative; + max-height: 9em; + overflow: clip; +} + +.collapsed { + display: block; + position: absolute; + bottom: 0; + left: 0; + z-index: 2; + width: 100%; + height: 64px; + //background: linear-gradient(0deg, var(--panel), var(--X15)); + + &:hover > .collapsedLabel { + background: var(--panelHighlight); + } +} + +.collapsedLabel { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: var(--radius-ellipse); + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} + +.text { + overflow-wrap: break-word; +} + +.replyIcon { + color: var(--accent); + margin-right: 0.5em; +} + +.translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; +} + +.urlPreview { + margin-top: 8px; +} + +.playMFMButton { + margin-top: 5px; +} + +.poll { + font-size: 80%; +} + +.quote { + padding: 8px 0; +} + +.quoteNote { + padding: 16px; + // Made border solid, stylistic choice + border: solid 1px var(--renote); + border-radius: var(--radius-sm); + overflow: clip; +} + +.channel { + opacity: 0.7; + font-size: 80%; +} + +.footer { + margin-bottom: -14px; +} + +.footerButton { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 1.5em; + } + + &:hover { + color: var(--fgHighlighted); + } +} + +.footerButtonCount { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; +} + +@container (max-width: 580px) { + .root { + font-size: 0.95em; + } + + .renote { + padding: 24px 28px 16px; + } + + .collapsedRenoteTarget { + padding: 8px 28px 24px; + } + + .article { + padding: 24px 26px; + } + + .avatar { + width: 50px; + height: 50px; + } +} + +@container (max-width: 500px) { + .root { + font-size: 0.9em; + } + + .article { + padding: 23px 25px; + } + + .footer { + margin-bottom: -8px; + } +} + +@container (max-width: 480px) { + .renote { + padding: 20px 24px 8px; + } + + .tip { + padding: 8px 16px 0 16px; + } + + .collapsedRenoteTarget { + padding: 8px 24px 20px; + margin-top: 4px; + } + + .article { + padding: 22px 24px; + } +} + +@container (max-width: 450px) { + .avatar { + margin: 0 10px 0 0; + width: 46px; + height: 46px; + top: calc(14px + var(--stickyTop, 0px)); + } +} + +@container (max-width: 400px) { + .root:not(.showActionsOnlyHover) { + .footerButton { + &:not(:last-child) { + margin-right: 0.2em; + } + } + } +} + +@container (max-width: 350px) { + .root:not(.showActionsOnlyHover) { + .footerButton { + &:not(:last-child) { + margin-right: 0.1em; + } + } + } + + .colorBar { + top: 6px; + left: 6px; + width: 4px; + height: calc(100% - 12px); + } +} + +@container (max-width: 300px) { + .avatar { + width: 44px; + height: 44px; + } + + .root:not(.showActionsOnlyHover) { + .footerButton { + &:not(:last-child) { + margin-right: 0.1em; + } + } + } +} + +@container (max-width: 250px) { + .quoteNote { + padding: 12px; + } +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} + +.reactionOmitted { + display: inline-block; + height: 32px; + margin: 2px; + padding: 0 6px; + opacity: .8; +} + +.clickToOpen { + cursor: pointer; +} +</style> diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue new file mode 100644 index 0000000000..91db2047e0 --- /dev/null +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -0,0 +1,1082 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + v-if="!muted" + v-show="!isDeleted" + ref="el" + v-hotkey="keymap" + :class="$style.root" +> + <div v-if="appearNote.reply && appearNote.reply.replyId && !conversationLoaded" style="padding: 16px"> + <MkButton style="margin: 0 auto;" primary rounded @click="loadConversation">{{ i18n.ts.loadConversation }}</MkButton> + </div> + <div v-if="isRenote" :class="$style.renote"> + <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> + <i class="ph-rocket-launch ph-bold ph-lg" style="margin-right: 4px;"></i> + <span :class="$style.renoteText"> + <I18n :src="i18n.ts.renotedBy" tag="span"> + <template #user> + <MkA v-user-preview="note.userId" :class="$style.renoteName" :to="userPage(note.user)"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + </span> + <div :class="$style.renoteInfo"> + <button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()"> + <i v-if="isMyRenote" class="ph-dots-three ph-bold ph-lg" style="margin-right: 4px;"></i> + <MkTime :time="note.createdAt"/> + </button> + <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> + <i v-if="note.visibility === 'home'" class="ph-house ph-bold ph-lg"></i> + <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> + </span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> + </div> + </div> + <template v-if="appearNote.reply && appearNote.reply.replyId"> + <SkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :expandAllCws="props.expandAllCws"/> + </template> + <SkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo" :expandAllCws="props.expandAllCws"/> + <article :class="$style.note" @contextmenu.stop="onContextmenu"> + <header :class="$style.noteHeader"> + <MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/> + <div style="display: flex; align-items: center; white-space: nowrap; overflow: hidden;"> + <div :class="$style.noteHeaderBody"> + <div> + <MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)"> + <MkUserName :nowrap="false" :user="appearNote.user"/> + </MkA> + <span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span> + <span v-if="appearNote.user.badgeRoles" :class="$style.badgeRoles"> + <img v-for="role in appearNote.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> + </span> + </div> + <div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div> + </div> + </div> + <div style="display: flex; align-items: flex-end; margin-left: auto;"> + <div :class="$style.noteHeaderBody"> + <div :class="$style.noteHeaderInfo"> + <span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]"> + <i v-if="appearNote.visibility === 'home'" class="ph-house ph-bold ph-lg"></i> + <i v-else-if="appearNote.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> + <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> + </span> + <span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> + </div> + <SkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> + </div> + </div> + </header> + <div :class="$style.noteContent"> + <p v-if="appearNote.cw != null" :class="$style.cw"> + <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> + <MkCwButton v-model="showContent" :note="appearNote"/> + </p> + <div v-show="appearNote.cw == null || showContent"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <Mfm + v-if="appearNote.text" + :parsedNodes="parsed" + :text="appearNote.text" + :author="appearNote.user" + :nyaize="'respect'" + :emojiUrls="appearNote.emojis" + :enableEmojiMenu="true" + :enableEmojiMenuReaction="true" + :isAnim="allowAnim" + /> + <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> + <div v-if="translating || translation" :class="$style.translation"> + <MkLoading v-if="translating" mini/> + <div v-else> + <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> + </div> + </div> + <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> + <div v-if="appearNote.files.length > 0"> + <MkMediaList :mediaList="appearNote.files"/> + </div> + <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> + <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div> + </div> + <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> + </div> + <footer :class="$style.footer"> + <div :class="$style.noteFooterInfo"> + <div v-if="appearNote.updatedAt"> + {{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/> + </div> + <MkA :to="notePage(appearNote)"> + <MkTime :time="appearNote.createdAt" mode="detail" colored/> + </MkA> + </div> + <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <button class="_button" :class="$style.noteFooterButton" @click="reply()"> + <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> + <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p> + </button> + <button + v-if="canRenote" + ref="renoteButton" + class="_button" + :class="$style.noteFooterButton" + :style="renoted ? 'color: var(--accent) !important;' : ''" + @mousedown="renoted ? undoRenote() : renote()" + > + <i class="ph-rocket-launch ph-bold ph-lg"></i> + <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="_button" :class="$style.noteFooterButton" disabled> + <i class="ph-prohibit ph-bold ph-lg"></i> + </button> + <button + v-if="canRenote" + ref="quoteButton" + class="_button" + :class="$style.noteFooterButton" + @mousedown="quote()" + > + <i class="ph-quotes ph-bold ph-lg"></i> + </button> + <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()"> + <i class="ph-heart ph-bold ph-lg"></i> + </button> + <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> + <i v-else class="ph-smiley ph-bold ph-lg"></i> + </button> + <button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)"> + <i class="ph-minus ph-bold ph-lg"></i> + </button> + <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()"> + <i class="ph-paperclip ph-bold ph-lg"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <i class="ph-dots-three ph-bold ph-lg"></i> + </button> + </footer> + </article> + <div :class="$style.tabs"> + <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ph-arrow-u-up-left ph-bold ph-lg"></i> {{ i18n.ts.replies }}</button> + <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ph-rocket-launch ph-bold ph-lg"></i> {{ i18n.ts.renotes }}</button> + <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'quotes' }]" @click="tab = 'quotes'"><i class="ph-quotes ph-bold ph-lg"></i> {{ i18n.ts._notification._types.quote }}</button> + <button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ph-smiley ph-bold ph-lg"></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> + <SkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws"/> + </div> + <div v-else-if="tab === 'renotes'" :class="$style.tab_renotes"> + <MkPagination :pagination="renotesPagination" :disableAutoLoad="true"> + <template #default="{ items }"> + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;"> + <MkA v-for="item in items" :key="item.id" :to="userPage(item.user)"> + <MkUserCardMini :user="item.user" :withChart="false"/> + </MkA> + </div> + </template> + </MkPagination> + </div> + <div v-if="tab === 'quotes'" :class="$style.tab_replies"> + <div v-if="!quotesLoaded" style="padding: 16px"> + <MkButton style="margin: 0 auto;" primary rounded @click="loadQuotes">{{ i18n.ts.loadReplies }}</MkButton> + </div> + <SkNoteSub v-for="note in quotes" :key="note.id" :note="note" :class="$style.reply" :detail="true" :expandAllCws="props.expandAllCws"/> + </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 v-if="reactionTabType" :key="reactionTabType" :pagination="reactionsPagination" :disableAutoLoad="true"> + <template #default="{ items }"> + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;"> + <MkA v-for="item in items" :key="item.id" :to="userPage(item.user)"> + <MkUserCardMini :user="item.user" :withChart="false"/> + </MkA> + </div> + </template> + </MkPagination> + </div> + </div> +</div> +<div v-else class="_panel" :class="$style.muted" @click="muted = false"> + <I18n :src="i18n.ts.userSaysSomething" tag="small"> + <template #name> + <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts" setup> +import { computed, inject, onMounted, provide, ref, shallowRef, watch } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import SkNoteSub from '@/components/SkNoteSub.vue'; +import SkNoteSimple from '@/components/SkNoteSimple.vue'; +import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkMediaList from '@/components/MkMediaList.vue'; +import MkCwButton from '@/components/MkCwButton.vue'; +import MkPoll from '@/components/MkPoll.vue'; +import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; +import { pleaseLogin } from '@/scripts/please-login.js'; +import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { userPage } from '@/filters/user.js'; +import { notePage } from '@/filters/note.js'; +import * as os from '@/os.js'; +import { defaultStore, noteViewInterruptors } from '@/store.js'; +import { reactionPicker } from '@/scripts/reaction-picker.js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; +import { $i } from '@/account.js'; +import { i18n } from '@/i18n.js'; +import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; +import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; +import { useNoteCapture } from '@/scripts/use-note-capture.js'; +import { deepClone } from '@/scripts/clone.js'; +import { useTooltip } from '@/scripts/use-tooltip.js'; +import { claimAchievement } from '@/scripts/achievements.js'; +import { MenuItem } from '@/types/menu.js'; +import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; +import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +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; + expandAllCws?: boolean; +}>(); + +const inChannel = inject('inChannel', null); + +let note = $ref(deepClone(props.note)); + +// plugin +if (noteViewInterruptors.length > 0) { + onMounted(async () => { + let result: Misskey.entities.Note | null = deepClone(note); + for (const interruptor of noteViewInterruptors) { + try { + result = await interruptor.handler(result); + if (result === null) { + isDeleted.value = true; + return; + } + } catch (err) { + console.error(err); + } + } + note = result; + }); +} + +const isRenote = ( + note.renote != null && + note.text == null && + note.fileIds.length === 0 && + note.poll == null +); + +const el = shallowRef<HTMLElement>(); +const menuButton = shallowRef<HTMLElement>(); +const menuVersionsButton = shallowRef<HTMLElement>(); +const renoteButton = shallowRef<HTMLElement>(); +const renoteTime = shallowRef<HTMLElement>(); +const reactButton = shallowRef<HTMLElement>(); +const quoteButton = shallowRef<HTMLElement>(); +const clipButton = shallowRef<HTMLElement>(); +const likeButton = shallowRef<HTMLElement>(); +let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); +const renoteUrl = appearNote.renote ? appearNote.renote.url : null; +const renoteUri = appearNote.renote ? appearNote.renote.uri : null; + +const isMyRenote = $i && ($i.id === note.userId); +const showContent = ref(false); +const isDeleted = ref(false); +const renoted = ref(false); +const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const translation = ref(null); +const translating = ref(false); +const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); +const urls = parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null; +const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); +const conversation = ref<Misskey.entities.Note[]>([]); +const replies = ref<Misskey.entities.Note[]>([]); +const quotes = ref<Misskey.entities.Note[]>([]); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id); +const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); + +watch(() => props.expandAllCws, (expandAllCws) => { + if (expandAllCws !== showContent.value) showContent.value = expandAllCws; +}); + +if ($i) { + os.api("notes/renotes", { + noteId: appearNote.id, + userId: $i.id, + limit: 1, + }).then((res) => { + renoted.value = res.length > 0; + }); +} + +const keymap = { + 'r': () => reply(true), + 'e|a|plus': () => react(true), + 'q': () => renoteButton.value.renote(true), + 'esc': blur, + 'm|o': () => menu(true), + 's': () => showContent.value !== showContent.value, +}; + +provide('react', (reaction: string) => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); +}); + +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), + pureNote: $$(note), + isDeletedRef: isDeleted, +}); + +useTooltip(renoteButton, async (showing) => { + const renotes = await os.api('notes/renotes', { + noteId: appearNote.id, + limit: 11, + }); + + const users = renotes.map(x => x.user); + + if (users.length < 1) return; + + os.popup(MkUsersTooltip, { + showing, + users, + count: appearNote.renoteCount, + targetElement: renoteButton.value, + }, {}, 'closed'); +}); + +useTooltip(quoteButton, async (showing) => { + const renotes = await os.api('notes/renotes', { + noteId: appearNote.id, + limit: 11, + quote: true, + }); + + const users = renotes.map(x => x.user); + + if (users.length < 1) return; + + os.popup(MkUsersTooltip, { + showing, + users, + count: appearNote.renoteCount, + targetElement: quoteButton.value, + }, {}, 'closed'); +}); + +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { + if (a === 'specified' || b === 'specified') return 'specified'; + if (a === 'followers' || b === 'followers') return 'followers'; + if (a === 'home' || b === 'home') return 'home'; + // if (a === 'public' || b === 'public') + return 'public'; +} + +function renote() { + pleaseLogin(); + showMovedDialog(); + + if (appearNote.channel) { + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.api('notes/create', { + renoteId: appearNote.id, + channelId: appearNote.channelId, + }).then(() => { + os.toast(i18n.ts.renoted); + renoted.value = true; + }); + } else if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) { + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; + const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; + + let visibility = appearNote.visibility; + visibility = smallerVisibility(visibility, configuredVisibility); + if (appearNote.channel?.isSensitive) { + visibility = smallerVisibility(visibility, 'home'); + } + + os.api('notes/create', { + localOnly, + visibility, + renoteId: appearNote.id, + }).then(() => { + os.toast(i18n.ts.renoted); + renoted.value = true; + }); + } +} + +function quote() { + pleaseLogin(); + showMovedDialog(); + + if (appearNote.channel) { + os.post({ + renote: appearNote, + channel: appearNote.channel, + }).then(() => { + os.api("notes/renotes", { + noteId: appearNote.id, + userId: $i.id, + limit: 1, + quote: true, + }).then((res) => { + if (!(res.length > 0)) return; + const el = quoteButton.value as HTMLElement | null | undefined; + if (el && res.length > 0) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.toast(i18n.ts.quoted); + }); + }); + } else { + os.post({ + renote: appearNote, + }).then(() => { + os.api("notes/renotes", { + noteId: appearNote.id, + userId: $i.id, + limit: 1, + quote: true, + }).then((res) => { + if (!(res.length > 0)) return; + const el = quoteButton.value as HTMLElement | null | undefined; + if (el && res.length > 0) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.toast(i18n.ts.quoted); + }); + }); + } +} + +function reply(viaKeyboard = false): void { + pleaseLogin(); + showMovedDialog(); + os.post({ + reply: appearNote, + channel: appearNote.channel, + animation: !viaKeyboard, + }, () => { + focus(); + }); +} + +function react(viaKeyboard = false): void { + pleaseLogin(); + showMovedDialog(); + if (appearNote.reactionAcceptance === 'likeOnly') { + os.api('notes/like', { + noteId: appearNote.id, + override: defaultLike.value, + }); + const el = reactButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + } else { + blur(); + reactionPicker.show(reactButton.value, reaction => { + os.api('notes/reactions/create', { + noteId: appearNote.id, + reaction: reaction, + }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + }, () => { + focus(); + }); + } +} + +function like(): void { + pleaseLogin(); + showMovedDialog(); + os.api('notes/like', { + noteId: appearNote.id, + override: defaultLike.value, + }); + const el = likeButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } +} + +function undoReact(note): void { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id, + }); +} + +function undoRenote() : void { + if (!renoted.value) return; + os.api("notes/unrenote", { + noteId: appearNote.id, + }); + os.toast(i18n.ts.rmboost); + renoted.value = false; + + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } +} + +function onContextmenu(ev: MouseEvent): void { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(ev.target)) return; + if (window.getSelection().toString() !== '') return; + + if (defaultStore.state.useReactionPickerForContextMenu) { + ev.preventDefault(); + react(); + } else { + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }); + os.contextMenu(menu, ev).then(focus).finally(cleanup); + } +} + +function menu(viaKeyboard = false): void { + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }); + os.popupMenu(menu, menuButton.value, { + viaKeyboard, + }).then(focus).finally(cleanup); +} + +async function menuVersions(viaKeyboard = false): Promise<void> { + const { menu, cleanup } = await getNoteVersionsMenu({ note: note, menuVersionsButton }); + os.popupMenu(menu, menuVersionsButton.value, { + viaKeyboard, + }).then(focus).finally(cleanup); +} + +async function clip() { + os.popupMenu(await getNoteClipMenu({ note: note, isDeleted }), clipButton.value).then(focus); +} + +function showRenoteMenu(viaKeyboard = false): void { + if (!isMyRenote) return; + pleaseLogin(); + os.popupMenu([{ + text: i18n.ts.unrenote, + icon: 'ph-trash ph-bold ph-lg', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: note.id, + }); + isDeleted.value = true; + }, + }], renoteTime.value, { + viaKeyboard: viaKeyboard, + }); +} + +function focus() { + el.value.focus(); +} + +function blur() { + el.value.blur(); +} + +const repliesLoaded = ref(false); + +function loadReplies() { + repliesLoaded.value = true; + os.api('notes/children', { + noteId: appearNote.id, + limit: 30, + showQuotes: false, + }).then(res => { + replies.value = res; + }); +} + +loadReplies(); + +const quotesLoaded = ref(false); + +function loadQuotes() { + quotesLoaded.value = true; + os.api('notes/renotes', { + noteId: appearNote.id, + limit: 30, + quote: true, + }).then(res => { + quotes.value = res; + }); +} + +loadQuotes(); + +const conversationLoaded = ref(false); + +function loadConversation() { + conversationLoaded.value = true; + os.api('notes/conversation', { + noteId: appearNote.replyId, + }).then(res => { + conversation.value = res.reverse(); + }); +} + +if (appearNote.reply && appearNote.reply.replyId && defaultStore.state.autoloadConversation) loadConversation(); + +function animatedMFM() { + if (allowAnim.value) { + allowAnim.value = false; + } else { + os.confirm({ + type: 'warning', + text: i18n.ts._animatedMFM._alert.text, + okText: i18n.ts._animatedMFM._alert.confirm, + }).then((res) => { if (!res.canceled) allowAnim.value = true; }); + } +} +</script> + +<style lang="scss" module> +.root { + position: relative; + transition: box-shadow 0.1s ease; + overflow: clip; + contain: content; +} + +.footer { + position: relative; + z-index: 1; + margin-top: 0.4em; + width: max-content; + min-width: min-content; + max-width: fit-content; +} + +.replyTo { + opacity: 0.7; + padding-bottom: 0; +} + +.replyToMore { + opacity: 0.7; +} + +.renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); +} + +.renoteAvatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: var(--radius-sm); +} + +.renoteText { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.renoteName { + font-weight: bold; +} + +.renoteInfo { + margin-left: auto; + font-size: 0.9em; +} + +.renoteTime { + flex-shrink: 0; + color: inherit; +} + +.renote + .note { + padding-top: 8px; +} + +.note { + padding: 32px; + font-size: 1.2em; + overflow: hidden; + + &:hover > .main > .footer > .button { + opacity: 1; + } +} + +.noteHeader { + display: flex; + position: relative; + margin-bottom: 16px; + align-items: center; + z-index: 2; +} + +.noteHeaderAvatar { + display: block; + flex-shrink: 0; + width: 58px; + height: 58px; +} + +.noteHeaderBody { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 16px; + font-size: 0.95em; +} + +.noteHeaderName { + font-weight: bold; + line-height: 1.3; +} + +.isBot { + display: inline-block; + margin: 0 0.5em; + padding: 4px 6px; + font-size: 80%; + line-height: 1; + border: solid 0.5px var(--divider); + border-radius: var(--radius-xs); +} + +.noteHeaderInfo { + float: right; + text-align: right; +} + +.noteHeaderUsername { + margin-bottom: 2px; + line-height: 1.3; + word-wrap: anywhere; + text-overflow: ellipsis; + white-space: nowrap; + + &::-webkit-scrollbar { + display: none; + } +} + +.playMFMButton { + margin-top: 5px; +} + +.noteContent { + container-type: inline-size; + overflow-wrap: break-word; + z-index: 1; +} + +.cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + +.noteReplyTarget { + color: var(--accent); + margin-right: 0.5em; +} + +.rn { + margin-left: 4px; + font-style: oblique; + color: var(--renote); +} + +.translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; +} + +.poll { + font-size: 80%; +} + +.quote { + padding: 8px 0; +} + +.quoteNote { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: var(--radius-sm); + overflow: clip; +} + +.channel { + opacity: 0.7; + font-size: 80%; +} + +.noteFooterInfo { + margin: 16px 0; + opacity: 0.7; + font-size: 0.9em; +} + +.noteFooterButton { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 1.5em; + } + + &:hover { + color: var(--fgHighlighted); + } +} + +.noteFooterButtonCount { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + + &.reacted { + color: var(--accent); + } +} + +.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: var(--radius-sm); +} + +.reactionTabActive { + border-color: var(--accent); +} + +@container (max-width: 500px) { + .root { + font-size: 0.9em; + } +} + +@container (max-width: 450px) { + .renote { + padding: 8px 16px 0 16px; + } + + .note { + padding: 16px; + } + + .noteHeaderAvatar { + width: 50px; + height: 50px; + } +} + +@container (max-width: 350px) { + .noteFooterButton { + &:not(:last-child) { + margin-right: 0.1em; + } + } +} + +@container (max-width: 300px) { + .root { + font-size: 0.825em; + } + + .noteHeaderAvatar { + width: 50px; + height: 50px; + } + + .noteFooterButton { + &:not(:last-child) { + margin-right: 0.1em; + } + } +} + +.avatar { + flex-shrink: 0 !important; + display: block !important; + margin: 0 10px 0 0 !important; + width: 40px !important; + height: 40px !important; + border-radius: var(--radius-sm) !important; +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} + +.badgeRoles { + margin: 0 .5em 0 0; +} + +.badgeRole { + height: 1.3em; + vertical-align: -20%; + + & + .badgeRole { + margin-left: 0.2em; + } +} +</style> diff --git a/packages/frontend/src/components/SkNoteHeader.vue b/packages/frontend/src/components/SkNoteHeader.vue new file mode 100644 index 0000000000..d3ecdf17bb --- /dev/null +++ b/packages/frontend/src/components/SkNoteHeader.vue @@ -0,0 +1,265 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<header v-if="!classic" :class="$style.root"> + <div :class="$style.section"> + <div style="display: flex;"> + <div v-if="mock" :class="$style.name"> + <MkUserName :user="note.user"/> + </div> + <MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)"> + <MkUserName :user="note.user"/> + </MkA> + <div v-if="note.user.isBot" :class="$style.isBot">bot</div> + <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> + <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> + </div> + </div> + <div :class="$style.username"><MkAcct :user="note.user"/></div> + </div> + <div :class="$style.section"> + <div :class="$style.info"> + <div v-if="mock"> + <MkTime :time="note.createdAt" colored/> + </div> + <MkA v-else :class="$style.time" :to="notePage(note)"> + <MkTime :time="note.createdAt" colored/> + </MkA> + <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> + <i v-if="note.visibility === 'home'" class="ph-house ph-bold ph-lg"></i> + <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> + </span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> + <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> + </div> + <div :class="$style.info"><SkInstanceTicker v-if="showTicker" style="cursor: pointer;" :instance="note.user.instance" @click.stop="showOnRemote()"/></div> + </div> +</header> +<header v-else :class="$style.classicRoot"> + <div v-if="mock" :class="$style.name"> + <MkUserName :user="note.user"/> + </div> + <MkA v-else v-user-preview="note.user.id" :class="$style.classicName" :to="userPage(note.user)"> + <MkUserName :user="note.user"/> + </MkA> + <div v-if="note.user.isBot" :class="$style.isBot">bot</div> + <div :class="$style.classicUsername"><MkAcct :user="note.user"/></div> + <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> + <img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/> + </div> + <SkInstanceTicker v-if="showTicker && !isMobile && defaultStore.state.showTickerOnReplies" style="cursor: pointer; max-height: 5px; top: 3px; position: relative; margin-top: 0px !important;" :instance="note.user.instance" @click.stop="showOnRemote()"/> + <div :class="$style.classicInfo"> + <div v-if="mock"> + <MkTime :time="note.createdAt" colored/> + </div> + <MkA v-else :to="notePage(note)"> + <MkTime :time="note.createdAt" colored/> + </MkA> + <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> + <i v-if="note.visibility === 'home'" class="ph-house ph-bold ph-lg"></i> + <i v-else-if="note.visibility === 'followers'" class="ph-lock ph-bold ph-lg"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="ph-envelope ph-bold ph-lg"></i> + </span> + <span v-if="note.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em; cursor: pointer;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil ph-bold ph-lg"></i></span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span> + <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span> + </div> +</header> +</template> + +<script lang="ts" setup> +import { inject, shallowRef, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; +import { notePage } from '@/filters/note.js'; +import { userPage } from '@/filters/user.js'; +import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; +import SkInstanceTicker from '@/components/SkInstanceTicker.vue'; +import { popupMenu } from '@/os.js'; +import { defaultStore } from '@/store.js'; +import { useRouter } from '@/router.js'; +import { deviceKind } from '@/scripts/device-kind.js'; + +const props = defineProps<{ + note: Misskey.entities.Note; + classic?: boolean; +}>(); + +const menuVersionsButton = shallowRef<HTMLElement>(); +const router = useRouter(); +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && props.note.user.instance); + +const MOBILE_THRESHOLD = 500; +const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); + +async function menuVersions(viaKeyboard = false): Promise<void> { + const { menu, cleanup } = await getNoteVersionsMenu({ note: props.note, menuVersionsButton }); + popupMenu(menu, menuVersionsButton.value, { + viaKeyboard, + }).then(focus).finally(cleanup); +} + +function showOnRemote() { + if (props.note.url ?? props.note.uri === undefined) router.push(notePage(props.note)); + else window.open(props.note.url ?? props.note.uri); +} + +const mock = inject<boolean>('mock', false); +</script> + +<style lang="scss" module> +.root { + display: flex; + cursor: auto; /* not clickToOpen-able */ +} + +.classicRoot { + display: flex; + align-items: baseline; + white-space: nowrap; + cursor: auto; /* not clickToOpen-able */ +} + +.section { + align-items: flex-start; + white-space: nowrap; + flex-direction: column; + overflow: hidden; + + &:last-child { + display: flex; + align-items: flex-end; + margin-left: auto; + padding-left: 10px; + overflow: clip; + } +} + +.name { + flex-shrink: 1; + display: block; + // note, these margin top values were done by hand may need futher checking if it actualy aligns pixel perfect + margin: 3px .5em 0 0; + padding: 0; + overflow: scroll; + overflow-wrap: anywhere; + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + max-width: 300px; + + &::-webkit-scrollbar { + display: none; + } + + &:hover { + color: var(--nameHover); + text-decoration: none; + } +} + +.classicName { + flex-shrink: 1; + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } +} + +.isBot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + border: solid 0.5px var(--divider); + border-radius: var(--radius-xs); +} + +.username { + flex-shrink: 9999999; + // note these top margins were made to align with the instance ticker + margin: 4px .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: .95em; + max-width: 300px; + + &::-webkit-scrollbar { + display: none; + } +} + +.classicUsername { + flex-shrink: 9999999; + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.info { + &:first-child { + margin-top: 4px; + flex-shrink: 0; + margin-left: auto; + font-size: 0.9em; + } + + &:not(:first-child) { + flex-shrink: 0; + margin-left: auto; + font-size: 0.9em; + } +} + +.classicInfo { + flex-shrink: 0; + margin-left: auto; + font-size: 0.9em; +} + +.time { + text-decoration: none; + + &:hover { + text-decoration: none; + } +} + +.badgeRoles { + margin: 0 .5em 0 0; +} + +.badgeRole { + height: 1.3em; + vertical-align: -20%; + + & + .badgeRole { + margin-left: 0.2em; + } +} + +.danger { + color: var(--accent); + } + + @container (max-width: 500px) { + .name, .username { + max-width: 200px; + } + } +</style> diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue new file mode 100644 index 0000000000..8ebd24b322 --- /dev/null +++ b/packages/frontend/src/components/SkNoteSimple.vue @@ -0,0 +1,113 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <MkAvatar :class="$style.avatar" :user="note.user" link preview/> + <div :class="$style.main"> + <MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/> + <div> + <p v-if="note.cw != null" :class="$style.cw"> + <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> + <MkCwButton v-model="showContent" :note="note" v-on:click.stop/> + </p> + <div v-show="note.cw == null || showContent"> + <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkNoteHeader from '@/components/MkNoteHeader.vue'; +import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; +import MkCwButton from '@/components/MkCwButton.vue'; +import { $i } from '@/account.js'; + +const props = defineProps<{ + note: Misskey.entities.Note; + expandAllCws?: boolean; + hideFiles?: boolean; +}>(); + +let showContent = $ref(false); + +watch(() => props.expandAllCws, (expandAllCws) => { + if (expandAllCws !== showContent) showContent = expandAllCws; +}); +</script> + +<style lang="scss" module> +.root { + display: flex; + margin: 0; + padding: 0; + font-size: 0.95em; +} + +.avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + position: sticky !important; + top: calc(16px + var(--stickyTop, 0px)); + left: 0; +} + +.main { + flex: 1; + min-width: 0; +} + +.header { + margin-bottom: 2px; + z-index: 2; +} + +.cw { + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + overflow: hidden; +} + +.text { + cursor: default; + margin: 0; + padding: 0; + overflow: hidden; +} + +@container (min-width: 250px) { + .avatar { + margin: 0 10px 0 0; + width: 40px; + height: 40px; + } +} + +@container (min-width: 350px) { + .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } +} + +@container (min-width: 500px) { + .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } +} +</style> diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue new file mode 100644 index 0000000000..aab30e54dd --- /dev/null +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -0,0 +1,545 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="!muted" ref="el" :class="[$style.root, { [$style.children]: depth > 1 }]"> + <div v-if="!hideLine" :class="$style.line"></div> + <div :class="$style.main"> + <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> + <MkAvatar :class="$style.avatar" :user="note.user" link preview/> + <div :class="$style.body"> + <SkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/> + <div :class="$style.content"> + <p v-if="note.cw != null" :class="$style.cw"> + <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/> + <MkCwButton v-model="showContent" :note="note"/> + </p> + <div v-show="note.cw == null || showContent"> + <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation"/> + </div> + </div> + <footer :class="$style.footer"> + <MkReactionsViewer ref="reactionsViewer" :note="note"/> + <button class="_button" :class="$style.noteFooterButton" @click="reply()"> + <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> + <p v-if="note.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ note.repliesCount }}</p> + </button> + <button + v-if="canRenote" + ref="renoteButton" + class="_button" + :class="$style.noteFooterButton" + :style="renoted ? 'color: var(--accent) !important;' : ''" + @mousedown="renoted ? undoRenote() : renote()" + > + <i class="ph-rocket-launch ph-bold ph-lg"></i> + <p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p> + </button> + <button + v-if="canRenote" + ref="quoteButton" + class="_button" + :class="$style.noteFooterButton" + @mousedown="quote()" + > + <i class="ph-quotes ph-bold ph-lg"></i> + </button> + <button v-else class="_button" :class="$style.noteFooterButton" disabled> + <i class="ph-prohibit ph-bold ph-lg"></i> + </button> + <button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @mousedown="like()"> + <i class="ph-heart ph-bold ph-lg"></i> + </button> + <button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()"> + <i v-if="note.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> + <i v-else class="ph-smiley ph-bold ph-lg"></i> + </button> + <button v-if="note.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(note)"> + <i class="ph-minus ph-bold ph-lg"></i> + </button> + <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()"> + <i class="ph-dots-three ph-bold ph-lg"></i> + </button> + </footer> + </div> + </div> + <template v-if="depth < 5"> + <MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1" :expandAllCws="props.expandAllCws"/> + </template> + <div v-else :class="$style.more"> + <MkA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ph-caret-double-right ph-bold ph-lg"></i></MkA> + </div> +</div> +<div v-else :class="$style.muted" @click="muted = false"> + <I18n :src="i18n.ts.userSaysSomething" tag="small"> + <template #name> + <MkA v-user-preview="note.userId" :to="userPage(note.user)"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, shallowRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import SkNoteHeader from '@/components/SkNoteHeader.vue'; +import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; +import MkCwButton from '@/components/MkCwButton.vue'; +import { notePage } from '@/filters/note.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { $i } from '@/account.js'; +import { userPage } from "@/filters/user.js"; +import { checkWordMute } from "@/scripts/check-word-mute.js"; +import { defaultStore } from "@/store.js"; +import { pleaseLogin } from '@/scripts/please-login.js'; +import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import { reactionPicker } from '@/scripts/reaction-picker.js'; +import { claimAchievement } from '@/scripts/achievements.js'; +import type { MenuItem } from '@/types/menu.js'; +import { getNoteMenu } from '@/scripts/get-note-menu.js'; +import { useNoteCapture } from '@/scripts/use-note-capture.js'; + +const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id); +const hideLine = computed(() => { return props.detail ? true : false; }); + +const props = withDefaults(defineProps<{ + note: Misskey.entities.Note; + detail?: boolean; + expandAllCws?: boolean; + + // how many notes are in between this one and the note being viewed in detail + depth?: number; +}>(), { + depth: 1, +}); + +const el = shallowRef<HTMLElement>(); +const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); +const translation = ref<any>(null); +const translating = ref(false); +const isDeleted = ref(false); +const renoted = ref(false); +const reactButton = shallowRef<HTMLElement>(); +const renoteButton = shallowRef<HTMLElement>(); +const quoteButton = shallowRef<HTMLElement>(); +const menuButton = shallowRef<HTMLElement>(); +const likeButton = shallowRef<HTMLElement>(); + +let appearNote = $computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); +const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); + +const isRenote = ( + props.note.renote != null && + props.note.text == null && + props.note.fileIds.length === 0 && + props.note.poll == null +); + +useNoteCapture({ + rootEl: el, + note: $$(appearNote), + isDeletedRef: isDeleted, +}); + +if ($i) { + os.api("notes/renotes", { + noteId: appearNote.id, + userId: $i.id, + limit: 1, + }).then((res) => { + renoted.value = res.length > 0; + }); +} + +function focus() { + el.value.focus(); +} + +function reply(viaKeyboard = false): void { + pleaseLogin(); + showMovedDialog(); + os.post({ + reply: props.note, + channel: props.note.channel, + animation: !viaKeyboard, + }, () => { + focus(); + }); +} + +function react(viaKeyboard = false): void { + pleaseLogin(); + showMovedDialog(); + if (props.note.reactionAcceptance === 'likeOnly') { + os.api('notes/like', { + noteId: props.note.id, + override: defaultLike.value, + }); + const el = reactButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + } else { + blur(); + reactionPicker.show(reactButton.value, reaction => { + os.api('notes/reactions/create', { + noteId: props.note.id, + reaction: reaction, + }); + if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + }, () => { + focus(); + }); + } +} + +function like(): void { + pleaseLogin(); + showMovedDialog(); + os.api('notes/like', { + noteId: props.note.id, + override: defaultLike.value, + }); + const el = reactButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } +} + +function undoReact(note): void { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id, + }); +} + +function undoRenote() : void { + if (!renoted.value) return; + os.api("notes/unrenote", { + noteId: appearNote.id, + }); + os.toast(i18n.ts.rmboost); + renoted.value = false; + + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } +} + +let showContent = $ref(false); + +watch(() => props.expandAllCws, (expandAllCws) => { + if (expandAllCws !== showContent) showContent = expandAllCws; +}); + +let replies: Misskey.entities.Note[] = $ref([]); + +function renote() { + pleaseLogin(); + showMovedDialog(); + + if (appearNote.channel) { + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.api('notes/create', { + renoteId: props.note.id, + channelId: props.note.channelId, + }).then(() => { + os.toast(i18n.ts.renoted); + renoted.value = true; + }); + } else { + const el = renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.api('notes/create', { + renoteId: props.note.id, + }).then(() => { + os.toast(i18n.ts.renoted); + renoted.value = true; + }); + } +} + +function quote() { + pleaseLogin(); + showMovedDialog(); + + if (appearNote.channel) { + os.post({ + renote: appearNote, + channel: appearNote.channel, + }).then(() => { + os.api("notes/renotes", { + noteId: props.note.id, + userId: $i.id, + limit: 1, + quote: true, + }).then((res) => { + if (!(res.length > 0)) return; + const el = quoteButton.value as HTMLElement | null | undefined; + if (el && res.length > 0) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.toast(i18n.ts.quoted); + }); + }); + } else { + os.post({ + renote: appearNote, + }).then(() => { + os.api("notes/renotes", { + noteId: props.note.id, + userId: $i.id, + limit: 1, + quote: true, + }).then((res) => { + if (!(res.length > 0)) return; + const el = quoteButton.value as HTMLElement | null | undefined; + if (el && res.length > 0) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + os.toast(i18n.ts.quoted); + }); + }); + } +} + +function menu(viaKeyboard = false): void { + const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, menuButton, isDeleted }); + os.popupMenu(menu, menuButton.value, { + viaKeyboard, + }).then(focus).finally(cleanup); +} + +if (props.detail) { + os.api('notes/children', { + noteId: props.note.id, + limit: 5, + }).then(res => { + replies = res; + }); +} +</script> + +<style lang="scss" module> +.root { + padding: 28px 32px; + font-size: 0.9em; + position: relative; + + &.children { + padding: 10px 0 0 16px; + font-size: 1em; + } +} + +.line { + position: absolute; + height: 100%; + left: 60px; + // using solid instead of dotted, stylelistic choice + border-left: 2.5px solid rgb(174, 174, 174); +} + +.footer { + position: relative; + z-index: 1; + margin-top: 0.4em; + width: max-content; + min-width: max-content; +} + +.main { + display: flex; +} + +.colorBar { + position: absolute; + top: 8px; + left: 8px; + width: 5px; + height: calc(100% - 8px); + border-radius: var(--radius-ellipse); + pointer-events: none; +} + +.avatar { + flex-shrink: 0; + display: block; + margin: 0 14px 0 0; + width: 58px; + height: 58px; + border-radius: var(--radius-sm); +} + +.body { + flex: 1; + min-width: 0; +} + +.content { + overflow: hidden; +} + +.text { + margin: 0; + padding: 0; +} + +.header { + margin-bottom: 2px; +} + +.noteFooterButton { + margin: 0; + padding: 8px; + padding-top: 10px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 1.5em; + } + + &:hover { + color: var(--fgHighlighted); + } +} + +.reply, .more { + border-left: solid 0.5px var(--divider); + margin-top: 10px; +} + +.more { + padding: 10px 0 0 16px; +} + +@container (max-width: 580px) { + .root { + padding: 28px 26px 0; + } + + .line { + left: 50.5px; + } + + .avatar { + width: 50px; + height: 50px; + } +} + +@container (max-width: 500px) { + .root { + padding: 23px 25px; + } +} + +@container (max-width: 400px) { + .noteFooterButton { + &:not(:last-child) { + margin-right: 0.7em; + } + } +} + +.noteFooterButtonCount { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + + &.reacted { + color: var(--accent); + } +} + +.cw { + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + +.text { + margin: 0; + padding: 0; +} + +.reply, .more { + border-left: solid 0.5px var(--divider); + margin-top: 10px; +} + +.more { + padding: 10px 0 0 16px; +} + +@container (max-width: 480px) { + .root { + padding: 22px 24px; + + &.children { + padding: 10px 0 0 8px; + } + } +} + +@container (max-width: 450px) { + .line { + left: 46px; + } + + .avatar { + width: 46px; + height: 46px; + } +} + +.muted { + text-align: center; + padding: 8px !important; + border: 1px solid var(--divider); + margin: 8px 8px 0 8px; + border-radius: var(--radius-sm); +} +</style> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index c43b5f900f..9bafa17005 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -16,10 +16,14 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_margin"> <MkButton v-if="!showNext" :class="$style.loadNext" @click="showNext = true"><i class="ph-caret-up ph-bold ph-lg"></i></MkButton> - <div class="_margin _gaps_s"> + <div v-if="defaultStore.state.noteDesign === 'misskey'" class="_margin _gaps_s"> <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> <MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note" :expandAllCws="expandAllCws"/> </div> + <div v-else-if="defaultStore.state.noteDesign === 'sharkey'" class="_margin _gaps_s"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> + <SkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note" :expandAllCws="expandAllCws"/> + </div> <div v-if="clips && clips.length > 0" class="_margin"> <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> <div class="_gaps"> @@ -48,6 +52,7 @@ import { computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import MkNotes from '@/components/MkNotes.vue'; +import SkNoteDetailed from '@/components/SkNoteDetailed.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index c55728dc77..51be3b21b5 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -59,6 +59,11 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="medium">{{ i18n.ts.medium }}</option> <option value="large">{{ i18n.ts.large }}</option> </MkRadios> + <MkRadios v-model="noteDesign"> + <template #label>Note Design</template> + <option value="sharkey">Sharkey</option> + <option value="misskey">Misskey</option> + </MkRadios> </div> <MkSelect v-model="instanceTicker"> @@ -273,6 +278,7 @@ const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn')); const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications')); const showTickerOnReplies = computed(defaultStore.makeGetterSetter('showTickerOnReplies')); +const noteDesign = computed(defaultStore.makeGetterSetter('noteDesign')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 9443e8b210..e823e84eb6 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -254,6 +254,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + noteDesign: { + where: 'device', + default: 'sharkey' as 'sharkey' | 'misskey', + }, enableInfiniteScroll: { where: 'device', default: true,