diff --git a/packages/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue index 34b1a79831..0b3012da24 100644 --- a/packages/client/src/components/MkNote.vue +++ b/packages/client/src/components/MkNote.vue @@ -10,10 +10,12 @@ :aria-label="accessibleLabel" class="tkcbzcuz note-container" :tabindex="!isDeleted ? '-1' : undefined" - :class="{ renote: isRenote || (renotesSliced && renotesSliced.length > 0) }" + :class="{ renote: isRenote || (renotes && renotes.length > 0) }" > <MkNoteSub - v-if="appearNote.reply && !detailedView && !collapsedReply && !parents" + v-if=" + appearNote.reply && !detailedView && !collapsedReply && !parents + " :note="appearNote.reply" class="reply-to" /> @@ -32,106 +34,11 @@ }" @click="noteClick" > - <div v-if="!collapsedReply" class="line"></div> - <div v-if="appearNote._prId_" class="info"> - <i :class="icon('ph-megaphone-simple-bold')"></i> - {{ i18n.ts.promotion - }}<button class="_textButton hide" @click.stop="readPromo()"> - {{ i18n.ts.hideThisNote }} - <i :class="icon('ph-x')"></i> - </button> - </div> - <div v-if="appearNote._featuredId_" class="info"> - <i :class="icon('ph-lightning')"></i> - {{ i18n.ts.featured }} - </div> - <div v-if="pinned" class="info"> - <i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }} - </div> - <div v-if="collapsedReply && appearNote.reply" class="info"> - <MkAvatar class="avatar" :user="appearNote.reply.user" /> - <MkUserName - class="username" - :user="appearNote.reply.user" - ></MkUserName> - <Mfm - class="summary" - :text="getNoteSummary(appearNote.reply)" - :plain="true" - :nowrap="true" - :lang="appearNote.reply.lang" - :custom-emojis="note.emojis" - /> - </div> - <div v-if="isRenote || (renotesSliced && renotesSliced.length > 0)" class="renote"> - <i :class="icon('ph-rocket-launch')"></i> - <I18n - v-if="renotesSliced == null" - :src="i18n.ts.renotedBy" - tag="span" - > - <template #user> - <MkAvatar class="avatar" :user="note.user" /> - <MkA - v-user-preview="note.userId" - class="name" - :to="userPage(note.user)" - @click.stop - > - <MkUserName :user="note.user" /> - </MkA> - </template> - </I18n> - <I18n - v-else - :src="i18n.ts.renotedBy" - tag="span" - > - <template #user> - <template - v-for="(renote, index) in renotesSliced" - > - <MkAvatar - class="avatar" - :user="renote.user" - /> - <MkA - v-user-preview="renote.userId" - class="name" - :to="userPage(renote.user)" - @click.stop - > - <MkUserName :user="renote.user" /> - </MkA> - {{ - index !== renotesSliced.length - 1 - ? ", " - : renotesSliced.length < renotes!.length - ? "..." - : "" - }} - </template> - </template> - </I18n> - <div class="info"> - <button - ref="renoteTime" - class="_button time" - @click.stop="showRenoteMenu()" - > - <i - v-if="isMyNote" - :class="icon('ph-dots-three-outline dropdownIcon')" - ></i> - <MkTime - v-if="(renotesSliced && renotesSliced.length > 0)" - :time="renotesSliced[0].createdAt" - /> - <MkTime v-else :time="note.createdAt" /> - </button> - <MkVisibility :note="note" /> - </div> - </div> + <XNoteHeaderInfo v-bind="{ appearNote, note, collapsedReply, pinned }" /> + <XRenoteBar + v-bind="{ appearNote, note, isRenote, renotes }" + @deleted="isDeleted = true" + /> </div> <article class="article" @@ -154,7 +61,7 @@ /> </div> <div class="body"> - <MkSubNoteContent + <XNoteContent class="text" :note="appearNote" :detailed="true" @@ -164,148 +71,22 @@ @push="(e) => router.push(notePage(e))" @focusfooter="footerEl!.focus()" @expanded="(e) => setPostExpanded(e)" - ></MkSubNoteContent> - <div v-if="translating || translation" class="translation"> - <MkLoading v-if="translating" mini /> - <div v-else-if="translation != null" class="translated"> - <b - >{{ - i18n.t("translatedFrom", { - x: translation.sourceLang, - }) - }}: - </b> - <Mfm - :text="translation.text" - :author="appearNote.user" - :i="me" - :lang="targetLang" - :custom-emojis="appearNote.emojis" - /> - </div> - </div> + ></XNoteContent> + <XNoteTranslation ref="noteTranslation" :note="note"/> </div> - <div - v-if="detailedView || (appearNote.channel && !inChannel)" - class="info" - > - <MkA - v-if="detailedView" - class="created-at" - :to="notePage(appearNote)" - > - <MkTime v-if="appearNote.scheduledAt != null" :time="appearNote.scheduledAt"/> - <MkTime v-else :time="appearNote.createdAt" mode="absolute" /> - </MkA> - <MkA - v-if="appearNote.channel && !inChannel" - class="channel" - :to="`/channels/${appearNote.channel.id}`" - @click.stop - ><i :class="icon('ph-television', false)"></i> - {{ appearNote.channel.name }}</MkA - > - </div> - <footer - v-show="!hideFooter" - ref="footerEl" + <XNoteFooterInfo class="info" :note="appearNote" :detailedView /> + <XNoteFooter class="footer" - tabindex="-1" - > - <XReactionsViewer - v-if="enableEmojiReactions && !hideEmojiViewer" - ref="reactionsViewer" - :note="appearNote" - /> - <button - v-tooltip.noDelay.bottom="i18n.ts.reply" - class="button _button" - @click.stop="reply()" - :disabled="note.scheduledAt != null" - > - <i :class="icon('ph-arrow-u-up-left')"></i> - <template - v-if="appearNote.repliesCount > 0 && !detailedView" - > - <p class="count">{{ appearNote.repliesCount }}</p> - </template> - </button> - <XRenoteButton - ref="renoteButton" - class="button" - :note="appearNote" - :count="appearNote.renoteCount" - :detailed-view="detailedView" - :disabled="note.scheduledAt != null" - /> - <XStarButtonNoEmoji - v-if="!enableEmojiReactions" - class="button" - :note="appearNote" - :count="reactionCount" - :reacted="appearNote.myReaction != null" - :disabled="note.scheduledAt != null" - /> - <XStarButton - v-if=" - enableEmojiReactions && - appearNote.myReaction == null - " - ref="starButton" - class="button" - :note="appearNote" - :disabled="note.scheduledAt != null" - /> - <button - v-if=" - enableEmojiReactions && - appearNote.myReaction == null - " - ref="reactButton" - v-tooltip.noDelay.bottom="i18n.ts.reaction" - class="button _button" - @click.stop="react()" - :disabled="note.scheduledAt != null" - > - <i :class="icon('ph-smiley')"></i> - <p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p> - </button> - <button - v-if=" - enableEmojiReactions && - appearNote.myReaction != null - " - ref="reactButton" - v-tooltip.noDelay.bottom="i18n.ts.removeReaction" - class="button _button reacted" - @click.stop="undoReact(appearNote)" - :disabled="note.scheduledAt != null" - > - <i :class="icon('ph-minus')"></i> - <p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p> - </button> - <XQuoteButton class="button" :note="appearNote" :disabled="note.scheduledAt != null"/> - <button - v-if=" - isSignedIn(me) && - isForeignLanguage && - translation == null - " - v-tooltip.noDelay.bottom="i18n.ts.translate" - class="button _button" - @click.stop="translate" - > - <i :class="icon('ph-translate')"></i> - </button> - <button - ref="menuButton" - v-tooltip.noDelay.bottom="i18n.ts.more" - class="button _button" - @click.stop="menu()" - > - <i :class="icon('ph-dots-three-outline')"></i> - </button> - </footer> + ref="footerEl" + :note="appearNote" + :enableEmojiReactions + :hideEmojiViewer + :detailedView + :note-translation="noteTranslation!" + @deleted="isDeleted = true" + @event:focus="focus" + @event:blur="blur" + /> </div> </article> </div> @@ -333,39 +114,29 @@ </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, watch } from "vue"; -import type { Ref } from "vue"; +import { computed, onMounted, ref, watch } from "vue"; import type { entities } from "firefish-js"; -import MkSubNoteContent from "./MkSubNoteContent.vue"; +import XNoteContent from "@/components/note/MkNoteContent.vue"; import MkNoteSub from "@/components/MkNoteSub.vue"; -import XNoteHeader from "@/components/MkNoteHeader.vue"; -import XRenoteButton from "@/components/MkRenoteButton.vue"; -import XReactionsViewer from "@/components/MkReactionsViewer.vue"; -import XStarButton from "@/components/MkStarButton.vue"; -import XStarButtonNoEmoji from "@/components/MkStarButtonNoEmoji.vue"; -import XQuoteButton from "@/components/MkQuoteButton.vue"; -import MkVisibility from "@/components/MkVisibility.vue"; -import copyToClipboard from "@/scripts/copy-to-clipboard"; -import { detectLanguage } from "@/scripts/language-utils"; -import { url } from "@/config"; -import { pleaseLogin } from "@/scripts/please-login"; +import XNoteHeader from "@/components/note/MkNoteHeader.vue"; import { focusNext, focusPrev } from "@/scripts/focus"; import { getWordSoftMute } from "@/scripts/check-word-mute"; import { useRouter } from "@/router"; import { userPage } from "@/filters/user"; -import * as os from "@/os"; import { defaultStore, noteViewInterruptors } from "@/store"; -import { reactionPicker } from "@/scripts/reaction-picker"; -import { isSignedIn, me } from "@/me"; +import { me } from "@/me"; import { i18n } from "@/i18n"; -import { getNoteMenu } from "@/scripts/get-note-menu"; import { useNoteCapture } from "@/scripts/use-note-capture"; import { notePage } from "@/filters/note"; import { deepClone } from "@/scripts/clone"; -import { getNoteSummary } from "@/scripts/get-note-summary"; -import icon from "@/scripts/icon"; -import type { NoteTranslation, NoteType } from "@/types/note"; +import type { NoteType } from "@/types/note"; import { isDeleted as _isDeleted, isRenote as _isRenote } from "@/scripts/note"; +import XNoteHeaderInfo from "@/components/note/MkNoteHeaderInfo.vue"; +import XNoteFooterInfo from "@/components/note/MkNoteFooterInfo.vue"; +import XRenoteBar from "@/components/note/MkRenoteBar.vue"; +import XNoteFooter from "./note/MkNoteFooter.vue"; +import XNoteTranslation from "./note/MkNoteTranslation.vue"; +import { showNoteContextMenu } from "@/scripts/show-note-context-menu"; const props = defineProps<{ note: NoteType; @@ -381,42 +152,28 @@ const props = defineProps<{ // #region Constants const router = useRouter(); -const inChannel = inject("inChannel", null); const keymap = { - r: () => reply(true), - "e|a|plus": () => react(true), - q: () => renoteButton.value!.renote(true), + r: () => footerEl.value!.reply(true), + "e|a|plus": () => footerEl.value!.react(true), + q: () => footerEl.value!.renote(true), "up|k": focusBefore, "down|j": focusAfter, esc: blur, - "m|o": () => menu(true), + "m|o": () => footerEl.value!.menu(true), // FIXME: What's this? // s: () => showContent.value !== showContent.value, }; const el = ref<HTMLElement | null>(null); -const footerEl = ref<HTMLElement>(); -const menuButton = ref<HTMLElement>(); -const starButton = ref<InstanceType<typeof XStarButton>>(); -const renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null); -const renoteTime = ref<HTMLElement>(); -const reactButton = ref<HTMLElement | null>(null); +const footerEl = ref<InstanceType<typeof XNoteFooter> | null>(null); const enableEmojiReactions = defaultStore.reactiveState.enableEmojiReactions; const expandOnNoteClick = defaultStore.reactiveState.expandOnNoteClick; -const lang = localStorage.getItem("lang"); -const translateLang = localStorage.getItem("translateLang"); -const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2); -const currentClipPage = inject<Ref<entities.Clip> | null>( - "currentClipPage", - null, -); +const noteTranslation = ref<InstanceType<typeof XNoteTranslation> | null>(null); // #endregion // #region Variables bound to Notes let capture: ReturnType<typeof useNoteCapture> | undefined; const note = ref(deepClone(props.note)); const postIsExpanded = ref(false); -const translation = ref<NoteTranslation | null>(null); -const translating = ref(false); const isDeleted = ref(false); const renotes = ref(props.renotes?.filter((rn) => !_isDeleted(rn.id))); const muted = ref( @@ -430,31 +187,10 @@ const muted = ref( // #endregion // #region computed - -const renotesSliced = computed(() => renotes.value?.slice(0, 5)); - const isRenote = computed(() => _isRenote(note.value)); const appearNote = computed(() => isRenote.value ? (note.value.renote as NoteType) : note.value, ); -const isMyNote = computed( - () => isSignedIn(me) && me.id === note.value.userId && props.renotes == null, -); -const isForeignLanguage = computed( - () => - defaultStore.state.detectPostLanguage && - appearNote.value.text != null && - (() => { - const postLang = detectLanguage(appearNote.value.text); - return postLang !== "" && postLang !== targetLang; - })(), -); -const reactionCount = computed(() => - Object.values(appearNote.value.reactions).reduce( - (partialSum, val) => partialSum + val, - 0, - ), -); const accessibleLabel = computed(() => { let label = `${appearNote.value.user.username}; `; if (appearNote.value.renote) { @@ -507,9 +243,6 @@ async function init(newNote: NoteType, first = false) { note.value = deepClone(newNote); } } - - translation.value = null; - translating.value = false; postIsExpanded.value = false; isDeleted.value = _isDeleted(note.value.id); if (appearNote.value.historyId == null) { @@ -574,33 +307,6 @@ watch( ); watch(() => props.renotes?.length, recalculateRenotes); -async function translate_(noteId: string, targetLang: string) { - return await os.api("notes/translate", { - noteId, - targetLang, - }); -} - -async function translate() { - if (translation.value != null) return; - translating.value = true; - translation.value = await translate_( - appearNote.value.id, - translateLang || lang || navigator.language, - ); - - // use UI language as the second translation language - if ( - translateLang != null && - lang != null && - translateLang !== lang && - (!translation.value || - translation.value.sourceLang.toLowerCase() === translateLang.slice(0, 2)) - ) - translation.value = await translate_(appearNote.value.id, lang); - translating.value = false; -} - function softMuteReasonI18nSrc(what?: string) { if (what === "note") return i18n.ts.userSaysSomethingReason; if (what === "reply") return i18n.ts.userSaysSomethingReasonReply; @@ -611,152 +317,12 @@ function softMuteReasonI18nSrc(what?: string) { return i18n.ts.userSaysSomething; } -function reply(_viaKeyboard = false): void { - pleaseLogin(); - os.post( - { - reply: appearNote.value, - // animation: !viaKeyboard, - }, - () => { - focus(); - }, - ); -} - -function react(_viaKeyboard = false): void { - pleaseLogin(); - blur(); - reactionPicker.show( - reactButton.value!, - (reaction) => { - os.api("notes/reactions/create", { - noteId: appearNote.value.id, - reaction, - }); - }, - () => { - focus(); - }, - ); -} - -function undoReact(note: NoteType): void { - const oldReaction = note.myReaction; - if (!oldReaction) return; - os.api("notes/reactions/delete", { - noteId: note.id, - }); -} - function onContextmenu(ev: MouseEvent): void { - const isLink = (el: HTMLElement): boolean => { - if (el.tagName === "A") return true; - // The Audio element's context menu is the browser default, such as for selecting playback speed. - if (el.tagName === "AUDIO") return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - return false; - }; - if (isLink(ev.target as HTMLElement)) return; - if (window.getSelection()?.toString() !== "") return; - - if (defaultStore.state.useReactionPickerForContextMenu) { - ev.preventDefault(); - react(); - } else { - os.contextMenu( - [ - { - type: "label", - text: notePage(appearNote.value), - }, - { - icon: `${icon("ph-browser")}`, - text: i18n.ts.openInWindow, - action: () => { - os.pageWindow(notePage(appearNote.value)); - }, - }, - notePage(appearNote.value) !== location.pathname - ? { - icon: `${icon("ph-arrows-out-simple")}`, - text: i18n.ts.showInPage, - action: () => { - router.push(notePage(appearNote.value), "forcePage"); - }, - } - : undefined, - null, - { - type: "a", - icon: `${icon("ph-arrow-square-out")}`, - text: i18n.ts.openInNewTab, - href: notePage(appearNote.value), - target: "_blank", - }, - { - icon: `${icon("ph-link-simple")}`, - text: i18n.ts.copyLink, - action: () => { - copyToClipboard(`${url}${notePage(appearNote.value)}`); - os.success(); - }, - }, - appearNote.value.user.host != null - ? { - type: "a", - icon: `${icon("ph-arrow-square-up-right")}`, - text: i18n.ts.showOnRemote, - href: appearNote.value.url ?? appearNote.value.uri ?? "", - target: "_blank", - } - : undefined, - ], - ev, - ); - } -} - -function menu(viaKeyboard = false): void { - os.popupMenu( - getNoteMenu({ - note: note.value, - translating, - translation, - menuButton, - isDeleted, - currentClipPage, - }), - menuButton.value, - { - viaKeyboard, - }, - ).then(focus); -} - -function showRenoteMenu(viaKeyboard = false): void { - if (!isMyNote.value) return; - os.popupMenu( - [ - { - text: i18n.ts.unrenote, - icon: `${icon("ph-trash")}`, - danger: true, - action: () => { - os.api("notes/delete", { - noteId: note.value.id, - }); - isDeleted.value = true; - }, - }, - ], - renoteTime.value, - { - viaKeyboard, - }, - ); + showNoteContextMenu({ + ev, + note: appearNote.value, + react: footerEl.value!.react, + }); } function focus() { @@ -791,13 +357,6 @@ function noteClick(e) { } } -function readPromo() { - os.api("promo/read", { - noteId: appearNote.value.id, - }); - isDeleted.value = true; -} - function setPostExpanded(val: boolean) { postIsExpanded.value = val; } @@ -900,71 +459,6 @@ defineExpose({ > div > i { margin-left: -0.5px; } - > .info { - display: flex; - align-items: center; - font-size: 90%; - white-space: pre; - color: #f6c177; - - > i { - margin-right: 4px; - } - - > .hide { - margin-left: auto; - color: inherit; - } - } - - > .renote { - display: flex; - align-items: center; - white-space: pre; - color: var(--renote); - cursor: pointer; - - > i { - margin-right: 4px; - } - - .avatar { - width: 1.2em; - height: 1.2em; - border-radius: 2em; - overflow: hidden; - margin-right: 0.4em; - background: var(--panelHighlight); - transform: translateY(-4px); - } - - > span { - overflow: hidden; - flex-shrink: 1; - text-overflow: ellipsis; - white-space: nowrap; - - > .name { - font-weight: bold; - } - } - - > .info { - margin-left: auto; - font-size: 0.9em; - display: flex; - - > .time { - flex-shrink: 0; - color: inherit; - display: inline-flex; - align-items: center; - > .dropdownIcon { - margin-right: 4px; - } - } - } - } &.collapsedReply { .line { @@ -1054,12 +548,6 @@ defineExpose({ > .body { margin-top: 0.7em; - > .translation { - border: solid 0.5px var(--divider); - border-radius: var(--radius); - padding: 12px; - margin-top: 8px; - } > .renote { padding-top: 8px; > * { @@ -1074,74 +562,6 @@ defineExpose({ } } } - > .info { - display: flex; - justify-content: space-between; - flex-wrap: wrap; - gap: 0.7em; - margin-top: 16px; - opacity: 0.7; - font-size: 0.9em; - } - > .footer { - position: relative; - z-index: 2; - display: flex; - flex-wrap: wrap; - margin-top: 0.4em; - > :deep(.button) { - position: relative; - margin: 0; - padding: 8px; - opacity: 0.7; - &:disabled { - opacity: 0.3 !important; - } - flex-grow: 1; - max-width: 3.5em; - width: max-content; - min-width: max-content; - height: auto; - transition: opacity 0.2s; - &::before { - content: ""; - position: absolute; - inset: 0; - bottom: 2px; - background: var(--panel); - z-index: -1; - transition: background 0.2s; - } - &:first-of-type { - margin-left: -0.5em; - &::before { - border-radius: 100px 0 0 100px; - } - } - &:last-of-type { - &::before { - border-radius: 0 100px 100px 0; - } - } - &:hover { - color: var(--fgHighlighted); - } - - > i { - display: inline !important; - } - - > .count { - display: inline; - margin: 0 0 0 8px; - opacity: 0.7; - } - - &.reacted { - color: var(--accent); - } - } - } } } diff --git a/packages/client/src/components/MkNoteDetailed.vue b/packages/client/src/components/MkNoteDetailed.vue index 7dcb055d47..c8092d3de4 100644 --- a/packages/client/src/components/MkNoteDetailed.vue +++ b/packages/client/src/components/MkNoteDetailed.vue @@ -238,7 +238,7 @@ const repliesPagingComponent = ref<MkPaginationType<"notes/replies"> | null>( ); const el = ref<HTMLElement | null>(null); -const noteEl = ref(); +const noteEl = ref<InstanceType<typeof MkNote> | null>(null); const menuButton = ref<HTMLElement>(); const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); const reactButton = ref<HTMLElement>(); @@ -361,11 +361,11 @@ function menu(viaKeyboard = false): void { } function focus() { - noteEl.value.focus(); + noteEl.value?.focus(); } function blur() { - noteEl.value.blur(); + noteEl.value?.blur(); } conversation.value = null; @@ -418,12 +418,12 @@ document.addEventListener("wheel", () => { onMounted(() => { isScrolling = false; - noteEl.value.scrollIntoView(); + noteEl.value?.scrollIntoView(); }); onUpdated(() => { if (!isScrolling) { - noteEl.value.scrollIntoView(); + noteEl.value?.scrollIntoView(); if (location.hash) { location.replace(location.hash); // Jump to highlighted reply } diff --git a/packages/client/src/components/MkNoteSimple.vue b/packages/client/src/components/MkNoteSimple.vue index 92d8a77696..97c3a973c4 100644 --- a/packages/client/src/components/MkNoteSimple.vue +++ b/packages/client/src/components/MkNoteSimple.vue @@ -9,7 +9,7 @@ <div class="main"> <XNoteHeader class="header" :note="note" :mini="true" /> <div class="body"> - <MkSubNoteContent class="text" :note="note" /> + <XNoteContent class="text" :note="note" /> </div> </div> </div> @@ -18,8 +18,8 @@ <script lang="ts" setup> import type { entities } from "firefish-js"; import { computed, ref, watch } from "vue"; -import XNoteHeader from "@/components/MkNoteHeader.vue"; -import MkSubNoteContent from "@/components/MkSubNoteContent.vue"; +import XNoteHeader from "@/components/note/MkNoteHeader.vue"; +import XNoteContent from "@/components/note/MkNoteContent.vue"; import { deepClone } from "@/scripts/clone"; import { useNoteCapture } from "@/scripts/use-note-capture"; import { isDeleted } from "@/scripts/note"; diff --git a/packages/client/src/components/MkNoteSub.vue b/packages/client/src/components/MkNoteSub.vue index f3448d73e3..b2886fbb6c 100644 --- a/packages/client/src/components/MkNoteSub.vue +++ b/packages/client/src/components/MkNoteSub.vue @@ -30,7 +30,7 @@ <div class="body"> <XNoteHeader class="header" :note="note" :mini="true" /> <div class="body"> - <MkSubNoteContent + <XNoteContent class="text" :note="note" :parent-id="parentId" @@ -38,112 +38,20 @@ :detailed-view="detailedView" @focusfooter="footerEl!.focus()" /> - <div v-if="translating || translation" class="translation"> - <MkLoading v-if="translating || translation == null" mini /> - <div v-else class="translated"> - <b - >{{ - i18n.t("translatedFrom", { - x: translation.sourceLang, - }) - }}: - </b> - <Mfm - :text="translation.text" - :author="appearNote.user" - :i="me" - :lang="targetLang" - :custom-emojis="appearNote.emojis" - /> - </div> - </div> + <XNoteTranslation ref="noteTranslation" :note="note"/> </div> - <footer ref="footerEl" class="footer" tabindex="-1"> - <XReactionsViewer - v-if="enableEmojiReactions && !hideEmojiViewer" - ref="reactionsViewer" - :note="appearNote" - /> - <button - v-tooltip.noDelay.bottom="i18n.ts.reply" - class="button _button" - @click.stop="reply()" - > - <i :class="icon('ph-arrow-u-up-left')"></i> - <template v-if="appearNote.repliesCount > 0"> - <p class="count">{{ appearNote.repliesCount }}</p> - </template> - </button> - <XRenoteButton - ref="renoteButton" - class="button" - :note="appearNote" - :count="appearNote.renoteCount" - /> - <XStarButtonNoEmoji - v-if="!enableEmojiReactions" - class="button" - :note="appearNote" - :count="reactionCount" - :reacted="appearNote.myReaction != null" - /> - <XStarButton - v-if=" - enableEmojiReactions && - appearNote.myReaction == null - " - ref="starButton" - class="button" - :note="appearNote" - /> - <button - v-if=" - enableEmojiReactions && - appearNote.myReaction == null - " - ref="reactButton" - v-tooltip.noDelay.bottom="i18n.ts.reaction" - class="button _button" - @click.stop="react()" - > - <i :class="icon('ph-smiley')"></i> - <p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p> - </button> - <button - v-if=" - enableEmojiReactions && - appearNote.myReaction != null - " - ref="reactButton" - v-tooltip.noDelay.bottom="i18n.ts.removeReaction" - class="button _button reacted" - @click.stop="undoReact(appearNote)" - > - <i :class="icon('ph-minus')"></i> - <p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p> - </button> - <XQuoteButton class="button" :note="appearNote" /> - <button - v-if=" - isSignedIn(me) && - isForeignLanguage && - translation == null - " - v-tooltip.noDelay.bottom="i18n.ts.translate" - class="button _button" - @click.stop="translate" - > - <i :class="icon('ph-translate')"></i> - </button> - <button - ref="menuButton" - v-tooltip.noDelay.bottom="i18n.ts.more" - class="button _button" - @click.stop="menu()" - > - <i :class="icon('ph-dots-three-outline')"></i> - </button> - </footer> + <XNoteFooter + class="footer" + ref="footerEl" + :note="appearNote" + :enableEmojiReactions + :hideEmojiViewer + :detailedView + :note-translation="noteTranslation!" + @deleted="isDeleted = true" + @event:focus="focus" + @event:blur="blur" + /> </div> </div> <MkLoading v-if="conversationLoading" /> @@ -200,34 +108,24 @@ </template> <script lang="ts" setup> -import { computed, inject, ref, watch } from "vue"; -import type { Ref } from "vue"; +import { computed, ref, watch } from "vue"; import type { entities } from "firefish-js"; -import XNoteHeader from "@/components/MkNoteHeader.vue"; -import MkSubNoteContent from "@/components/MkSubNoteContent.vue"; -import XReactionsViewer from "@/components/MkReactionsViewer.vue"; -import XStarButton from "@/components/MkStarButton.vue"; -import XStarButtonNoEmoji from "@/components/MkStarButtonNoEmoji.vue"; -import XRenoteButton from "@/components/MkRenoteButton.vue"; -import XQuoteButton from "@/components/MkQuoteButton.vue"; -import copyToClipboard from "@/scripts/copy-to-clipboard"; -import { detectLanguage } from "@/scripts/language-utils"; -import { url } from "@/config"; -import { pleaseLogin } from "@/scripts/please-login"; -import { getNoteMenu } from "@/scripts/get-note-menu"; +import XNoteHeader from "@/components/note/MkNoteHeader.vue"; +import XNoteContent from "@/components/note/MkNoteContent.vue"; import { getWordSoftMute } from "@/scripts/check-word-mute"; import { notePage } from "@/filters/note"; import { useRouter } from "@/router"; import { userPage } from "@/filters/user"; import * as os from "@/os"; -import { reactionPicker } from "@/scripts/reaction-picker"; -import { isSignedIn, me } from "@/me"; +import { me } from "@/me"; import { i18n } from "@/i18n"; import { useNoteCapture } from "@/scripts/use-note-capture"; import { defaultStore } from "@/store"; import { deepClone } from "@/scripts/clone"; import icon from "@/scripts/icon"; -import type { NoteTranslation } from "@/types/note"; +import XNoteFooter from "./note/MkNoteFooter.vue"; +import XNoteTranslation from "./note/MkNoteTranslation.vue"; +import { showNoteContextMenu } from "@/scripts/show-note-context-menu"; const router = useRouter(); @@ -312,11 +210,8 @@ const isRenote = note.value.poll == null; const el = ref<HTMLElement | null>(null); -const footerEl = ref<HTMLElement | null>(null); -const menuButton = ref<HTMLElement>(); -const starButton = ref<InstanceType<typeof XStarButton> | null>(null); -const renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null); -const reactButton = ref<HTMLElement | null>(null); +const noteTranslation = ref<InstanceType<typeof XNoteTranslation> | null>(null); +const footerEl = ref<InstanceType<typeof XNoteFooter> | null>(null); const appearNote = computed(() => isRenote ? (note.value.renote as entities.Note) : note.value, ); @@ -329,55 +224,8 @@ const muted = ref( defaultStore.state.mutedLangs, ), ); -const translation = ref<NoteTranslation | null>(null); -const translating = ref(false); const enableEmojiReactions = defaultStore.state.enableEmojiReactions; const expandOnNoteClick = defaultStore.state.expandOnNoteClick; -const lang = localStorage.getItem("lang"); -const translateLang = localStorage.getItem("translateLang"); -const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2); - -const reactionCount = computed(() => - Object.values(appearNote.value.reactions).reduce( - (partialSum, val) => partialSum + val, - 0, - ), -); - -const isForeignLanguage: boolean = - defaultStore.state.detectPostLanguage && - appearNote.value.text != null && - (() => { - const postLang = detectLanguage(appearNote.value.text); - return postLang !== "" && postLang !== targetLang; - })(); - -async function translate_(noteId, targetLang: string) { - return await os.api("notes/translate", { - noteId, - targetLang, - }); -} - -async function translate() { - if (translation.value != null) return; - translating.value = true; - translation.value = await translate_( - appearNote.value.id, - translateLang || lang || navigator.language, - ); - - // use UI language as the second translation language - if ( - translateLang != null && - lang != null && - translateLang !== lang && - (!translation.value || - translation.value.sourceLang.toLowerCase() === translateLang.slice(0, 2)) - ) - translation.value = await translate_(appearNote.value.id, lang); - translating.value = false; -} useNoteCapture({ rootEl: el, @@ -393,129 +241,12 @@ useNoteCapture({ }, }); -function reply(_viaKeyboard = false): void { - pleaseLogin(); - os.post({ - reply: appearNote.value, - // animation: !viaKeyboard, - }).then(() => { - focus(); - }); -} - -function react(_viaKeyboard = false): void { - pleaseLogin(); - blur(); - reactionPicker.show( - reactButton.value!, - (reaction) => { - os.api("notes/reactions/create", { - noteId: appearNote.value.id, - reaction, - }); - }, - () => { - focus(); - }, - ); -} - -function undoReact(note): void { - const oldReaction = note.myReaction; - if (!oldReaction) return; - os.api("notes/reactions/delete", { - noteId: note.id, - }); -} - -const currentClipPage = inject<Ref<entities.Clip> | null>( - "currentClipPage", - null, -); - -function menu(viaKeyboard = false): void { - os.popupMenu( - getNoteMenu({ - note: note.value, - translating, - translation, - menuButton, - isDeleted, - currentClipPage, - }), - menuButton.value, - { - viaKeyboard, - }, - ).then(focus); -} - function onContextmenu(ev: MouseEvent): void { - const isLink = (el: HTMLElement | null) => { - if (el == null) return; - if (el.tagName === "A") return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(ev.target as HTMLElement | null)) return; - if (window.getSelection()?.toString() !== "") return; - - if (defaultStore.state.useReactionPickerForContextMenu) { - ev.preventDefault(); - react(); - } else { - os.contextMenu( - [ - { - type: "label", - text: notePage(appearNote.value), - }, - { - icon: `${icon("ph-browser")}`, - text: i18n.ts.openInWindow, - action: () => { - os.pageWindow(notePage(appearNote.value)); - }, - }, - notePage(appearNote.value) !== location.pathname - ? { - icon: `${icon("ph-arrows-out-simple")}`, - text: i18n.ts.showInPage, - action: () => { - router.push(notePage(appearNote.value), "forcePage"); - }, - } - : undefined, - null, - { - type: "a", - icon: `${icon("ph-arrow-square-out")}`, - text: i18n.ts.openInNewTab, - href: notePage(appearNote.value), - target: "_blank", - }, - { - icon: `${icon("ph-link-simple")}`, - text: i18n.ts.copyLink, - action: () => { - copyToClipboard(`${url}${notePage(appearNote.value)}`); - os.success(); - }, - }, - note.value.user.host != null - ? { - type: "a", - icon: `${icon("ph-arrow-square-up-right")}`, - text: i18n.ts.showOnRemote, - href: note.value.url ?? note.value.uri ?? "", - target: "_blank", - } - : undefined, - ], - ev, - ); - } + showNoteContextMenu({ + ev, + note: appearNote.value, + react: footerEl.value!.react, + }); } function focus() { @@ -580,15 +311,6 @@ function noteClick(e: MouseEvent) { margin-bottom: 2px; cursor: auto; } - - > .body { - > .translation { - border: solid 0.5px var(--divider); - border-radius: var(--radius); - padding: 12px; - margin-top: 8px; - } - } > .footer { position: relative; z-index: 2; diff --git a/packages/client/src/components/global/MkError.vue b/packages/client/src/components/global/MkError.vue index 7472542ee1..1fb76f2d06 100644 --- a/packages/client/src/components/global/MkError.vue +++ b/packages/client/src/components/global/MkError.vue @@ -10,9 +10,9 @@ <i :class="icon('ph-warning')"></i> {{ i18n.ts.somethingHappened }} </p> - <MkButton class="button" @click="() => $emit('retry')">{{ - i18n.ts.retry - }}</MkButton> + <MkButton class="button" @click.stop="() => $emit('retry')"> + {{ i18n.ts.retry }} + </MkButton> </div> </transition> </template> diff --git a/packages/client/src/components/MkSubNoteContent.vue b/packages/client/src/components/note/MkNoteContent.vue similarity index 98% rename from packages/client/src/components/MkSubNoteContent.vue rename to packages/client/src/components/note/MkNoteContent.vue index 5860d27fff..358c365ec3 100644 --- a/packages/client/src/components/MkSubNoteContent.vue +++ b/packages/client/src/components/note/MkNoteContent.vue @@ -66,7 +66,7 @@ tabindex: !showContent ? '-1' : undefined, }" > - <span v-if="note.deletedAt" style="opacity: 0.5" + <span v-if="deleted" style="opacity: 0.5" >({{ i18n.ts.deleted }})</span > <template v-if="!note.cw"> @@ -195,6 +195,7 @@ import { extractMfmWithAnimation } from "@/scripts/extract-mfm"; import { i18n } from "@/i18n"; import { defaultStore } from "@/store"; import icon from "@/scripts/icon"; +import { isDeleted } from "@/scripts/note"; const props = withDefaults( defineProps<{ @@ -242,6 +243,8 @@ const mfms = computed(() => ); const hasMfm = computed(() => mfms.value && mfms.value.length > 0); +const deleted = computed(() => isDeleted(props.note.id)); + const disableMfm = ref(defaultStore.state.animatedMfm); const showContent = ref(false); const collapsed = ref(props.note.cw == null && isLong.value); diff --git a/packages/client/src/components/note/MkNoteFooter.vue b/packages/client/src/components/note/MkNoteFooter.vue new file mode 100644 index 0000000000..aff161ff47 --- /dev/null +++ b/packages/client/src/components/note/MkNoteFooter.vue @@ -0,0 +1,290 @@ +<template> + <footer class="footer" ref="el" tabindex="-1"> + <XReactionsViewer + v-if="enableEmojiReactions && !hideEmojiViewer" + ref="reactionsViewer" + :note="note" + /> + <button + v-tooltip.noDelay.bottom="i18n.ts.reply" + class="button _button" + @click.stop="reply()" + :disabled="note.scheduledAt != null" + > + <i :class="icon('ph-arrow-u-up-left')"></i> + <template v-if="note.repliesCount > 0 && !detailedView"> + <p class="count">{{ note.repliesCount }}</p> + </template> + </button> + <XRenoteButton + ref="renoteButton" + class="button" + :note="note" + :count="note.renoteCount" + :detailed-view="detailedView" + :disabled="note.scheduledAt != null" + /> + <XStarButtonNoEmoji + v-if="!enableEmojiReactions" + class="button" + :note="note" + :count="reactionCount" + :reacted="note.myReaction != null" + :disabled="note.scheduledAt != null" + /> + <XStarButton + v-if="enableEmojiReactions && note.myReaction == null" + ref="starButton" + class="button" + :note="note" + :disabled="note.scheduledAt != null" + /> + <button + v-if="enableEmojiReactions && note.myReaction == null" + ref="reactButton" + v-tooltip.noDelay.bottom="i18n.ts.reaction" + class="button _button" + @click.stop="react()" + :disabled="note.scheduledAt != null" + > + <i :class="icon('ph-smiley')"></i> + <p v-if="reactionCount > 0 && hideEmojiViewer" class="count"> + {{ reactionCount }} + </p> + </button> + <button + v-if="enableEmojiReactions && note.myReaction != null" + ref="reactButton" + v-tooltip.noDelay.bottom="i18n.ts.removeReaction" + class="button _button reacted" + @click.stop="undoReact(note)" + :disabled="note.scheduledAt != null" + > + <i :class="icon('ph-minus')"></i> + <p v-if="reactionCount > 0 && hideEmojiViewer" class="count"> + {{ reactionCount }} + </p> + </button> + <XQuoteButton + class="button" + :note="note" + :disabled="note.scheduledAt != null" + /> + <button + v-if=" + isSignedIn(me) && + isForeignLanguage && + noteTranslation.canTranslate + " + v-tooltip.noDelay.bottom="i18n.ts.translate" + class="button _button" + @click.stop="noteTranslation.translate" + > + <i :class="icon('ph-translate')"></i> + </button> + <button + ref="menuButton" + v-tooltip.noDelay.bottom="i18n.ts.more" + class="button _button" + @click.stop="menu()" + > + <i :class="icon('ph-dots-three-outline')"></i> + </button> + </footer> +</template> + +<script lang="ts" setup> +import { i18n } from "@/i18n"; +import { isSignedIn, me } from "@/me"; +import icon from "@/scripts/icon"; +import type { NoteType } from "@/types/note"; +import { type Ref, computed, inject, ref, watch } from "vue"; +import XReactionsViewer from "@/components/MkReactionsViewer.vue"; +import XStarButton from "@/components/MkStarButton.vue"; +import XStarButtonNoEmoji from "@/components/MkStarButtonNoEmoji.vue"; +import XQuoteButton from "@/components/MkQuoteButton.vue"; +import XRenoteButton from "@/components/MkRenoteButton.vue"; +import * as os from "@/os"; +import { pleaseLogin } from "@/scripts/please-login"; +import { reactionPicker } from "@/scripts/reaction-picker"; +import { getNoteMenu } from "@/scripts/get-note-menu"; +import { defaultStore } from "@/store"; +import { detectLanguage } from "@/scripts/language-utils"; +import type { entities } from "firefish-js"; +import type MkNoteTranslation from "./MkNoteTranslation.vue"; + +const props = defineProps<{ + note: NoteType; + enableEmojiReactions?: boolean; + hideEmojiViewer?: boolean; + detailedView?: boolean; + noteTranslation: InstanceType<typeof MkNoteTranslation>; +}>(); + +const emit = defineEmits<{ + "event:focus": []; + "event:blur": []; + deleted: []; +}>(); + +const el = ref<HTMLElement | null>(null); +const starButton = ref<InstanceType<typeof XStarButton>>(); +const renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null); +const reactButton = ref<HTMLElement | null>(null); +const menuButton = ref<HTMLElement>(); + +const reactionCount = computed(() => + Object.values(props.note.reactions).reduce( + (partialSum, val) => partialSum + val, + 0, + ), +); + +const currentClipPage = inject<Ref<entities.Clip> | null>( + "currentClipPage", + null, +); + +const isForeignLanguage = computed( + () => + defaultStore.state.detectPostLanguage && + props.note.text != null && + (() => { + const postLang = detectLanguage(props.note.text); + return postLang !== "" && postLang !== props.noteTranslation.targetLang; + })(), +); + +function focus() { + emit("event:focus"); +} + +function reply(_viaKeyboard = false): void { + pleaseLogin(); + os.post( + { + reply: props.note, + // animation: !viaKeyboard, + }, + () => { + focus(); + }, + ); +} + +function react(_viaKeyboard = false): void { + pleaseLogin(); + emit("event:blur"); + reactionPicker.show( + reactButton.value!, + (reaction) => { + os.api("notes/reactions/create", { + noteId: props.note.id, + reaction, + }); + }, + () => { + focus(); + }, + ); +} + +function undoReact(note: NoteType): void { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api("notes/reactions/delete", { + noteId: note.id, + }); +} + +function menu(viaKeyboard = false): void { + const isDeleted = ref(false); + watch(isDeleted, (v) => { + if (v === true) emit("deleted"); + }); + os.popupMenu( + getNoteMenu({ + note: props.note, + menuButton, + isDeleted, + currentClipPage, + translationEl: props.noteTranslation, + }), + menuButton.value, + { + viaKeyboard, + }, + ).then(focus); +} + +defineExpose({ + reply, + react, + undoReact, + menu, + renote: (viaKeyboard: boolean) => renoteButton.value!.renote(viaKeyboard), + focus: () => el.value?.focus(), +}); +</script> + +<style lang="scss" scoped> +.footer { + position: relative; + z-index: 2; + display: flex; + flex-wrap: wrap; + margin-top: 0.4em; + > :deep(.button) { + position: relative; + margin: 0; + padding: 8px; + opacity: 0.7; + &:disabled { + opacity: 0.3 !important; + } + flex-grow: 1; + max-width: 3.5em; + width: max-content; + min-width: max-content; + height: auto; + transition: opacity 0.2s; + &::before { + content: ""; + position: absolute; + inset: 0; + bottom: 2px; + background: var(--panel); + z-index: -1; + transition: background 0.2s; + } + &:first-of-type { + margin-left: -0.5em; + &::before { + border-radius: 100px 0 0 100px; + } + } + &:last-of-type { + &::before { + border-radius: 0 100px 100px 0; + } + } + &:hover { + color: var(--fgHighlighted); + } + + > i { + display: inline !important; + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } +} +</style> diff --git a/packages/client/src/components/note/MkNoteFooterInfo.vue b/packages/client/src/components/note/MkNoteFooterInfo.vue new file mode 100644 index 0000000000..1e62053657 --- /dev/null +++ b/packages/client/src/components/note/MkNoteFooterInfo.vue @@ -0,0 +1,44 @@ +<template> + <div v-if="detailedView || (note.channel && !inChannel)" class="footer-info"> + <MkA v-if="detailedView" class="created-at" :to="notePage(note)"> + <MkTime + v-if="note.scheduledAt != null" + :time="note.scheduledAt" + /> + <MkTime v-else :time="note.createdAt" mode="absolute" /> + </MkA> + <MkA + v-if="note.channel && !inChannel" + class="channel" + :to="`/channels/${note.channel.id}`" + @click.stop + ><i :class="icon('ph-television', false)"></i> + {{ note.channel.name }}</MkA + > + </div> +</template> + +<script lang="ts" setup> +import { notePage } from "@/filters/note"; +import icon from "@/scripts/icon"; +import type { NoteType } from "@/types/note"; +import { inject } from "vue"; + +defineProps<{ + note: NoteType; + detailedView?: boolean; +}>(); +const inChannel = inject("inChannel", null); +</script> + +<style lang="scss" scoped> +.footer-info { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.7em; + margin-top: 16px; + opacity: 0.7; + font-size: 0.9em; +} +</style> diff --git a/packages/client/src/components/MkNoteHeader.vue b/packages/client/src/components/note/MkNoteHeader.vue similarity index 100% rename from packages/client/src/components/MkNoteHeader.vue rename to packages/client/src/components/note/MkNoteHeader.vue diff --git a/packages/client/src/components/note/MkNoteHeaderInfo.vue b/packages/client/src/components/note/MkNoteHeaderInfo.vue new file mode 100644 index 0000000000..bbd7120aaf --- /dev/null +++ b/packages/client/src/components/note/MkNoteHeaderInfo.vue @@ -0,0 +1,71 @@ +<template> + <!-- _prId_ and _featuredId_ is not used now --> + <!-- TODO: remove them --> + <!-- <div v-if="appearNote._prId_" class="info"> + <i :class="icon('ph-megaphone-simple-bold')"></i> + {{ i18n.ts.promotion + }}<button class="_textButton hide" @click.stop="readPromo()"> + {{ i18n.ts.hideThisNote }} + <i :class="icon('ph-x')"></i> + </button> + </div> + <div v-if="appearNote._featuredId_" class="info"> + <i :class="icon('ph-lightning')"></i> + {{ i18n.ts.featured }} + </div> --> + <div v-if="pinned" class="info"> + <i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }} + </div> + <div v-if="collapsedReply && appearNote.reply" class="info"> + <MkAvatar class="avatar" :user="appearNote.reply.user" /> + <MkUserName class="username" :user="appearNote.reply.user"></MkUserName> + <Mfm + class="summary" + :text="getNoteSummary(appearNote.reply)" + :plain="true" + :nowrap="true" + :lang="appearNote.reply.lang" + :custom-emojis="note.emojis" + /> + </div> +</template> + +<script lang="ts" setup> +import { i18n } from "@/i18n"; +import { getNoteSummary } from "@/scripts/get-note-summary"; +import icon from "@/scripts/icon"; +import type { NoteType } from "@/types/note"; + +defineProps<{ + note: NoteType; + appearNote: NoteType; + collapsedReply?: boolean; + pinned?: boolean; +}>(); + +// function readPromo() { +// os.api("promo/read", { +// noteId: props.appearNote.id, +// }); +// isDeleted.value = true; +// } +</script> + +<style lang="scss" scoped> +.info { + display: flex; + align-items: center; + font-size: 90%; + white-space: pre; + color: #f6c177; + + > i { + margin-right: 4px; + } + + > .hide { + margin-left: auto; + color: inherit; + } +} +</style> diff --git a/packages/client/src/components/MkNoteMedia.vue b/packages/client/src/components/note/MkNoteMedia.vue similarity index 100% rename from packages/client/src/components/MkNoteMedia.vue rename to packages/client/src/components/note/MkNoteMedia.vue diff --git a/packages/client/src/components/MkNoteMediaList.vue b/packages/client/src/components/note/MkNoteMediaList.vue similarity index 88% rename from packages/client/src/components/MkNoteMediaList.vue rename to packages/client/src/components/note/MkNoteMediaList.vue index 879ef8d673..cb02eebb3f 100644 --- a/packages/client/src/components/MkNoteMediaList.vue +++ b/packages/client/src/components/note/MkNoteMediaList.vue @@ -16,7 +16,7 @@ <script lang="ts" setup> import type { entities } from "firefish-js"; -import XNoteMedia from "@/components/MkNoteMedia.vue"; +import XNoteMedia from "@/components/note/MkNoteMedia.vue"; defineProps<{ note: entities.Note; diff --git a/packages/client/src/components/note/MkNoteTranslation.vue b/packages/client/src/components/note/MkNoteTranslation.vue new file mode 100644 index 0000000000..8b07c9f903 --- /dev/null +++ b/packages/client/src/components/note/MkNoteTranslation.vue @@ -0,0 +1,110 @@ +<template> + <div v-if="translating || translation != null || hasError" class="translation-container"> + <MkLoading v-if="translating" mini/> + <MkError v-else-if="hasError" @retry="translate"/> + <div v-else-if="translation != null" class="translated"> + <b + >{{ + i18n.t("translatedFrom", { + x: translation.sourceLang, + }) + }}: + </b> + <Mfm + :text="translation.text" + :author="note.user" + :i="me" + :lang="targetLang" + :custom-emojis="note.emojis" + /> + </div> + </div> +</template> + +<script lang="ts" setup> +import { i18n } from "@/i18n"; +import { me } from "@/me"; +import type { NoteTranslation, NoteType } from "@/types/note"; +import { computed, ref, watch } from "vue"; +import * as os from "@/os"; +import { instance } from "@/instance"; + +const props = defineProps<{ + note: NoteType; + detailedView?: boolean; +}>(); + +const translation = ref<NoteTranslation | null>(null); +const translating = ref<boolean>(); +const hasError = ref<boolean>(); +const canTranslate = computed( + () => + instance.translatorAvailable && + translation.value == null && + translating.value !== true, +); + +const lang = localStorage.getItem("lang"); +const translateLang = localStorage.getItem("translateLang"); +const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2); + +watch( + () => props.note.id, + (o, n) => { + if (o !== n) { + translating.value = false; + translation.value = null; + } + }, +); + +async function getTranslation(noteId: string, targetLang: string) { + return await os.api("notes/translate", { + noteId, + targetLang, + }); +} + +async function translate() { + try { + if (translation.value != null) return; + translating.value = true; + translation.value = await getTranslation( + props.note.id, + translateLang || lang || navigator.language, + ); + + // use UI language as the second translation language + if ( + translateLang != null && + lang != null && + translateLang !== lang && + (!translation.value || + translation.value.sourceLang.toLowerCase() === + translateLang.slice(0, 2)) + ) + translation.value = await getTranslation(props.note.id, lang); + hasError.value = false; + } catch (err) { + hasError.value = true; + translation.value = null; + } finally { + translating.value = false; + } +} + +defineExpose({ + translate, + canTranslate, + targetLang, +}); +</script> + +<style lang="scss" scoped> +.translation-container { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; +} +</style> diff --git a/packages/client/src/components/note/MkRenoteBar.vue b/packages/client/src/components/note/MkRenoteBar.vue new file mode 100644 index 0000000000..948dfbc72f --- /dev/null +++ b/packages/client/src/components/note/MkRenoteBar.vue @@ -0,0 +1,165 @@ +<template> + <div + v-if="isRenote || (renotesSliced && renotesSliced.length > 0)" + class="renote" + > + <i :class="icon('ph-rocket-launch')"></i> + <I18n v-if="renotesSliced == null" :src="i18n.ts.renotedBy" tag="span"> + <template #user> + <MkAvatar class="avatar" :user="note.user" /> + <MkA + v-user-preview="note.userId" + class="name" + :to="userPage(note.user)" + @click.stop + > + <MkUserName :user="note.user" /> + </MkA> + </template> + </I18n> + <I18n v-else :src="i18n.ts.renotedBy" tag="span"> + <template #user> + <template v-for="(renote, index) in renotesSliced"> + <MkAvatar class="avatar" :user="renote.user" /> + <MkA + v-user-preview="renote.userId" + class="name" + :to="userPage(renote.user)" + @click.stop + > + <MkUserName :user="renote.user" /> + </MkA> + {{ + index !== renotesSliced.length - 1 + ? ", " + : renotesSliced.length < renotes!.length + ? "..." + : "" + }} + </template> + </template> + </I18n> + <div class="info"> + <button + ref="renoteTime" + class="_button time" + @click.stop="showRenoteMenu()" + > + <i + v-if="isMyNote" + :class="icon('ph-dots-three-outline dropdownIcon')" + ></i> + <MkTime + v-if="renotesSliced && renotesSliced.length > 0" + :time="renotesSliced[0].createdAt" + /> + <MkTime v-else :time="note.createdAt" /> + </button> + <MkVisibility :note="note" /> + </div> + </div> +</template> + +<script lang="ts" setup> +import { userPage } from "@/filters/user"; +import { i18n } from "@/i18n"; +import { isSignedIn, me } from "@/me"; +import icon from "@/scripts/icon"; +import type { NoteType } from "@/types/note"; +import { computed, ref } from "vue"; +import MkVisibility from "@/components/MkVisibility.vue"; +import * as os from "@/os"; + +const props = defineProps<{ + note: NoteType; + appearNote: NoteType; + isRenote?: boolean; + renotes?: NoteType[]; +}>(); + +const emit = defineEmits<{ + deleted: []; +}>(); + +const renoteTime = ref<HTMLElement>(); + +const renotesSliced = computed(() => props.renotes?.slice(0, 5)); + +const isMyNote = computed( + () => isSignedIn(me) && me.id === props.note.userId && props.renotes == null, +); + +function showRenoteMenu(viaKeyboard = false): void { + if (!isMyNote.value) return; + os.popupMenu( + [ + { + text: i18n.ts.unrenote, + icon: `${icon("ph-trash")}`, + danger: true, + action: () => { + os.api("notes/delete", { + noteId: props.note.id, + }); + emit("deleted"); + }, + }, + ], + renoteTime.value, + { + viaKeyboard, + }, + ); +} +</script> + +<style lang="scss" scoped> +.renote { + display: flex; + align-items: center; + white-space: pre; + color: var(--renote); + cursor: pointer; + + > i { + margin-right: 4px; + } + + .avatar { + width: 1.2em; + height: 1.2em; + border-radius: 2em; + overflow: hidden; + margin-right: 0.4em; + background: var(--panelHighlight); + transform: translateY(-4px); + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + display: flex; + + > .time { + flex-shrink: 0; + color: inherit; + display: inline-flex; + align-items: center; + > .dropdownIcon { + margin-right: 4px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/user/media-list.vue b/packages/client/src/pages/user/media-list.vue index 533ad2a860..01c4526274 100644 --- a/packages/client/src/pages/user/media-list.vue +++ b/packages/client/src/pages/user/media-list.vue @@ -13,7 +13,7 @@ <script lang="ts" setup> import { computed } from "vue"; import type { entities } from "firefish-js"; -import MkNoteMediaList from "@/components/MkNoteMediaList.vue"; +import MkNoteMediaList from "@/components/note/MkNoteMediaList.vue"; import MkPagination from "@/components/MkPagination.vue"; const props = defineProps<{ diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts index ade43a3e22..e323a992f6 100644 --- a/packages/client/src/scripts/get-note-menu.ts +++ b/packages/client/src/scripts/get-note-menu.ts @@ -3,7 +3,6 @@ import { defineAsyncComponent } from "vue"; import type { entities } from "firefish-js"; import { isModerator, isSignedIn, me } from "@/me"; import { i18n } from "@/i18n"; -import { instance } from "@/instance"; import * as os from "@/os"; import copyToClipboard from "@/scripts/copy-to-clipboard"; import { url } from "@/config"; @@ -13,18 +12,17 @@ import { getUserMenu } from "@/scripts/get-user-menu"; import icon from "@/scripts/icon"; import { useRouter } from "@/router"; import { notePage } from "@/filters/note"; -import type { NoteTranslation } from "@/types/note"; import type { MenuItem } from "@/types/menu"; import type { NoteDraft } from "@/types/post-form"; +import type MkNoteTranslation from "@/components/note/MkNoteTranslation.vue"; const router = useRouter(); export function getNoteMenu(props: { note: entities.Note; menuButton: Ref<HTMLElement | undefined>; - translation: Ref<NoteTranslation | null>; - translating: Ref<boolean>; isDeleted: Ref<boolean>; + translationEl: InstanceType<typeof MkNoteTranslation>; currentClipPage?: Ref<entities.Clip> | null; }) { const isRenote = @@ -270,42 +268,11 @@ export function getNoteMenu(props: { function share(): void { navigator.share({ title: i18n.t("noteOf", { user: appearNote.user.name }), - text: appearNote.text, + text: appearNote.text ?? undefined, url: `${url}/notes/${appearNote.id}`, }); } - async function translate_(noteId: number, targetLang: string) { - return await os.api("notes/translate", { - noteId, - targetLang, - }); - } - - async function translate(): Promise<void> { - const translateLang = localStorage.getItem("translateLang"); - const lang = localStorage.getItem("lang"); - - if (props.translation.value != null) return; - props.translating.value = true; - props.translation.value = await translate_( - appearNote.id, - translateLang || lang || navigator.language, - ); - - // use UI language as the second translation target - if ( - translateLang != null && - lang != null && - translateLang !== lang && - (!props.translation.value || - props.translation.value.sourceLang.toLowerCase() === - translateLang.slice(0, 2)) - ) - props.translation.value = await translate_(appearNote.id, lang); - props.translating.value = false; - } - let menu: MenuItem[]; if (isSignedIn(me)) { const statePromise = os.api("notes/state", { @@ -394,11 +361,11 @@ export function getNoteMenu(props: { action: () => showEditHistory(), } : undefined, - instance.translatorAvailable + props.translationEl.canTranslate ? { icon: `${icon("ph-translate")}`, text: i18n.ts.translate, - action: translate, + action: props.translationEl.translate, } : undefined, appearNote.url || appearNote.uri diff --git a/packages/client/src/scripts/show-note-context-menu.ts b/packages/client/src/scripts/show-note-context-menu.ts new file mode 100644 index 0000000000..dc88627df5 --- /dev/null +++ b/packages/client/src/scripts/show-note-context-menu.ts @@ -0,0 +1,89 @@ +import { notePage } from "@/filters/note"; +import * as os from "@/os"; +import { defaultStore } from "@/store"; +import type { NoteType } from "@/types/note"; +import icon from "./icon"; +import { i18n } from "@/i18n"; +import copyToClipboard from "./copy-to-clipboard"; +import { useRouter } from "@/router"; + +const router = useRouter(); +import { url } from "@/config"; + +export function showNoteContextMenu({ + ev, + note, + react, +}: { + ev: MouseEvent; + note: NoteType; + react: () => void; +}): void { + const isLink = (el: HTMLElement): boolean => { + if (el.tagName === "A") return true; + // The Audio element's context menu is the browser default, such as for selecting playback speed. + if (el.tagName === "AUDIO") return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + return false; + }; + if (isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== "") return; + + if (defaultStore.state.useReactionPickerForContextMenu) { + ev.preventDefault(); + react(); + } else { + os.contextMenu( + [ + { + type: "label", + text: notePage(note), + }, + { + icon: `${icon("ph-browser")}`, + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(notePage(note)); + }, + }, + notePage(note) !== location.pathname + ? { + icon: `${icon("ph-arrows-out-simple")}`, + text: i18n.ts.showInPage, + action: () => { + router.push(notePage(note), "forcePage"); + }, + } + : undefined, + null, + { + type: "a", + icon: `${icon("ph-arrow-square-out")}`, + text: i18n.ts.openInNewTab, + href: notePage(note), + target: "_blank", + }, + { + icon: `${icon("ph-link-simple")}`, + text: i18n.ts.copyLink, + action: () => { + copyToClipboard(`${url}${notePage(note)}`); + os.success(); + }, + }, + note.user.host != null + ? { + type: "a", + icon: `${icon("ph-arrow-square-up-right")}`, + text: i18n.ts.showOnRemote, + href: note.url ?? note.uri ?? "", + target: "_blank", + } + : undefined, + ], + ev, + ); + } +}