Merge branch 'develop' into upstream/2025.5.0

This commit is contained in:
dakkar 2025-05-30 11:13:37 +01:00
commit 46bb75d274
116 changed files with 2636 additions and 973 deletions

View file

@ -30,7 +30,7 @@
"@transfem-org/sfm-js": "0.24.6",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.2.3",
"@vue/compiler-sfc": "3.5.13",
"@vue/compiler-sfc": "3.5.14",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"astring": "1.9.0",
"broadcast-channel": "7.1.0",
@ -76,7 +76,7 @@
"uuid": "11.1.0",
"v-code-diff": "1.13.1",
"vite": "6.3.4",
"vue": "3.5.13",
"vue": "3.5.14",
"vuedraggable": "next",
"wanakana": "5.3.1"
},
@ -119,8 +119,8 @@
"@typescript-eslint/eslint-plugin": "8.31.0",
"@typescript-eslint/parser": "8.31.0",
"@vitest/coverage-v8": "3.1.2",
"@vue/compiler-core": "3.5.13",
"@vue/runtime-core": "3.5.13",
"@vue/compiler-core": "3.5.14",
"@vue/runtime-core": "3.5.14",
"acorn": "8.14.1",
"cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0",

View file

@ -23,10 +23,10 @@ import type MkNote from '@/components/MkNote.vue';
import type SkNote from '@/components/SkNote.vue';
import { prefer } from '@/preferences';
const XNote = computed(() =>
prefer.r.noteDesign.value === 'misskey'
? defineAsyncComponent(() => import('@/components/MkNote.vue'))
: defineAsyncComponent(() => import('@/components/SkNote.vue')),
const XNote = defineAsyncComponent(() =>
prefer.s.noteDesign === 'misskey'
? import('@/components/MkNote.vue')
: import('@/components/SkNote.vue')
);
const rootEl = useTemplateRef<ComponentExposed<typeof MkNote | typeof SkNote>>('rootEl');

View file

@ -20,10 +20,10 @@ import type MkNoteDetailed from '@/components/MkNoteDetailed.vue';
import type SkNoteDetailed from '@/components/SkNoteDetailed.vue';
import { prefer } from '@/preferences';
const XNoteDetailed = computed(() =>
prefer.r.noteDesign.value === 'misskey'
? defineAsyncComponent(() => import('@/components/MkNoteDetailed.vue'))
: defineAsyncComponent(() => import('@/components/SkNoteDetailed.vue')),
const XNoteDetailed = defineAsyncComponent(() =>
prefer.s.noteDesign === 'misskey'
? import('@/components/MkNoteDetailed.vue')
: import('@/components/SkNoteDetailed.vue'),
);
const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteDetailed | typeof SkNoteDetailed>>('rootEl');

View file

@ -21,10 +21,10 @@ import type MkNoteSimple from '@/components/MkNoteSimple.vue';
import type SkNoteSimple from '@/components/SkNoteSimple.vue';
import { prefer } from '@/preferences';
const XNoteSimple = computed(() =>
prefer.r.noteDesign.value === 'misskey'
? defineAsyncComponent(() => import('@/components/MkNoteSimple.vue'))
: defineAsyncComponent(() => import('@/components/SkNoteSimple.vue')),
const XNoteSimple = defineAsyncComponent(() =>
prefer.s.noteDesign === 'misskey'
? import('@/components/MkNoteSimple.vue')
: import('@/components/SkNoteSimple.vue'),
);
const rootEl = useTemplateRef<ComponentExposed<typeof MkNoteSimple | typeof SkNoteSimple>>('rootEl');

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button
v-if="!link"
ref="el" class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.accent]: accent, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:type="type"
:name="name"
:value="value"
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
<MkA
v-else class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.accent]: accent, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:to="to ?? '#'"
:behavior="linkBehavior"
@mousedown="onMousedown"
@ -48,6 +48,7 @@ const props = defineProps<{
linkBehavior?: null | 'window' | 'browser';
autofocus?: boolean;
wait?: boolean;
accent?: boolean;
danger?: boolean;
full?: boolean;
small?: boolean;
@ -234,6 +235,24 @@ function onMousedown(evt: MouseEvent): void {
}
}
&.accent {
font-weight: bold;
color: var(--MI_THEME-accent);
&.primary {
color: #fff;
background: var(--MI_THEME-accent);
&:not(:disabled):hover {
background: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
}
&:not(:disabled):active {
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
}
}
}
&.danger {
font-weight: bold;
color: var(--MI_THEME-error);

View file

@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@leave="leave"
@afterLeave="afterLeave"
>
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted, [$style.naked]: naked }]">
<slot></slot>
<button v-if="omitted" :class="$style.fade" class="_button" @click="showMore">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
@ -228,6 +228,11 @@ onUnmounted(() => {
*/
background: var(--MI_THEME-panel);
&.naked {
background: transparent !important;
box-shadow: none !important;
}
&.omitted {
position: relative;
max-height: var(--maxHeight);

View file

@ -86,13 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:isBlock="true"
class="_selectable"
/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
</div>
@ -101,7 +95,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
@ -160,10 +154,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ph-smiley ph-bold ph-lg"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -171,24 +168,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</article>
</div>
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
<template #word>
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
</template>
</I18n>
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
</div>
<div v-else>
<!--
@ -204,7 +184,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
import * as config from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { Ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
@ -224,7 +204,7 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkButton from '@/components/MkButton.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
@ -236,7 +216,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
@ -246,13 +226,17 @@ import { getNoteSummary } from '@/utility/get-note-summary.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { instance, isEnabledUrlPreview } from '@/instance.js';
import { focusPrev, focusNext } from '@/utility/focus.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { useRouter } from '@/router.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -272,8 +256,6 @@ const emit = defineEmits<{
const router = useRouter();
const inTimeline = inject<boolean>('inTimeline', false);
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
@ -322,15 +304,14 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const renoted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
@ -347,38 +328,13 @@ const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
}));
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted);
/* Overload FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
*/
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, $i, mutedWords);
if (Array.isArray(result)) return result;
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
if (Array.isArray(replyResult)) return replyResult;
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
if (Array.isArray(renoteResult)) return renoteResult;
}
if (checkOnly) return false;
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
return 'sensitiveMute';
}
return false;
}
let renoting = false;
const keymap = {
@ -403,6 +359,11 @@ const keymap = {
if (!prefer.s.showClipButtonInNoteFooter) return;
clip();
},
't': () => {
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
'o': () => {
if (renoteCollapsed.value) return;
galleryEl.value?.openGallery();
@ -825,6 +786,12 @@ async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate() {
if (props.mock) return;
await translateNote(appearNote.value.id, translation, translating);
}
function showRenoteMenu(): void {
if (props.mock) {
return;
@ -1199,13 +1166,6 @@ function emitUpdReaction(emoji: string, delta: number) {
margin-right: 0.5em;
}
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
.urlPreview {
margin-top: 8px;
}

View file

@ -104,13 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_selectable"
/>
<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-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
<div v-if="appearNote.files && appearNote.files.length > 0">
@ -118,7 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
@ -172,10 +166,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ph-smiley ph-bold ph-lg"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -230,11 +227,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
</div>
</template>
@ -243,7 +236,7 @@ import { computed, inject, onMounted, provide, ref, useTemplateRef, watch } from
import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js';
import * as config from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue';
@ -260,7 +253,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
@ -271,7 +264,7 @@ import { reactionPicker } from '@/utility/reaction-picker.js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
@ -285,11 +278,15 @@ import MkPagination from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { instance, isEnabledUrlPreview } from '@/instance.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -340,12 +337,12 @@ const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const isDeleted = ref(false);
const renoted = ref(false);
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]);
@ -358,6 +355,8 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted);
const { muted } = checkMutes(appearNote.value);
watch(() => props.expandAllCws, (expandAllCws) => {
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
});
@ -376,7 +375,7 @@ let renoting = false;
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
}));
const keymap = {
@ -388,6 +387,11 @@ const keymap = {
if (!prefer.s.showClipButtonInNoteFooter) return;
clip();
},
't': () => {
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
'o': () => galleryEl.value?.openGallery(),
'v|enter': () => {
if (appearNote.value.cw != null) {
@ -766,6 +770,10 @@ async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
}
async function translate() {
await translateNote(appearNote.value.id, translation, translating);
}
function showRenoteMenu(): void {
if (!isMyRenote) return;
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
@ -1044,13 +1052,6 @@ function animatedMFM() {
color: var(--MI_THEME-renote);
}
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
.poll {
font-size: 80%;
}

View file

@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_button"
:class="$style.noteFooterButton"
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
@mousedown="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
@click.stop="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
>
<i class="ph-rocket-launch ph-bold ph-lg"></i>
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
@ -42,24 +42,30 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="quoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown="quote()"
@click.stop="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()">
<button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @click.stop="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()">
<button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @click.stop="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()">
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()">
<i class="ph-dots-three ph-bold ph-lg"></i>
</button>
</footer>
@ -73,19 +79,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-else :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, shallowRef, watch } from 'vue';
import { computed, inject, ref, shallowRef, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import { host } from '@@/js/config.js';
import * as config from '@@/js/config.js';
import type { Ref } from 'vue';
import type { Visibility } from '@/utility/boost-quote.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
@ -99,16 +102,18 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { userPage } from '@/filters/user.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { pleaseLogin } from '@/utility/please-login.js';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { reactionPicker } from '@/utility/reaction-picker.js';
import { claimAchievement } from '@/utility/achievements.js';
import { getNoteMenu } from '@/utility/get-note-menu.js';
import { getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import { instance } from '@/instance';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -126,12 +131,12 @@ const props = withDefaults(defineProps<{
const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id);
const el = shallowRef<HTMLElement>();
const muted = computed(() => $i ? checkWordMute(props.note, $i, $i.mutedWords) : false);
const translation = ref<any>(null);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const isDeleted = ref(false);
const renoted = ref(false);
const reactButton = shallowRef<HTMLElement>();
const clipButton = useTemplateRef('clipButton');
const renoteButton = shallowRef<HTMLElement>();
const quoteButton = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>();
@ -154,9 +159,11 @@ const isRenote = (
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
}));
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
async function addReplyTo(replyNote: Misskey.entities.Note) {
replies.value.unshift(replyNote);
appearNote.value.repliesCount += 1;
@ -170,6 +177,8 @@ async function removeReply(id: Misskey.entities.Note['id']) {
}
}
const { muted } = checkMutes(appearNote.value);
useNoteCapture({
rootEl: el,
note: appearNote,
@ -378,6 +387,14 @@ function menu(): void {
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate() {
await translateNote(appearNote.value.id, translation, translating);
}
if (props.detail) {
misskeyApi('notes/children', {
noteId: props.note.id,

View file

@ -240,13 +240,18 @@ watch(props, async () => {
const type = props.notification.type;
// To avoid extra lookups, only do the query when it actually matters.
if (type === 'follow' || type === 'receiveFollowRequest') {
const user = await misskeyApi('users/show', {
userId: props.notification.userId,
});
if ((type === 'follow' || type === 'receiveFollowRequest') && props.notification.userId) {
try {
const user = await misskeyApi('users/show', {
userId: props.notification.userId,
});
userDetailed.value = user;
followRequestDone.value = !user.hasPendingFollowRequestToYou;
userDetailed.value = user;
followRequestDone.value = !user.hasPendingFollowRequestToYou;
} catch {
userDetailed.value = null;
followRequestDone.value = false;
}
} else {
userDetailed.value = null;
followRequestDone.value = false;

View file

@ -18,10 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass=" $style.transition_x_move"
tag="div"
>
<template v-for="(notification, i) in notifications" :key="notification.id">
<div v-for="(notification, i) in notifications" :key="notification.id">
<DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</template>
</div>
</component>
</template>
</MkPagination>

View file

@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { reactive, watch } from 'vue';
import number from '@/filters/number.js';
import { prefer } from '@/preferences';
const props = defineProps<{
value: number;
@ -36,7 +37,11 @@ watch(() => props.value, (to, from) => {
}
}
window.requestAnimationFrame(step);
if (prefer.s.animation) {
window.requestAnimationFrame(step);
} else {
tweened.number = to;
}
}, {
immediate: true,
});

View file

@ -274,18 +274,16 @@ const fetchMore = async (): Promise<void> => {
if (res.length === 0) {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = false;
});
await reverseConcat(res);
more.value = false;
} else {
items.value = concatMapWithArray(items.value, res);
more.value = false;
}
} else {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = true;
});
await reverseConcat(res);
more.value = true;
} else {
items.value = concatMapWithArray(items.value, res);
more.value = true;

View file

@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import * as config from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import { sum } from '@/utility/array.js';
@ -72,7 +72,7 @@ const showResult = ref(props.readOnly || isVoted.value);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${props.noteId}`,
url: `${config.url}/notes/${props.noteId}`,
}));
//

View file

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
tag="div" :class="$style.root"
>
<XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
<slot v-if="hasMoreReactions" name="more"/>
<slot v-if="hasMoreReactions" :key="'$more'" name="more"/>
</component>
</template>

View file

@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
role: Misskey.entities.Role;
forModeration: boolean;
detailed: boolean;
detailed?: boolean;
}>(), {
detailed: true,
});

View file

@ -12,13 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/>
<MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
<div v-if="note.text && translating || note.text && translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
</div>
</div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA>
</div>
<details v-if="note.files && note.files.length > 0" :open="!prefer.s.collapseFiles && !hideFiles">
@ -51,14 +45,20 @@ import * as os from '@/os.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { useRouter } from '@/router';
import { prefer } from '@/preferences.js';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
const props = defineProps<{
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
translating?: boolean;
translation?: any;
translation?: Misskey.entities.NotesTranslateResponse | false | null;
hideFiles?: boolean;
expandAllCws?: boolean;
}>();
}>(), {
translating: false,
translation: null,
hideFiles: false,
expandAllCws: false,
});
const router = useRouter();
@ -140,13 +140,6 @@ watch(() => props.expandAllCws, (expandAllCws) => {
color: var(--MI_THEME-renote);
}
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
.showLess {
width: 100%;
margin-top: 14px;

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass="$style.transition_x_move"
tag="div"
>
<template v-for="(note, i) in notes" :key="note.id">
<div v-for="(note, i) in notes" :key="note.id">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<DynamicNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<DynamicNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</div>
</component>
</template>
</MkPagination>

View file

@ -71,8 +71,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
</MkButton>
</div>
<div v-if="showAsQuote && activityPub && !theNote && !fetchingTheNote" :class="$style.action">
<MkButton :small="true" inline @click="fetchNote()">
<div v-if="showAsQuote && activityPub && !theNote && $i" :class="$style.action">
<MkButton :small="true" :disabled="!!fetching || fetchingTheNote" inline @click="() => refresh(true)">
<i class="ti ti-note"></i> {{ i18n.ts.fetchLinkedNote }}
</MkButton>
</div>
@ -93,6 +93,7 @@ import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js';
import * as Misskey from 'misskey-js';
import { maybeMakeRelative } from '@@/js/url.js';
import type { summaly } from '@misskey-dev/summaly';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
@ -104,7 +105,7 @@ import { prefer } from '@/preferences.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { warningExternalWebsite } from '@/utility/warning-external-website.js';
import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
import { maybeMakeRelative } from '@@/js/url.js';
import { $i } from '@/i';
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
@ -131,7 +132,7 @@ const maybeRelativeUrl = maybeMakeRelative(props.url, local);
const self = maybeRelativeUrl !== props.url;
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
const fetching = ref(true);
const fetching = ref<Promise<void> | null>(null);
const title = ref<string | null>(null);
const description = ref<string | null>(null);
const thumbnail = ref<string | null>(null);
@ -139,11 +140,12 @@ const icon = ref<string | null>(null);
const sitename = ref<string | null>(null);
const sensitive = ref<boolean>(false);
const activityPub = ref<string | null>(null);
const player = ref({
const player = ref<SummalyResult['player']>({
url: null,
width: null,
height: null,
} as SummalyResult['player']);
allow: [],
});
const playerEnabled = ref(false);
const tweetId = ref<string | null>(null);
const tweetExpanded = ref(props.detail);
@ -173,14 +175,14 @@ async function fetchNote() {
return;
}
theNote.value = response['object'];
fetchingTheNote.value = false;
} catch (err) {
if (_DEV_) {
console.error(`failed to extract note for preview of ${activityPub.value}`, err);
}
activityPub.value = null;
fetchingTheNote.value = false;
theNote.value = null;
} finally {
fetchingTheNote.value = false;
}
}
@ -198,39 +200,52 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
requestUrl.hash = '';
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
.then(res => {
if (!res.ok) {
if (_DEV_) {
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
}
return null;
}
return res.json();
})
.then((info: SummalyResult & { haveNoteLocally?: boolean } | null) => {
if (!info || info.url == null) {
fetching.value = false;
unknownUrl.value = true;
return;
}
fetching.value = false;
unknownUrl.value = false;
title.value = info.title;
description.value = info.description;
thumbnail.value = info.thumbnail;
icon.value = info.icon;
sitename.value = info.sitename;
player.value = info.player;
sensitive.value = info.sensitive ?? false;
activityPub.value = info.activityPub;
if (info.haveNoteLocally) {
fetchNote();
}
function refresh(withFetch = false) {
const params = new URLSearchParams({
url: requestUrl.href,
lang: versatileLang,
});
if (withFetch) {
params.set('fetch', 'true');
}
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers })
.then(res => {
if (!res.ok) {
if (_DEV_) {
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
}
return null;
}
return res.json();
})
.then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => {
unknownUrl.value = info == null;
title.value = info?.title ?? null;
description.value = info?.description ?? null;
thumbnail.value = info?.thumbnail ?? null;
icon.value = info?.icon ?? null;
sitename.value = info?.sitename ?? null;
player.value = info?.player ?? {
url: null,
width: null,
height: null,
allow: [],
};
sensitive.value = info?.sensitive ?? false;
activityPub.value = info?.activityPub ?? null;
theNote.value = null;
if (info?.haveNoteLocally) {
await fetchNote();
}
})
.finally(() => {
fetching.value = null;
});
}
function adjustTweetHeight(message: MessageEvent) {
if (message.origin !== 'https://platform.twitter.com') return;
@ -256,6 +271,9 @@ window.addEventListener('message', adjustTweetHeight);
onUnmounted(() => {
window.removeEventListener('message', adjustTweetHeight);
});
// Load initial data
refresh();
</script>
<style lang="scss" module>

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" @click="$emit('select', note.user)">
<div v-if="!hardMuted" :class="$style.root" @click="$emit('select', note.user)">
<div :class="$style.avatar">
<MkAvatar :class="$style.icon" :user="note.user" indictor/>
</div>
@ -18,11 +18,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
</header>
<div>
<div v-if="isMuted" :class="[$style.text, $style.muted]">({{ i18n.ts.postFiltered }})</div>
<div v-if="muted" :class="[$style.text, $style.muted]">
<SkMutedNote :muted="muted" :note="note"></SkMutedNote>
</div>
<Mfm v-else :class="$style.text" :text="getNoteSummary(note)" :isBlock="true" :plain="true" :nowrap="false" :isNote="true" nyaize="respect" :author="note.user"/>
</div>
</div>
</div>
<div v-else>
<!--
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
so MkNote create empty div instead of no elements
-->
</div>
</template>
<script lang="ts" setup>
@ -30,19 +38,19 @@ import * as Misskey from 'misskey-js';
import { getNoteSummary } from '@/utility/get-note-summary.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import { i18n } from '@/i18n.js';
import { checkMutes } from '@/utility/check-word-mute';
import SkMutedNote from '@/components/SkMutedNote.vue';
withDefaults(defineProps<{
const props = defineProps<{
note: Misskey.entities.Note,
isMuted: boolean
}>(), {
isMuted: false,
});
}>();
defineEmits<{
(event: 'select', user: Misskey.entities.UserLite): void
}>();
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const { muted, hardMuted } = checkMutes(props.note);
</script>
<style lang="scss" module>

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notes }">
<MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true">
<SkFollowingFeedEntry v-if="!isHardMuted(note)" :isMuted="isSoftMuted(note)" :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/>
<SkFollowingFeedEntry :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/>
</MkDateSeparatedList>
</template>
</MkPagination>
@ -18,16 +18,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed, shallowRef } from 'vue';
import type { FollowingFeedTab } from '@/types/following-feed.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { FollowingFeedTab } from '@/types/following-feed.js';
import { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkPagination from '@/components/MkPagination.vue';
import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.vue';
import { $i } from '@/i.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
const props = defineProps<{
@ -78,37 +76,6 @@ const latestNotesPagination: Paging<'notes/following'> = {
};
const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>();
function isSoftMuted(note: Misskey.entities.Note): boolean {
return isMuted(note, $i?.mutedWords);
}
function isHardMuted(note: Misskey.entities.Note): boolean {
return isMuted(note, $i?.hardMutedWords);
}
// Match the typing used by Misskey
type Mutes = (string | string[])[] | null | undefined;
// Adapted from MkNote.ts
function isMuted(note: Misskey.entities.Note, mutes: Mutes): boolean {
return checkMute(note, mutes)
|| checkMute(note.reply, mutes)
|| checkMute(note.renote, mutes);
}
// Adapted from check-word-mute.ts
function checkMute(note: Misskey.entities.Note | undefined | null, mutes: Mutes): boolean {
if (!note) {
return false;
}
if (!mutes || mutes.length < 1) {
return false;
}
return !!checkWordMute(note, $i, mutes);
}
</script>
<style module lang="scss">

View file

@ -0,0 +1,45 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
</I18n>
<I18n v-else-if="prefer.s.showSoftWordMutedWord" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
<MkUserName :user="note.user"/>
</template>
<template #word>
{{ mutedWords }}
</template>
</I18n>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed } from 'vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
const props = defineProps<{
muted: false | 'sensitiveMute' | string[];
note: Misskey.entities.Note;
}>();
const mutedWords = computed(() => Array.isArray(props.muted)
? props.muted.join(', ')
: props.muted);
</script>
<style module lang="scss">
</style>

View file

@ -88,13 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:isAnim="allowAnim"
:isBlock="true"
/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
</div>
@ -103,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/>
</div>
<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" @click.stop @click="collapsed = false">
@ -161,10 +155,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ph-smiley ph-bold ph-lg"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" class="_button" :class="$style.footerButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @click.stop="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -172,24 +169,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</article>
</div>
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
<template #word>
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
</template>
</I18n>
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
</div>
<div v-else>
<!--
@ -205,7 +185,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
import * as config from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { Ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
@ -224,7 +204,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkButton from '@/components/MkButton.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
@ -236,7 +216,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/utility/get-note-menu.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
@ -246,13 +226,17 @@ import { getNoteSummary } from '@/utility/get-note-summary.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { instance, isEnabledUrlPreview } from '@/instance.js';
import { focusPrev, focusNext } from '@/utility/focus.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { useRouter } from '@/router.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -272,8 +256,6 @@ const emit = defineEmits<{
const router = useRouter();
const inTimeline = inject<boolean>('inTimeline', false);
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
@ -322,15 +304,14 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const renoted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const { muted, hardMuted } = checkMutes(appearNote.value, props.withHardMute);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
@ -347,38 +328,13 @@ const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
}));
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted);
/* Overload FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
*/
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, $i, mutedWords);
if (Array.isArray(result)) return result;
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
if (Array.isArray(replyResult)) return replyResult;
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
if (Array.isArray(renoteResult)) return renoteResult;
}
if (checkOnly) return false;
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
return 'sensitiveMute';
}
return false;
}
let renoting = false;
const keymap = {
@ -403,6 +359,11 @@ const keymap = {
if (!prefer.s.showClipButtonInNoteFooter) return;
clip();
},
't': () => {
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
'o': () => {
if (renoteCollapsed.value) return;
galleryEl.value?.openGallery();
@ -825,6 +786,12 @@ async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate() {
if (props.mock) return;
await translateNote(appearNote.value.id, translation, translating);
}
function showRenoteMenu(): void {
if (props.mock) {
return;
@ -1233,13 +1200,6 @@ function emitUpdReaction(emoji: string, delta: number) {
margin-right: 0.5em;
}
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
.urlPreview {
margin-top: 8px;
}

View file

@ -109,13 +109,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_selectable"
/>
<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-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
<div v-if="appearNote.files && appearNote.files.length > 0">
@ -123,7 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
@ -177,10 +171,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ph-smiley ph-bold ph-lg"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -235,11 +232,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
</div>
</template>
@ -248,7 +241,7 @@ import { computed, inject, onMounted, onUnmounted, onUpdated, provide, ref, useT
import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js';
import * as config from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue';
@ -265,7 +258,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import SkInstanceTicker from '@/components/SkInstanceTicker.vue';
import { pleaseLogin } from '@/utility/please-login.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
@ -276,7 +269,7 @@ import { reactionPicker } from '@/utility/reaction-picker.js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { getNoteVersionsMenu } from '@/utility/get-note-versions-menu.js';
import { checkAnimationFromMfm } from '@/utility/check-animated-mfm.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
@ -290,11 +283,15 @@ import MkPagination from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { instance, isEnabledUrlPreview } from '@/instance.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -346,12 +343,12 @@ const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const isDeleted = ref(false);
const renoted = ref(false);
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm ? true : false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]);
@ -364,6 +361,8 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted);
const { muted } = checkMutes(appearNote.value);
watch(() => props.expandAllCws, (expandAllCws) => {
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
});
@ -382,7 +381,7 @@ let renoting = false;
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
}));
const keymap = {
@ -394,6 +393,11 @@ const keymap = {
if (!prefer.s.showClipButtonInNoteFooter) return;
clip();
},
't': () => {
if (prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable) {
translate();
}
},
'o': () => galleryEl.value?.openGallery(),
'v|enter': () => {
if (appearNote.value.cw != null) {
@ -772,6 +776,10 @@ async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
}
async function translate() {
await translateNote(appearNote.value.id, translation, translating);
}
function showRenoteMenu(): void {
if (!isMyRenote) return;
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
@ -1102,13 +1110,6 @@ onUnmounted(() => {
color: var(--MI_THEME-renote);
}
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
.poll {
font-size: 80%;
}

View file

@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_button"
:class="$style.noteFooterButton"
:style="renoted ? 'color: var(--MI_THEME-accent) !important;' : ''"
@mousedown="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
@click.stop="renoted ? undoRenote() : boostVisibility($event.shiftKey)"
>
<i class="ph-rocket-launch ph-bold ph-lg"></i>
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
@ -50,24 +50,30 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="quoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown="quote()"
@click.stop="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()">
<button v-if="note.myReaction == null && note.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.noteFooterButton" class="_button" @click.stop="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()">
<button v-if="note.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @click.stop="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()">
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.noteFooterButton" class="_button" @click.stop="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button v-if="prefer.s.showTranslationButtonInNoteFooter && $i?.policies.canUseTranslator && instance.translatorAvailable" ref="translationButton" class="_button" :class="$style.noteFooterButton" :disabled="translating || !!translation" @click.stop="translate()">
<i class="ti ti-language-hiragana"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @click.stop="menu()">
<i class="ph-dots-three ph-bold ph-lg"></i>
</button>
</footer>
@ -81,19 +87,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-else :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkUserName :user="appearNote.user"/>
</template>
</I18n>
<SkMutedNote :muted="muted" :note="appearNote"></SkMutedNote>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, shallowRef, watch } from 'vue';
import { computed, inject, ref, shallowRef, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import { host } from '@@/js/config.js';
import * as config from '@@/js/config.js';
import type { Ref } from 'vue';
import type { Visibility } from '@/utility/boost-quote.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import SkNoteHeader from '@/components/SkNoteHeader.vue';
@ -107,16 +110,18 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { userPage } from '@/filters/user.js';
import { checkWordMute } from '@/utility/check-word-mute.js';
import { checkMutes } from '@/utility/check-word-mute.js';
import { pleaseLogin } from '@/utility/please-login.js';
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { reactionPicker } from '@/utility/reaction-picker.js';
import { claimAchievement } from '@/utility/achievements.js';
import { getNoteMenu } from '@/utility/get-note-menu.js';
import { getNoteClipMenu, getNoteMenu, translateNote } from '@/utility/get-note-menu.js';
import { boostMenuItems, computeRenoteTooltip } from '@/utility/boost-quote.js';
import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import { instance } from '@/instance';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -140,12 +145,12 @@ const canRenote = computed(() => ['public', 'home'].includes(props.note.visibili
const hideLine = computed(() => props.detail);
const el = shallowRef<HTMLElement>();
const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false);
const translation = ref<any>(null);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const isDeleted = ref(false);
const renoted = ref(false);
const reactButton = shallowRef<HTMLElement>();
const clipButton = useTemplateRef('clipButton');
const renoteButton = shallowRef<HTMLElement>();
const quoteButton = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>();
@ -168,9 +173,11 @@ const isRenote = (
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `https://${host}/notes/${appearNote.value.id}`,
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
}));
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
async function addReplyTo(replyNote: Misskey.entities.Note) {
replies.value.unshift(replyNote);
appearNote.value.repliesCount += 1;
@ -184,6 +191,8 @@ async function removeReply(id: Misskey.entities.Note['id']) {
}
}
const { muted } = checkMutes(appearNote.value);
useNoteCapture({
rootEl: el,
note: appearNote,
@ -392,6 +401,14 @@ function menu(): void {
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate() {
await translateNote(appearNote.value.id, translation, translating);
}
if (props.detail) {
misskeyApi('notes/children', {
noteId: props.note.id,

View file

@ -0,0 +1,48 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="translating || translation != null" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation && translation.text != null">
<b v-if="translation.sourceLang">{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" class="_selectable"/>
</div>
<div v-else>{{ i18n.ts.translationFailed }}</div>
</div>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { watch } from 'vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
translating?: boolean;
translation?: Misskey.entities.NotesTranslateResponse | false | null;
}>(), {
translating: false,
translation: null,
});
if (_DEV_) {
// Prop watch syntax: https://stackoverflow.com/a/59127059
watch(
[() => props.translation, () => props.translating],
([translation, translating]) => console.debug('Translation status changed: ', { translation, translating }),
{ immediate: true },
);
}
</script>
<style module lang="scss">
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
</style>

View file

@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div>
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
</div>
</header>
<div :class="$style.noteContent">
@ -42,18 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<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" :text="appearNote.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
<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" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></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>
@ -92,12 +86,14 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { userPage } from '@/filters/user.js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { i18n } from '@/i18n.js';
import { deepClone } from '@/utility/clone.js';
import { dateTimeFormat } from '@/utility/intl-const.js';
import { prefer } from '@/preferences';
import { getPluginHandlers } from '@/plugin.js';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
const props = defineProps<{
note: Misskey.entities.Note;
@ -146,14 +142,14 @@ const isRenote = (
);
const el = shallowRef<HTMLElement>();
let appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null;
const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null;
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const showContent = ref(false);
const translation = ref(null);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const urls = appearNote.value.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null;
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
</script>
@ -259,13 +255,6 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
color: var(--MI_THEME-renote);
}
.translation {
border: solid 0.5px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
margin-top: 8px;
}
.poll {
font-size: 80%;
}

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.wordMuteTestDescription }}</template>
</MkTextarea>
<div><MkButton :disabled="!testWords" @click="testWordMutes">{{ i18n.ts.wordMuteTestTest }}</MkButton></div>
<div v-if="testMatches == null">{{ i18n.ts.wordMuteTestNoResults}}</div>
<div v-if="testMatches == null">{{ i18n.ts.wordMuteTestNoResults }}</div>
<div v-else-if="testMatches === ''">{{ i18n.ts.wordMuteTestNoMatch }}</div>
<div v-else>{{ i18n.tsx.wordMuteTestMatch({ words: testMatches }) }}</div>
</div>
@ -44,7 +44,7 @@ function testWordMutes() {
try {
const mutes = parseMutes(props.mutedWords);
const matches = checkWordMute(testWords.value, null, mutes);
testMatches.value = matches ? matches.flat(2).join(', ') : '';
testMatches.value = matches ? matches.join(', ') : '';
} catch {
// Error is displayed by above function
testMatches.value = null;

View file

@ -7,38 +7,49 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init">
<div v-if="tab === 'overview'" class="_gaps_m">
<div class="aeakzknw">
<div v-if="tab === 'overview'" class="_gaps">
<div v-if="user" class="aeakzknw">
<MkAvatar class="avatar" :user="user" indicator link preview/>
<div class="body">
<span class="name"><MkUserName class="name" :user="user"/></span>
<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
<span class="sub">
<span class="acct _monospace">@{{ acct(user) }}</span>
<button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard('@' + acct(user))"><i class="ti ti-copy"></i></button>
</span>
<span class="sub">
<span class="_monospace">{{ user.id }}</span>
<button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(user.id)"><i class="ti ti-copy"></i></button>
</span>
<span class="state">
<span v-if="!approved" class="silenced">{{ i18n.ts.notApproved }}</span>
<span v-if="approved && !user.host" class="moderator">{{ i18n.ts.approved }}</span>
<span v-if="suspended" class="suspended">Suspended</span>
<span v-if="silenced" class="silenced">Silenced</span>
<span v-if="moderator" class="moderator">Moderator</span>
<span v-if="suspended" class="suspended">{{ i18n.ts.suspended }}</span>
<span v-if="silenced" class="silenced">{{ i18n.ts.silenced }}</span>
<span v-if="moderator" class="moderator">{{ i18n.ts.moderator }}</span>
</span>
</div>
</div>
<MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo>
<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
<div style="display: flex; flex-direction: column; gap: 1em;">
<MkKeyValue :copy="user.id" oneline>
<template #key>ID</template>
<template #value><span class="_monospace">{{ user.id }}</span></template>
</MkKeyValue>
<!-- 要る
<MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline>
<template #key>IP (recent)</template>
<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
</MkKeyValue>
-->
<template v-if="!isSystem">
<MkFolder v-if="!isSystem">
<template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.details }}</template>
<div style="display: flex; flex-direction: column; gap: 1em;">
<MkKeyValue v-if="user" :copy="user.id" oneline>
<template #key>{{ i18n.ts.id }}</template>
<template #value><span class="_monospace">{{ user.id }}</span></template>
</MkKeyValue>
<MkKeyValue v-if="user" :copy="'@' + acct(user)" oneline>
<template #key>{{ i18n.ts.username }}</template>
<template #value><span class="_monospace">@{{ acct(user) }}</span></template>
</MkKeyValue>
<!-- 要る
<MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline>
<template #key>IP (recent)</template>
<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
</MkKeyValue>
-->
<MkKeyValue oneline>
<template #key>{{ i18n.ts.createdAt }}</template>
<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
@ -51,16 +62,64 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #key>{{ i18n.ts.email }}</template>
<template #value><span class="_monospace">{{ info.email }}</span></template>
</MkKeyValue>
</template>
</div>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.totalFollowers }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowers"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.totalFollowing }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowing"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.remoteFollowers }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowers"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.remoteFollowing }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowing"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.localFollowers }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowers"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.localFollowing }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowing"></MkNumber></span></template>
</MkKeyValue>
</div>
</MkFolder>
<MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
<MkFolder v-if="info">
<template #icon><i class="ph-scroll ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts._role.policies }}</template>
<div class="_gaps">
<div v-for="policy in Object.keys(info.policies)" :key="policy">
{{ policy }} ... {{ info.policies[policy] }}
</div>
</div>
</MkFolder>
<MkFolder v-if="iAmAdmin && ips && ips.length > 0">
<template #icon><i class="ph-network ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.ip }}</template>
<MkInfo>{{ i18n.ts.ipTip }}</MkInfo>
<div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
<span class="date">{{ record.createdAt }}</span>
<span class="ip">{{ record.ip }}</span>
</div>
</MkFolder>
<MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0">
<template #icon><i class="ph-stamp ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
<MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
</MkFolder>
<FormSection v-if="user.host">
<template #label>ActivityPub</template>
<FormSection v-if="user?.host">
<template #label>{{ i18n.ts.activityPub }}</template>
<div class="_gaps_m">
<div style="display: flex; flex-direction: column; gap: 1em;">
@ -73,12 +132,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #value><MkTime mode="detail" :time="user.lastFetchedAt"/></template>
</MkKeyValue>
</div>
<MkButton @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
</div>
</FormSection>
<FormSection v-if="!isSystem">
<FormSection v-if="!isSystem && user && iAmModerator">
<div class="_gaps">
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>
<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
@ -90,58 +147,40 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.mandatoryCWDescription }}</template>
</MkInput>
<div>
<MkButton v-if="user.host == null && !isSystem" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton>
<div :class="$style.buttonStrip">
<MkButton v-if="user.host != null" inline @click="updateRemoteUser"><i class="ph-cloud-arrow-down ph-bold ph-lg"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
<MkButton v-if="user.host == null" inline accent @click="resetPassword"><i class="ph-password ph-bold ph-lg"></i> {{ i18n.ts.resetPassword }}</MkButton>
<MkButton inline accent @click="unsetUserAvatar"><i class="ph-camera-slash ph-bold ph-lg"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
<MkButton inline accent @click="unsetUserBanner"><i class="ph-image-broken ph-bold ph-lg"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
<MkButton inline danger @click="deleteAllFiles"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton>
<MkButton v-if="iAmAdmin" inline danger @click="deleteAccount"><i class="ph-skull ph-bold ph-lg"></i> {{ i18n.ts.deleteAccount }}</MkButton>
</div>
<MkFolder>
<template #icon><i class="ph-scroll ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts._role.policies }}</template>
<div class="_gaps">
<div v-for="policy in Object.keys(info.policies)" :key="policy">
{{ policy }} ... {{ info.policies[policy] }}
</div>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-password"></i></template>
<template #label>IP</template>
<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
<!-- TODO translate -->
<MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo>
<template v-if="iAmAdmin && ips">
<div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
<span class="date">{{ record.createdAt }}</span>
<span class="ip">{{ record.ip }}</span>
</div>
</template>
</MkFolder>
<div>
<MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserAvatar"><i class="ti ti-user-circle"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
<MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserBanner"><i class="ti ti-photo"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
<MkButton v-if="iAmModerator" inline danger @click="deleteAllFiles"><i class="ph-cloud ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton>
</div>
<MkButton v-if="$i.isAdmin && !isSystem" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton>
</div>
</FormSection>
</div>
<div v-else-if="tab === 'roles'" class="_gaps">
<MkButton v-if="user.host == null" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<MkButton primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="role in info.roles" :key="role.id">
<div :class="$style.roleItemMain">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
<button class="_button" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button class="_button" @click="toggleRoleItem(role)">
<i v-if="!expandedRoles.includes(role.id)" class="ti ti-chevron-down"></i>
<i v-if="expandedRoles.includes(role.id)" class="ti ti-chevron-left"></i>
</button>
<button v-if="role.target === 'manual' || info.roleAssigns.some(a => a.roleId === role.id)" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
<div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub">
<div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div>
<div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
<template v-if="info.roleAssigns.some(a => a.roleId === role.id)">
<div>{{ i18n.ts.roleAssigned }}: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div>
<div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">{{ i18n.ts.rolePeriod }}: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div>
<div v-else>{{ i18n.ts.rolePeriod }}: {{ i18n.ts.indefinitely }}</div>
</template>
<template v-else>
<div>{{ i18n.ts.roleAssigned }}: {{ i18n.ts.roleAutomatic }}</div>
</template>
</div>
</div>
</div>
@ -231,6 +270,8 @@ import { iAmAdmin, $i, iAmModerator } from '@/i.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkInput from '@/components/MkInput.vue';
import MkNumber from '@/components/MkNumber.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
const props = withDefaults(defineProps<{
userId: string;
@ -740,4 +781,12 @@ definePage(() => ({
border-radius: var(--MI-radius-sm);
cursor: pointer;
}
.buttonStrip {
margin: calc(var(--MI-margin) / 2 * -1);
>* {
margin: calc(var(--MI-margin) / 2);
}
}
</style>

View file

@ -8,6 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkInput v-model="translationTimeout" type="number" manualSave @update:modelValue="saveTranslationTimeout">
<template #label>{{ i18n.ts.translationTimeoutLabel }}</template>
<template #caption>{{ i18n.ts.translationTimeoutCaption }}</template>
</MkInput>
<MkFolder>
<template #label>DeepL Translation</template>
@ -69,6 +74,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkFolder from '@/components/MkFolder.vue';
const translationTimeout = ref(0);
const deeplAuthKey = ref<string | null>('');
const deeplIsPro = ref<boolean>(false);
const deeplFreeMode = ref<boolean>(false);
@ -78,6 +84,7 @@ const libreTranslateKey = ref<string | null>('');
async function init() {
const meta = await misskeyApi('admin/meta');
translationTimeout.value = meta.translationTimeout;
deeplAuthKey.value = meta.deeplAuthKey;
deeplIsPro.value = meta.deeplIsPro;
deeplFreeMode.value = meta.deeplFreeMode;
@ -86,6 +93,13 @@ async function init() {
libreTranslateKey.value = meta.libreTranslateKey;
}
async function saveTranslationTimeout() {
await os.apiWithDialog('admin/update-meta', {
translationTimeout: translationTimeout.value,
});
await os.promiseDialog(fetchInstance(true));
}
function save_deepl() {
os.apiWithDialog('admin/update-meta', {
deeplAuthKey: deeplAuthKey.value,
@ -93,7 +107,7 @@ function save_deepl() {
deeplFreeMode: deeplFreeMode.value,
deeplFreeInstance: deeplFreeInstance.value,
}).then(() => {
fetchInstance(true);
os.promiseDialog(fetchInstance(true));
});
}
@ -102,7 +116,7 @@ function save_libre() {
libreTranslateURL: libreTranslateURL.value,
libreTranslateKey: libreTranslateKey.value,
}).then(() => {
fetchInstance(true);
os.promiseDialog(fetchInstance(true));
});
}

View file

@ -797,6 +797,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canTrend, 'canTrend'])">
<template #label>{{ i18n.ts._role._options.canTrend }}</template>
<template #suffix>
<span v-if="role.policies.canTrend.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canTrend.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canTrend)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canTrend.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canTrend.value" :disabled="role.policies.canTrend.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canTrend.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div>
</FormSlot>
</div>

View file

@ -300,6 +300,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canTrend, 'canTrend'])">
<template #label>{{ i18n.ts._role._options.canTrend }}</template>
<template #suffix>{{ policies.canTrend ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canTrend">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
</div>
</MkFolder>
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>

View file

@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
</MkFukidashi>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :showAsQuote="!message.fromUser.rejectQuotes" style="margin: 8px 0;"/>
<div :class="$style.footer">
<button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
<MkTime :class="$style.time" :time="message.createdAt"/>

View file

@ -39,14 +39,14 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass="prefer.s.animation ? $style.transition_x_move : ''"
tag="div" class="_gaps"
>
<template v-for="item in timeline.toReversed()" :key="item.id">
<div v-for="item in timeline.toReversed()" :key="item.id">
<XMessage v-if="item.type === 'item'" :message="item.data"/>
<div v-else-if="item.type === 'date'" :class="$style.dateDivider">
<span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span>
</div>
</template>
</div>
</TransitionGroup>
</div>

View file

@ -46,7 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="tag == null">
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.tsx.popularUsersLocal({ name: instance.name ?? host }) }}</template>
<MkUserList :pagination="popularUsersLocalF"/>
</MkFoldableSection>
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsersGlobal }}</template>
<MkUserList :pagination="popularUsersF"/>
</MkFoldableSection>
<MkFoldableSection class="_margin">
@ -65,6 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { watch, ref, useTemplateRef, computed } from 'vue';
import * as Misskey from 'misskey-js';
import { host } from '@@/js/config';
import MkUserList from '@/components/MkUserList.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue';
@ -73,7 +78,7 @@ import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
tag?: string;
tag?: string | undefined;
}>();
const origin = ref('local');
@ -86,43 +91,48 @@ watch(() => props.tag, () => {
});
const tagUsers = computed(() => ({
endpoint: 'hashtags/users' as const,
endpoint: 'hashtags/users',
limit: 30,
params: {
tag: props.tag,
origin: 'combined',
sort: '+follower',
},
}));
} as const));
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
const pinnedUsers = { endpoint: 'pinned-users', limit: 10, noPaging: true } as const;
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'local',
sort: '+follower',
} };
} } as const;
const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'local',
sort: '+updatedAt',
} };
} } as const;
const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'local',
state: 'alive',
sort: '+createdAt',
} };
} } as const;
const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'remote',
sort: '+follower',
} };
} } as const;
const popularUsersLocalF = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'remote',
sort: '+localFollower',
} } as const;
const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'combined',
sort: '+updatedAt',
} };
} } as const;
const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
origin: 'combined',
sort: '+createdAt',
} };
} } as const;
misskeyApi('hashtags/list', {
sort: '+attachedLocalUsers',

View file

@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import * as config from '@@/js/config.js';
import type { Paging } from '@/components/MkPagination.vue';
import DynamicNoteDetailed from '@/components/DynamicNoteDetailed.vue';
import MkNotes from '@/components/MkNotes.vue';
@ -151,7 +151,7 @@ function fetchNote() {
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
openOnRemote: {
type: 'lookup',
url: `https://${host}/notes/${props.noteId}`,
url: `${config.url}/notes/${props.noteId}`,
},
});
}

View file

@ -22,6 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo>
<MkInfo warn>{{ i18n.ts.wordMuteWarning }}</MkInfo>
<SearchMarker
:label="i18n.ts.showMutedWord"
:keywords="['show']"
@ -44,6 +46,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo>
<MkInfo warn>{{ i18n.ts.wordMuteWarning }}</MkInfo>
<XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
</div>
</MkFolder>

View file

@ -196,8 +196,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['pinned', 'list']">
<MkFolder>
<SearchMarker v-slot="slotProps" :keywords="['pinned', 'list']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template>
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
<MkButton v-if="prefer.r.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton>
@ -271,6 +271,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['footer', 'action', 'translation', 'show']">
<MkPreferenceContainer k="showTranslationButtonInNoteFooter">
<MkSwitch v-model="showTranslationButtonInNoteFooter">
<template #label><SearchLabel>{{ i18n.ts.showTranslationButtonInNoteFooter }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['reaction', 'count', 'show']">
<MkPreferenceContainer k="showReactionsCount">
<MkSwitch v-model="showReactionsCount">
@ -428,9 +436,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</SearchMarker>
</div>
<SearchMarker :keywords="['default', 'note', 'visibility']">
<SearchMarker v-slot="slotProps" :keywords="['default', 'note', 'visibility']">
<MkDisableSection :disabled="rememberNoteVisibility">
<MkFolder>
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template>
<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
<template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template>
@ -851,24 +859,28 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['boost', 'show', 'visib', 'selector']">
<SearchMarker v-slot="slotProps" :keywords="['boost', 'show', 'visib', 'selector']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.boostSettings }}</SearchLabel></template>
<div class="_gaps_m">
<MkPreferenceContainer k="showVisibilitySelectorOnBoost">
<MkSwitch v-model="showVisibilitySelectorOnBoost">
<template #label><SearchLabel>{{ i18n.ts.showVisibilitySelectorOnBoost }}</SearchLabel></template>
<template #caption>{{ i18n.ts.showVisibilitySelectorOnBoostDescription }}</template>
</MkSwitch>
</MkPreferenceContainer>
<MkPreferenceContainer k="visibilityOnBoost">
<MkSelect v-model="visibilityOnBoost">
<template #label><SearchLabel>{{ i18n.ts.visibilityOnBoost }}</SearchLabel></template>
<option value="public">{{ i18n.ts._visibility['public'] }}</option>
<option value="home">{{ i18n.ts._visibility['home'] }}</option>
<option value="followers">{{ i18n.ts._visibility['followers'] }}</option>
</MkSelect>
</MkPreferenceContainer>
<SearchMarker :keywords="['boost', 'show', 'visib', 'selector']">
<MkPreferenceContainer k="showVisibilitySelectorOnBoost">
<MkSwitch v-model="showVisibilitySelectorOnBoost">
<template #label><SearchLabel>{{ i18n.ts.showVisibilitySelectorOnBoost }}</SearchLabel></template>
<template #caption>{{ i18n.ts.showVisibilitySelectorOnBoostDescription }}</template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['boost', 'visib']">
<MkPreferenceContainer k="visibilityOnBoost">
<MkSelect v-model="visibilityOnBoost">
<template #label><SearchLabel>{{ i18n.ts.visibilityOnBoost }}</SearchLabel></template>
<option value="public">{{ i18n.ts._visibility['public'] }}</option>
<option value="home">{{ i18n.ts._visibility['home'] }}</option>
<option value="followers">{{ i18n.ts._visibility['followers'] }}</option>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
@ -900,8 +912,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']">
<MkFolder>
<SearchMarker v-slot="slotProps" :keywords="['emoji', 'dictionary', 'additional', 'extra']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template>
<div class="_buttons">
<template v-for="lang in emojiIndexLangs" :key="lang">
@ -973,6 +985,7 @@ const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior');
const hemisphere = prefer.model('hemisphere');
const showNoteActionsOnlyHover = prefer.model('showNoteActionsOnlyHover');
const showClipButtonInNoteFooter = prefer.model('showClipButtonInNoteFooter');
const showTranslationButtonInNoteFooter = prefer.model('showTranslationButtonInNoteFooter');
const collapseRenotes = prefer.model('collapseRenotes');
const advancedMfm = prefer.model('advancedMfm');
const showReactionsCount = prefer.model('showReactionsCount');
@ -1109,6 +1122,7 @@ watch([
makeEveryTextElementsSelectable,
enableHorizontalSwipe,
enablePullToRefresh,
noteDesign,
], async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
});

View file

@ -203,19 +203,21 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSlot>
</SearchMarker>
<SearchMarker :keywords="['federate', 'auth', 'fetch']">
<MkFolder v-if="instance.federation !== 'none'">
<SearchMarker v-slot="slotProps" :keywords="['federate', 'auth', 'fetch']">
<MkFolder v-if="instance.federation !== 'none'" :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.authorizedFetchSection }}</SearchLabel></template>
<template #suffix>{{ computedAllowUnsignedFetch !== 'always' ? i18n.ts.enabled : i18n.ts.disabled }}</template>
<MkRadios v-model="allowUnsignedFetch" @update:modelValue="save()">
<template #label><SearchLabel>{{ i18n.ts.authorizedFetchLabel }}</SearchLabel></template>
<template #caption><SearchKeyword>{{ i18n.ts.authorizedFetchDescription }}</SearchKeyword></template>
<option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{ i18n.ts._authorizedFetchValueDescription.never }}</option>
<option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{ i18n.ts._authorizedFetchValueDescription.always }}</option>
<option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{ i18n.ts._authorizedFetchValueDescription.essential }}</option>
<option value="staff">{{ i18n.ts._authorizedFetchValue.staff }} - {{ i18n.tsx._authorizedFetchValueDescription.staff({ value: i18n.ts._authorizedFetchValue[instance.allowUnsignedFetch] }) }}</option>
</MkRadios>
<SearchMarker :keywords="['federate', 'auth', 'fetch']">
<MkRadios v-model="allowUnsignedFetch" @update:modelValue="save()">
<template #label><SearchLabel>{{ i18n.ts.authorizedFetchLabel }}</SearchLabel></template>
<template #caption><SearchKeyword>{{ i18n.ts.authorizedFetchDescription }}</SearchKeyword></template>
<option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{ i18n.ts._authorizedFetchValueDescription.never }}</option>
<option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{ i18n.ts._authorizedFetchValueDescription.always }}</option>
<option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{ i18n.ts._authorizedFetchValueDescription.essential }}</option>
<option value="staff">{{ i18n.ts._authorizedFetchValue.staff }} - {{ i18n.tsx._authorizedFetchValueDescription.staff({ value: i18n.ts._authorizedFetchValue[instance.allowUnsignedFetch] }) }}</option>
</MkRadios>
</SearchMarker>
</MkFolder>
</SearchMarker>

View file

@ -71,9 +71,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect>
</SearchMarker>
<SearchMarker :keywords="['metadata']">
<SearchMarker v-slot="slotProps" :keywords="['metadata']">
<FormSlot>
<MkFolder>
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><i class="ti ti-list"></i></template>
<template #label><SearchLabel>{{ i18n.ts._profile.metadataEdit }}</SearchLabel></template>
<template #footer>
@ -139,8 +139,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect>
</SearchMarker>
<SearchMarker>
<MkFolder>
<SearchMarker v-slot="slotProps">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>{{ i18n.ts.advancedSettings }}</SearchLabel></template>
<div class="_gaps_m">
@ -149,6 +149,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label><SearchLabel>{{ i18n.ts.flagAsCat }}</SearchLabel></template>
<template #caption>{{ i18n.ts.flagAsCatDescription }}</template>
</MkSwitch>
</SearchMarker>
<SearchMarker :keywords="['cat']">
<MkSwitch v-if="profile.isCat" v-model="profile.speakAsCat">
<template #label><SearchLabel>{{ i18n.ts.flagSpeakAsCat }}</SearchLabel></template>
<template #caption>{{ i18n.ts.flagSpeakAsCatDescription }}</template>

View file

@ -248,6 +248,9 @@ export const PREF_DEF = {
showClipButtonInNoteFooter: {
default: false,
},
showTranslationButtonInNoteFooter: {
default: false,
},
reactionsDisplaySize: {
default: 'medium' as 'small' | 'medium' | 'large',
},

View file

@ -240,6 +240,12 @@ rt {
contain: strict;
overflow: auto;
overscroll-behavior: contain;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
._pageScrollable {

View file

@ -3,8 +3,49 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { inject, ref } from 'vue';
import type { Ref } from 'vue';
import { $i } from '@/i';
export function checkMutes(noteToCheck: Misskey.entities.Note, withHardMute = false) {
const muted = ref(checkMute(noteToCheck, $i?.mutedWords));
const hardMuted = ref(withHardMute && checkMute(noteToCheck, $i?.hardMutedWords, true));
return { muted, hardMuted };
}
export function checkMute(note: Misskey.entities.Note, mutes: undefined | null): false;
export function checkMute(note: Misskey.entities.Note, mutes: undefined | null, checkOnly: false): false;
export function checkMute(note: Misskey.entities.Note, mutes: undefined | null, checkOnly?: boolean): false | 'sensitiveMute';
export function checkMute(note: Misskey.entities.Note, mutes: Array<string | string[]> | undefined | null): string[] | false;
export function checkMute(note: Misskey.entities.Note, mutes: Array<string | string[]> | undefined | null, checkOnly: false): string[] | false;
export function checkMute(note: Misskey.entities.Note, mutes: Array<string | string[]> | undefined | null, checkOnly?: boolean): string[] | false | 'sensitiveMute';
export function checkMute(note: Misskey.entities.Note, mutes: Array<string | string[]> | undefined | null, checkOnly = false): string[] | false | 'sensitiveMute' {
if (mutes != null) {
const result =
checkWordMute(note, $i, mutes)
|| checkWordMute(note.reply, $i, mutes)
|| checkWordMute(note.renote, $i, mutes);
// Only continue to sensitiveMute if we don't match any *actual* mutes
if (result) {
return result;
}
}
if (checkOnly) {
const inTimeline = inject<boolean>('inTimeline', false);
const tl_withSensitive = inject<Ref<boolean> | null>('tl_withSensitive', null);
if (inTimeline && tl_withSensitive?.value === false && note.files?.some((v) => v.isSensitive)) {
return 'sensitiveMute';
}
}
return false;
}
export function checkWordMute(note: string | Misskey.entities.Note | undefined | null, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): string[] | false {
if (note == null) return false;
export function checkWordMute(note: string | Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
// 自分自身
if (me && typeof(note) === 'object' && (note.userId === me.id)) return false;
@ -13,30 +54,37 @@ export function checkWordMute(note: string | Misskey.entities.Note, me: Misskey.
if (text === '') return false;
const matched = mutedWords.filter(filter => {
const matched = mutedWords.reduce((matchedWords, filter) => {
if (Array.isArray(filter)) {
// Clean up
const filteredFilter = filter.filter(keyword => keyword !== '');
if (filteredFilter.length === 0) return false;
return filteredFilter.every(keyword => text.includes(keyword));
if (filteredFilter.length > 0 && filteredFilter.every(keyword => text.includes(keyword))) {
const fullFilter = filteredFilter.join(' ');
matchedWords.add(fullFilter);
}
} else {
// represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) return false;
try {
return new RegExp(regexp[1], regexp[2]).test(text);
} catch (err) {
// This should never happen due to input sanitisation.
return false;
if (regexp) {
try {
const flags = regexp[2].includes('g') ? regexp[2] : (regexp[2] + 'g');
const matches = text.matchAll(new RegExp(regexp[1], flags));
for (const match of matches) {
matchedWords.add(match[0]);
}
} catch {
// This should never happen due to input sanitisation.
}
}
}
});
if (matched.length > 0) return matched;
return matchedWords;
}, new Set<string>());
// Nested arrays are intentional, otherwise the note components will join with space (" ") and it's confusing.
if (matched.size > 0) return Array.from(matched);
}
return false;

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as config from '@@/js/config.js';
import type * as Misskey from 'misskey-js';
import type * as mfm from '@transfem-org/sfm-js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
/**
* Extracts all previewable URLs from a note.
*/
export function extractPreviewUrls(note: Misskey.entities.Note, contents: mfm.MfmNode[]): string[] {
const links = extractUrlFromMfm(contents);
return links.filter(url =>
// Remote note
url !== note.url &&
url !== note.uri &&
// Local note
url !== `${config.url}/notes/${note.id}` &&
// Remote reply
url !== note.reply?.url &&
url !== note.reply?.uri &&
// Local reply
url !== `${config.url}/notes/${note.reply?.id}` &&
// Remote renote or quote
url !== note.renote?.url &&
url !== note.renote?.uri &&
// Local renote or quote
url !== `${config.url}/notes/${note.renote?.id}` &&
// Remote renote *of* a quote
url !== note.renote?.renote?.url &&
url !== note.renote?.renote?.uri &&
// Local renote *of* a quote
url !== `${config.url}/notes/${note.renote?.renote?.id}`);
}

View file

@ -4,21 +4,34 @@
*/
import * as mfm from '@transfem-org/sfm-js';
import { unique } from '@/utility/array.js';
// unique without hash
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
const removeHash = (x: string) => {
if (URL.canParse(x)) {
const url = new URL(x);
url.hash = '';
return url.toString();
} else {
return x.replace(/#[^#]*$/, '');
}
};
export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
const urlNodes = mfm.extract(nodes, (node) => {
return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
});
const urls: string[] = unique(urlNodes.map(x => x.props.url));
const urls = new Map<string, string>();
return urls.reduce((array, url) => {
const urlWithoutHash = removeHash(url);
if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
return array;
}, [] as string[]);
// Single iteration pass to avoid potential DoS in maliciously-constructed notes.
for (const node of nodes) {
if ((node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent))) {
const url = (node as mfm.MfmUrl | mfm.MfmLink).props.url;
const key = removeHash(url);
// Keep the first match only, to preserve existing behavior.
if (!urls.has(key)) {
urls.set(key, url);
}
}
}
return Array.from(urls.values());
}

View file

@ -149,3 +149,4 @@ function createDefaultStorage(): Ref<StorageInterface> {
},
}));
}

View file

@ -176,7 +176,7 @@ function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuIt
export function getNoteMenu(props: {
note: Misskey.entities.Note;
translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
translation: Ref<Misskey.entities.NotesTranslateResponse | false | null>;
translating: Ref<boolean>;
isDeleted: Ref<boolean>;
currentClip?: Misskey.entities.Clip;
@ -290,17 +290,6 @@ export function getNoteMenu(props: {
os.pageWindow(`/notes/${appearNote.id}`);
}
async function translate(): Promise<void> {
if (props.translation.value != null) return;
props.translating.value = true;
const res = await misskeyApi('notes/translate', {
noteId: appearNote.id,
targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
});
props.translating.value = false;
props.translation.value = res;
}
const menuItems: MenuItem[] = [];
if ($i) {
@ -357,7 +346,7 @@ export function getNoteMenu(props: {
menuItems.push({
icon: 'ti ti-language-hiragana',
text: i18n.ts.translate,
action: translate,
action: () => translateNote(appearNote.id, props.translation, props.translating),
});
}
@ -697,3 +686,20 @@ export function getRenoteMenu(props: {
menu: renoteItems,
};
}
export async function translateNote(noteId: string, translation: Ref<Misskey.entities.NotesTranslateResponse | false | null>, translating: Ref<boolean>): Promise<void> {
if (translating.value || translation.value) return;
translating.value = true;
try {
const targetLang = miLocalStorage.getItem('lang') ?? navigator.language;
translation.value = await misskeyApi('notes/translate', {
noteId,
targetLang,
});
} catch (err) {
console.error(`Translation failed for ${noteId}: `, err);
translation.value = false;
} finally {
translating.value = false;
}
}

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type * as Misskey from 'misskey-js';
/**
* Gets IDs of notes that are visibly the "same" as the current note.
* These are IDs that should not be recursively resolved when starting from the provided note as entry.
*/
export function getSelfNoteIds(note: Misskey.entities.Note): string[] {
const ids = [note.id]; // Regular note
if (note.reply) ids.push(note.reply.id); // Reply
else if (note.replyId) ids.push(note.replyId); // Reply (not packed)
if (note.renote) ids.push(note.renote.id); // Renote or quote
else if (note.renoteId) ids.push(note.renoteId); // Renote or quote (not packed)
if (note.renote?.renote) ids.push(note.renote.renote.id); // Renote *of* a quote
else if (note.renote?.renoteId) ids.push(note.renote.renoteId); // Renote *of* a quote (not packed)
return ids;
}