mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-04 01:34:43 +02:00
Compare commits
3 Commits
feature/ro
...
2026.7.0-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5432984af8 | ||
|
|
c29a3d902b | ||
|
|
721b1b06a0 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.6.1-alpha.1",
|
||||
"version": "2026.7.0-alpha.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -7,6 +7,9 @@ import { beforeAll } from 'vitest';
|
||||
import { initTestDb, sendEnvResetRequest } from './utils.js';
|
||||
|
||||
beforeAll(async () => {
|
||||
await initTestDb(false);
|
||||
// 前ファイルのNestJSアプリをdispose(env-reset)した後にスキーマをdrop & 再作成する。
|
||||
// 逆順だと、前ファイルの最後のテストが投げっぱなしにした非同期処理(cacheServiceのrefresh等)が
|
||||
// dispose前のdrop中に発火し、Unhandled Rejection (relation does not exist) でクラッシュしうる。
|
||||
await sendEnvResetRequest();
|
||||
await initTestDb(false);
|
||||
});
|
||||
|
||||
@@ -144,7 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<button v-else :class="$style.footerButton" class="_button" disabled>
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
|
||||
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="handleToggleReact()">
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
|
||||
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
|
||||
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||
@@ -192,58 +192,38 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
|
||||
so MkNote create empty div instead of no elements
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import * as mfm from 'mfm-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 { inject, ref, useTemplateRef, provide, computed } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/i.js';
|
||||
import { useNote } from '@/composables/use-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { noteEvents } from '@/composables/use-note-capture.js';
|
||||
import { getNoteSummary } from '@/utility/get-note-summary.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import { focusPrev, focusNext } from '@/utility/focus.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import number from '@/filters/number.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import { DI } from '@/di.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
|
||||
// コンポーネント外部の依存関係
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteHeader from '@/components/MkNoteHeader.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import number from '@/filters/number.js';
|
||||
import * as os from '@/os.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
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 { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
|
||||
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { useTooltip } from '@/composables/use-tooltip.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import { getNoteSummary } from '@/utility/get-note-summary.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.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 { globalEvents } from '@/events.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
@@ -254,48 +234,21 @@ const props = withDefaults(defineProps<{
|
||||
mock: false,
|
||||
});
|
||||
|
||||
provide(DI.mock, props.mock);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'reaction', emoji: string): void;
|
||||
(ev: 'removeReaction', emoji: string): void;
|
||||
}>();
|
||||
|
||||
provide(DI.mock, props.mock);
|
||||
|
||||
// 周辺コンテキストのインジェクト
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
||||
const inChannel = inject(DI.inChannel, null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
const currentAntenna = inject<Ref<Misskey.entities.Antenna | null> | null>('currentAntenna', null);
|
||||
|
||||
let note = deepClone(props.note);
|
||||
|
||||
// plugin
|
||||
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
|
||||
const hideByPlugin = ref(false);
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
let result: Misskey.entities.Note | null = deepClone(note);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
hideByPlugin.value = true;
|
||||
} else {
|
||||
note = result as Misskey.entities.Note;
|
||||
}
|
||||
}
|
||||
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note) ?? note;
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
mock: props.mock,
|
||||
});
|
||||
|
||||
// Template Refsの定義
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const menuButton = useTemplateRef('menuButton');
|
||||
const renoteButton = useTemplateRef('renoteButton');
|
||||
@@ -303,64 +256,80 @@ const renoteTime = useTemplateRef('renoteTime');
|
||||
const reactButton = useTemplateRef('reactButton');
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const galleryEl = useTemplateRef('galleryEl');
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
|
||||
const isLong = shouldCollapsed(appearNote, urls.value ?? []);
|
||||
const collapsed = ref(appearNote.cw == null && isLong);
|
||||
const muted = ref(checkMute(appearNote, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
|
||||
|
||||
// コンポーサブルの呼び出し
|
||||
const {
|
||||
note,
|
||||
appearNote,
|
||||
$appearNote,
|
||||
hideByPlugin,
|
||||
isRenote,
|
||||
showContent,
|
||||
translating,
|
||||
translation,
|
||||
muted,
|
||||
hardMuted,
|
||||
collapsed,
|
||||
renoteCollapsed,
|
||||
parsed,
|
||||
urls,
|
||||
isLong,
|
||||
showTicker,
|
||||
canRenote,
|
||||
|
||||
renote,
|
||||
reply,
|
||||
react,
|
||||
reactViaMfmEmoji,
|
||||
toggleReact,
|
||||
onContextmenu,
|
||||
showMenu,
|
||||
clip,
|
||||
showRenoteMenu,
|
||||
blur,
|
||||
} = useNote(props, {
|
||||
rootEl,
|
||||
menuButton,
|
||||
renoteButton,
|
||||
renoteTime,
|
||||
reactButton,
|
||||
clipButton,
|
||||
}, {
|
||||
inTimeline,
|
||||
tl_withSensitive,
|
||||
inChannel,
|
||||
currentClip,
|
||||
currentAntenna,
|
||||
});
|
||||
|
||||
// provide
|
||||
provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
|
||||
|
||||
// MkNote固有
|
||||
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
|
||||
const renoteCollapsed = ref(
|
||||
prefer.s.collapseRenotes && isRenote && (
|
||||
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
($appearNote.myReaction != null)
|
||||
),
|
||||
);
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
}));
|
||||
|
||||
/* eslint-disable no-redeclare */
|
||||
/** checkOnlyでは純粋なワードミュート結果をbooleanで返却する */
|
||||
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[]> | boolean | 'sensitiveMute' {
|
||||
if (mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, $i, mutedWords);
|
||||
if (Array.isArray(result)) {
|
||||
return checkOnly ? (result.length > 0) : result;
|
||||
function handleToggleReact() {
|
||||
toggleReact((reaction) => {
|
||||
if ($appearNote.myReaction === reaction) {
|
||||
emit('removeReaction', reaction);
|
||||
} else {
|
||||
emit('reaction', reaction);
|
||||
$appearNote.reactions[reaction] = 1;
|
||||
$appearNote.reactionCount++;
|
||||
$appearNote.myReaction = reaction;
|
||||
}
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
|
||||
if (Array.isArray(replyResult)) {
|
||||
return checkOnly ? (replyResult.length > 0) : replyResult;
|
||||
}
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
|
||||
if (Array.isArray(renoteResult)) {
|
||||
return checkOnly ? (renoteResult.length > 0) : renoteResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
/* eslint-enable no-redeclare */
|
||||
|
||||
function emitUpdReaction(emoji: string, delta: number) {
|
||||
if (delta < 0) {
|
||||
emit('removeReaction', emoji);
|
||||
} else if (delta > 0) {
|
||||
emit('reaction', emoji);
|
||||
}
|
||||
}
|
||||
|
||||
// キーボードショートカットマップ
|
||||
const keymap = {
|
||||
'r': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
@@ -392,7 +361,7 @@ const keymap = {
|
||||
renoteCollapsed.value = false;
|
||||
} else if (appearNote.cw != null) {
|
||||
showContent.value = !showContent.value;
|
||||
} else if (isLong) {
|
||||
} else if (isLong.value) {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
},
|
||||
@@ -402,321 +371,13 @@ const keymap = {
|
||||
},
|
||||
'up|k|shift+tab': {
|
||||
allowRepeat: true,
|
||||
callback: () => focusBefore(),
|
||||
callback: () => focusPrev(rootEl.value),
|
||||
},
|
||||
'down|j|tab': {
|
||||
allowRepeat: true,
|
||||
callback: () => focusAfter(),
|
||||
callback: () => focusNext(rootEl.value),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
provide(DI.mfmEmojiReactCallback, (reaction) => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!props.mock) {
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
const users = renotes.map(x => x.user);
|
||||
|
||||
if (users.length < 1 || renoteButton.value == null) return;
|
||||
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
anchorElement: renoteButton.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: $appearNote.reactionCount,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
const { dispose } = os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: $appearNote.reactionCount,
|
||||
anchorElement: reactButton.value!,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function renote() {
|
||||
if (props.mock) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
|
||||
os.popupMenu(menu, renoteButton.value);
|
||||
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
if (props.mock) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function react() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: '❤️',
|
||||
});
|
||||
});
|
||||
const el = reactButton.value;
|
||||
if (el && prefer.s.animation) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
|
||||
});
|
||||
|
||||
if (confirm.canceled) return;
|
||||
}
|
||||
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
if (props.mock) {
|
||||
emit('reaction', reaction);
|
||||
$appearNote.reactions[reaction] = 1;
|
||||
$appearNote.reactionCount++;
|
||||
$appearNote.myReaction = reaction;
|
||||
return;
|
||||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function undoReact(): void {
|
||||
const oldReaction = $appearNote.myReaction;
|
||||
if (!oldReaction) return;
|
||||
|
||||
if (props.mock) {
|
||||
emit('removeReaction', oldReaction);
|
||||
return;
|
||||
}
|
||||
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: appearNote.id,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`unreacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: oldReaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleReact() {
|
||||
if ($appearNote.myReaction == null) {
|
||||
react();
|
||||
} else {
|
||||
undoReact();
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: PointerEvent): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.target && isLink(ev.target as HTMLElement)) return;
|
||||
if (window.getSelection()?.toString() !== '') return;
|
||||
|
||||
if (prefer.s.useReactionPickerForContextMenu) {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value, currentAntenna: currentAntenna?.value ?? undefined });
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value, currentAntenna: currentAntenna?.value ?? undefined });
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip(): Promise<void> {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
async function showRenoteMenu() {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
function getUnrenote(): MenuItem {
|
||||
return {
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('noteDeleted', note.id);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const renoteDetailsMenu: MenuItem[] = [{
|
||||
type: 'link',
|
||||
text: i18n.ts.renoteDetails,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: notePage(note),
|
||||
}];
|
||||
|
||||
if (
|
||||
props.note.channelId != null &&
|
||||
(inChannel == null || props.note.channelId !== inChannel.value)
|
||||
) {
|
||||
renoteDetailsMenu.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.viewRenotedChannel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: `/channels/${props.note.channelId}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (isMyRenote) {
|
||||
os.popupMenu([
|
||||
...renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getUnrenote(),
|
||||
], renoteTime.value);
|
||||
} else {
|
||||
os.popupMenu([
|
||||
...renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
|
||||
...(($i?.isModerator || $i?.isAdmin) ? [getUnrenote()] : []),
|
||||
], renoteTime.value);
|
||||
}
|
||||
}
|
||||
|
||||
function focus() {
|
||||
rootEl.value?.focus();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
rootEl.value?.blur();
|
||||
}
|
||||
|
||||
function focusBefore() {
|
||||
focusPrev(rootEl.value);
|
||||
}
|
||||
|
||||
function focusAfter() {
|
||||
focusNext(rootEl.value);
|
||||
}
|
||||
|
||||
function readPromo() {
|
||||
misskeyApi('promo/read', {
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
}
|
||||
|
||||
function emitUpdReaction(emoji: string, delta: number) {
|
||||
if (delta < 0) {
|
||||
emit('removeReaction', emoji);
|
||||
} else if (delta > 0) {
|
||||
emit('reaction', emoji);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
@@ -239,92 +239,45 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, markRaw, provide, ref, useTemplateRef } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { inject, provide, ref, useTemplateRef, markRaw, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import { useNote } from '@/composables/use-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import number from '@/filters/number.js';
|
||||
import { DI } from '@/di.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
// コンポーネント外部の依存関係
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import number from '@/filters/number.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
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 { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { useTooltip } from '@/composables/use-tooltip.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.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 { globalEvents, useGlobalEvent } from '@/events.js';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
initialTab?: string;
|
||||
initialTab?: 'replies' | 'renotes' | 'reactions';
|
||||
}>(), {
|
||||
initialTab: 'replies',
|
||||
});
|
||||
|
||||
// 周辺コンテキストのインジェクト
|
||||
const inChannel = inject(DI.inChannel, null);
|
||||
|
||||
let note = deepClone(props.note);
|
||||
|
||||
// plugin
|
||||
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
|
||||
const hideByPlugin = ref(false);
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
let result: Misskey.entities.Note | null = deepClone(note);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
hideByPlugin.value = true;
|
||||
} else {
|
||||
note = result as Misskey.entities.Note;
|
||||
}
|
||||
}
|
||||
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note) ?? note;
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
});
|
||||
|
||||
// Template Refsの定義
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const menuButton = useTemplateRef('menuButton');
|
||||
const renoteButton = useTemplateRef('renoteButton');
|
||||
@@ -332,30 +285,96 @@ const renoteTime = useTemplateRef('renoteTime');
|
||||
const reactButton = useTemplateRef('reactButton');
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const galleryEl = useTemplateRef('galleryEl');
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
|
||||
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null;
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const conversation = ref<Misskey.entities.Note[]>([]);
|
||||
const replies = ref<Misskey.entities.Note[]>([]);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id);
|
||||
|
||||
useGlobalEvent('noteDeleted', (noteId) => {
|
||||
if (noteId === note.id || noteId === appearNote.id) {
|
||||
isDeleted.value = true;
|
||||
}
|
||||
// コンポーサブルの呼び出し
|
||||
const {
|
||||
note,
|
||||
appearNote,
|
||||
$appearNote,
|
||||
hideByPlugin,
|
||||
isRenote,
|
||||
showContent,
|
||||
isDeleted,
|
||||
translating,
|
||||
translation,
|
||||
muted,
|
||||
canRenote,
|
||||
isMyRenote,
|
||||
parsed,
|
||||
urls,
|
||||
showTicker,
|
||||
|
||||
// 関数群
|
||||
renote,
|
||||
reply,
|
||||
react,
|
||||
reactViaMfmEmoji,
|
||||
toggleReact,
|
||||
onContextmenu,
|
||||
showMenu,
|
||||
clip,
|
||||
showRenoteMenu,
|
||||
blur,
|
||||
} = useNote(props, {
|
||||
rootEl,
|
||||
menuButton,
|
||||
renoteButton,
|
||||
renoteTime,
|
||||
reactButton,
|
||||
clipButton,
|
||||
}, {
|
||||
inChannel,
|
||||
});
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
// provide
|
||||
provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
|
||||
|
||||
// MkNoteDetailed固有
|
||||
const tab = ref(props.initialTab);
|
||||
const reactionTabType = ref<string | null>(null);
|
||||
|
||||
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
|
||||
limit: 10,
|
||||
params: {
|
||||
noteId: appearNote.id,
|
||||
},
|
||||
}));
|
||||
|
||||
const reactionsPaginator = markRaw(new Paginator('notes/reactions', {
|
||||
limit: 10,
|
||||
computedParams: computed(() => ({
|
||||
noteId: appearNote.id,
|
||||
type: reactionTabType.value,
|
||||
})),
|
||||
}));
|
||||
|
||||
const replies = ref<Misskey.entities.Note[]>([]);
|
||||
const repliesLoaded = ref(false);
|
||||
|
||||
function loadReplies() {
|
||||
repliesLoaded.value = true;
|
||||
misskeyApi('notes/children', {
|
||||
noteId: appearNote.id,
|
||||
limit: 30,
|
||||
}).then(res => {
|
||||
replies.value = res;
|
||||
});
|
||||
}
|
||||
|
||||
const conversation = ref<Misskey.entities.Note[]>([]);
|
||||
const conversationLoaded = ref(false);
|
||||
|
||||
function loadConversation() {
|
||||
conversationLoaded.value = true;
|
||||
if (appearNote.replyId == null) return;
|
||||
misskeyApi('notes/conversation', {
|
||||
noteId: appearNote.replyId,
|
||||
}).then(res => {
|
||||
conversation.value = res.reverse();
|
||||
});
|
||||
}
|
||||
|
||||
// キーボードショートカットマップ
|
||||
const keymap = {
|
||||
'r': () => reply(),
|
||||
'e|a|plus': () => react(),
|
||||
@@ -378,281 +397,6 @@ const keymap = {
|
||||
callback: () => blur(),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
provide(DI.mfmEmojiReactCallback, (reaction) => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const tab = ref(props.initialTab);
|
||||
const reactionTabType = ref<string | null>(null);
|
||||
|
||||
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
|
||||
limit: 10,
|
||||
params: {
|
||||
noteId: appearNote.id,
|
||||
},
|
||||
}));
|
||||
|
||||
const reactionsPaginator = markRaw(new Paginator('notes/reactions', {
|
||||
limit: 10,
|
||||
computedParams: computed(() => ({
|
||||
noteId: appearNote.id,
|
||||
type: reactionTabType.value,
|
||||
})),
|
||||
}));
|
||||
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const anchorElement = renoteButton.value;
|
||||
if (anchorElement == null) return;
|
||||
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
const users = renotes.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
anchorElement: anchorElement,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: $appearNote.reactionCount,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
const { dispose } = os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: $appearNote.reactionCount,
|
||||
anchorElement: reactButton.value!,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renote() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton });
|
||||
os.popupMenu(menu, renoteButton.value);
|
||||
|
||||
// リノート後は反応が来る可能性があるので手動で購読する
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function react() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: '❤️',
|
||||
});
|
||||
});
|
||||
const el = reactButton.value;
|
||||
if (el && prefer.s.animation) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
|
||||
});
|
||||
|
||||
if (confirm.canceled) return;
|
||||
}
|
||||
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function undoReact(targetNote: Misskey.entities.Note): void {
|
||||
const oldReaction = targetNote.myReaction;
|
||||
if (!oldReaction) return;
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: targetNote.id,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`unreacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: oldReaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleReact() {
|
||||
if (appearNote.myReaction == null) {
|
||||
react();
|
||||
} else {
|
||||
undoReact(appearNote);
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: PointerEvent): void {
|
||||
if (ev.target && isLink(ev.target as HTMLElement)) return;
|
||||
if (window.getSelection()?.toString() !== '') return;
|
||||
|
||||
if (prefer.s.useReactionPickerForContextMenu) {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(): void {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip(): Promise<void> {
|
||||
os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
async function showRenoteMenu() {
|
||||
if (!isMyRenote) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const menu: MenuItem[] = [];
|
||||
|
||||
if (isMyRenote) {
|
||||
menu.push({
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('noteDeleted', note.id);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
props.note.channelId != null &&
|
||||
(inChannel == null || props.note.channelId !== inChannel.value)
|
||||
) {
|
||||
menu.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.viewRenotedChannel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: `/channels/${props.note.channelId}`,
|
||||
});
|
||||
}
|
||||
|
||||
os.popupMenu(menu, renoteTime.value);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
rootEl.value?.focus();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
rootEl.value?.blur();
|
||||
}
|
||||
|
||||
const repliesLoaded = ref(false);
|
||||
|
||||
function loadReplies() {
|
||||
repliesLoaded.value = true;
|
||||
misskeyApi('notes/children', {
|
||||
noteId: appearNote.id,
|
||||
limit: 30,
|
||||
}).then(res => {
|
||||
replies.value = res;
|
||||
});
|
||||
}
|
||||
|
||||
const conversationLoaded = ref(false);
|
||||
|
||||
function loadConversation() {
|
||||
conversationLoaded.value = true;
|
||||
if (appearNote.replyId == null) return;
|
||||
misskeyApi('notes/conversation', {
|
||||
noteId: appearNote.replyId,
|
||||
}).then(res => {
|
||||
conversation.value = res.reverse();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
473
packages/frontend/src/composables/use-note.ts
Normal file
473
packages/frontend/src/composables/use-note.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import * as mfm from 'mfm-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 { pleaseLogin } from '@/utility/please-login.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import * as os from '@/os.js';
|
||||
import { reactionPicker } from '@/utility/reaction-picker.js';
|
||||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu, getAbuseNoteMenu, getCopyNoteLinkMenu } from '@/utility/get-note-menu.js';
|
||||
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { useTooltip } from '@/composables/use-tooltip.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { globalEvents, useGlobalEvent } from '@/events.js';
|
||||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import type { DI as DIType } from '@/di.js';
|
||||
import type { ExtractInjectedType } from '@/types/misc.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
export interface UseNoteProps {
|
||||
note: Misskey.entities.Note;
|
||||
pinned?: boolean;
|
||||
mock?: boolean;
|
||||
withHardMute?: boolean;
|
||||
}
|
||||
|
||||
export interface UseNoteElements {
|
||||
rootEl?: Ref<HTMLElement | null>;
|
||||
menuButton?: Ref<HTMLElement | null>;
|
||||
renoteButton?: Ref<HTMLElement | null>;
|
||||
renoteTime?: Ref<HTMLElement | null>;
|
||||
reactButton?: Ref<HTMLElement | null>;
|
||||
clipButton?: Ref<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export interface UseNoteOptions {
|
||||
inTimeline?: boolean;
|
||||
tl_withSensitive?: Ref<boolean>;
|
||||
inChannel?: ExtractInjectedType<typeof DIType['inChannel']>;
|
||||
currentClip?: Ref<Misskey.entities.Clip | null> | null;
|
||||
currentAntenna?: Ref<Misskey.entities.Antenna | null> | null;
|
||||
}
|
||||
|
||||
/* eslint-disable no-redeclare */
|
||||
export function calculateMuteStatus(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
user: typeof $i,
|
||||
inTimeline: boolean,
|
||||
tl_withSensitive: boolean,
|
||||
checkOnly: true,
|
||||
): boolean;
|
||||
export function calculateMuteStatus(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
user: typeof $i,
|
||||
inTimeline: boolean,
|
||||
tl_withSensitive: boolean,
|
||||
checkOnly?: false,
|
||||
): Array<string | string[]> | false | 'sensitiveMute';
|
||||
|
||||
export function calculateMuteStatus(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
user: typeof $i,
|
||||
inTimeline: boolean,
|
||||
tl_withSensitive: boolean,
|
||||
checkOnly = false
|
||||
): Array<string | string[]> | boolean | 'sensitiveMute' {
|
||||
if (user?.mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, user, user.mutedWords);
|
||||
if (Array.isArray(result)) return checkOnly ? (result.length > 0) : result;
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, user, user.mutedWords);
|
||||
if (Array.isArray(replyResult)) return checkOnly ? (replyResult.length > 0) : replyResult;
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, user, user.mutedWords);
|
||||
if (Array.isArray(renoteResult)) return checkOnly ? (renoteResult.length > 0) : renoteResult;
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && tl_withSensitive === false && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
/* eslint-enable no-redeclare */
|
||||
|
||||
/** MkNote, MkNoteDetailedの共通ロジック */
|
||||
export function useNote(
|
||||
props: UseNoteProps,
|
||||
els: UseNoteElements = {},
|
||||
options: UseNoteOptions = {},
|
||||
) {
|
||||
const inTimeline = options.inTimeline ?? false;
|
||||
const tl_withSensitive = options.tl_withSensitive ?? ref(true);
|
||||
const inChannel = options.inChannel ?? null;
|
||||
const currentClip = options.currentClip ?? null;
|
||||
const currentAntenna = options.currentAntenna ?? null;
|
||||
|
||||
// プラグインの割り込み処理
|
||||
let rawNote = deepClone(props.note);
|
||||
const hideByPlugin = ref(false);
|
||||
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
|
||||
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
let result: Misskey.entities.Note | null = deepClone(rawNote);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
hideByPlugin.value = true;
|
||||
} else {
|
||||
rawNote = result as Misskey.entities.Note;
|
||||
}
|
||||
}
|
||||
|
||||
// 基本状態
|
||||
const isRenote = Misskey.note.isPureRenote(rawNote);
|
||||
const appearNote = getAppearNote(rawNote) ?? rawNote;
|
||||
|
||||
// キャプチャ(ストリーム購読)
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: rawNote,
|
||||
mock: props.mock,
|
||||
});
|
||||
|
||||
// 各種フラグ状態
|
||||
const showContent = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const translating = ref(false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
|
||||
// ミュート判定
|
||||
const muted = ref($i ? (options.inTimeline ? calculateMuteStatus(appearNote, $i, inTimeline, tl_withSensitive.value) : checkWordMute(appearNote, $i, $i.mutedWords)) : false);
|
||||
const hardMuted = ref(props.withHardMute && $i && calculateMuteStatus(appearNote, $i, inTimeline, tl_withSensitive.value, true));
|
||||
|
||||
// 計算プロパティ (Computed)
|
||||
const isMyRenote = computed(() => $i && ($i.id === rawNote.userId));
|
||||
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
|
||||
const isLong = computed(() => shouldCollapsed(appearNote, urls.value ?? []));
|
||||
const collapsed = ref(appearNote.cw == null && isLong.value);
|
||||
const showTicker = computed(() => (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance));
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
|
||||
const renoteCollapsed = ref(prefer.s.collapseRenotes && isRenote && (($i && ($i.id === rawNote.userId || $i.id === appearNote.userId)) || ($appearNote.myReaction != null)));
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
}));
|
||||
|
||||
// グローバルイベントの監視
|
||||
useGlobalEvent('noteDeleted', (noteId) => {
|
||||
if (noteId === rawNote.id || noteId === appearNote.id) {
|
||||
isDeleted.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ツールチップのセットアップ (Mockでない場合のみ)
|
||||
if (!props.mock) {
|
||||
if (els.renoteButton != null) {
|
||||
useTooltip(els.renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
const users = renotes.map(x => x.user);
|
||||
if (users.length < 1 || els.renoteButton!.value == null) return;
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
anchorElement: els.renoteButton!.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (appearNote.reactionAcceptance === 'likeOnly' && els.reactButton != null) {
|
||||
useTooltip(els.reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: $appearNote.reactionCount,
|
||||
});
|
||||
const users = reactions.map(x => x.user);
|
||||
if (users.length < 1 || els.reactButton!.value == null) return;
|
||||
const { dispose } = os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: $appearNote.reactionCount,
|
||||
anchorElement: els.reactButton!.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 共通アクション関数群
|
||||
async function renote() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
if (els.renoteButton == null) return;
|
||||
const { menu } = getRenoteMenu({
|
||||
note: rawNote,
|
||||
renoteButton: els.renoteButton,
|
||||
mock: props.mock,
|
||||
});
|
||||
os.popupMenu(menu, els.renoteButton.value);
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function react(customCallback?: (reaction: string) => void) {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
if (props.mock) return;
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, { userId: $i!.id, reaction: '❤️' });
|
||||
});
|
||||
if (els.reactButton != null && els.reactButton.value != null && prefer.s.animation) {
|
||||
const rect = els.reactButton.value.getBoundingClientRect();
|
||||
const { dispose } = os.popup(MkRippleEffect, {
|
||||
x: rect.left + (els.reactButton.value.offsetWidth / 2),
|
||||
y: rect.top + (els.reactButton.value.offsetHeight / 2),
|
||||
}, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(els.reactButton?.value ?? null, rawNote, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
}
|
||||
sound.playMisskeySfx('reaction');
|
||||
if (props.mock) {
|
||||
if (customCallback) customCallback(reaction);
|
||||
return;
|
||||
}
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, { userId: $i!.id, reaction: reaction });
|
||||
});
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => { focus(); });
|
||||
}
|
||||
}
|
||||
|
||||
async function reactViaMfmEmoji(reaction: string) {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function undoReact(): void {
|
||||
const oldReaction = $appearNote.myReaction;
|
||||
if (!oldReaction) return;
|
||||
if (props.mock) return;
|
||||
misskeyApi('notes/reactions/delete', { noteId: appearNote.id }).then(() => {
|
||||
noteEvents.emit(`unreacted:${appearNote.id}`, { userId: $i!.id, reaction: oldReaction });
|
||||
});
|
||||
}
|
||||
|
||||
function toggleReact(customMockCallback?: (reaction: string) => void) {
|
||||
if ($appearNote.myReaction == null) {
|
||||
react(customMockCallback);
|
||||
} else {
|
||||
if (props.mock && customMockCallback) {
|
||||
customMockCallback($appearNote.myReaction);
|
||||
} else {
|
||||
undoReact();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: PointerEvent): void {
|
||||
if (props.mock) return;
|
||||
if (ev.target && isLink(ev.target as HTMLElement)) return;
|
||||
if (window.getSelection()?.toString() !== '') return;
|
||||
|
||||
if (prefer.s.useReactionPickerForContextMenu) {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({
|
||||
note: rawNote,
|
||||
translating,
|
||||
translation,
|
||||
currentClip: currentClip?.value,
|
||||
currentAntenna: currentAntenna?.value ?? undefined,
|
||||
});
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(): void {
|
||||
if (props.mock || els.menuButton == null) return;
|
||||
const { menu, cleanup } = getNoteMenu({
|
||||
note: rawNote,
|
||||
translating,
|
||||
translation,
|
||||
currentClip: currentClip?.value,
|
||||
currentAntenna: currentAntenna?.value ?? undefined,
|
||||
});
|
||||
os.popupMenu(menu, els.menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip(): Promise<void> {
|
||||
if (props.mock) return;
|
||||
os.popupMenu(await getNoteClipMenu({
|
||||
note: rawNote,
|
||||
currentClip: currentClip?.value,
|
||||
}), els.clipButton?.value).then(focus);
|
||||
}
|
||||
|
||||
async function showRenoteMenu() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const getUnrenote = () => ({
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', { noteId: rawNote.id }).then(() => { globalEvents.emit('noteDeleted', rawNote.id); });
|
||||
},
|
||||
});
|
||||
|
||||
const menuItems: MenuItem[] = [{
|
||||
type: 'link',
|
||||
text: i18n.ts.renoteDetails,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: notePage(rawNote),
|
||||
}];
|
||||
|
||||
if (props.note.channelId != null && (inChannel == null || props.note.channelId !== inChannel.value)) {
|
||||
menuItems.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.viewRenotedChannel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: `/channels/${props.note.channelId}`,
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push(getCopyNoteLinkMenu(rawNote, i18n.ts.copyLinkRenote));
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
if (isMyRenote.value) {
|
||||
menuItems.push(getUnrenote());
|
||||
os.popupMenu(menuItems, els.renoteTime?.value);
|
||||
} else {
|
||||
menuItems.push(getAbuseNoteMenu(rawNote, i18n.ts.reportAbuseRenote));
|
||||
if ($i?.isModerator || $i?.isAdmin) {
|
||||
menuItems.push(getUnrenote());
|
||||
}
|
||||
|
||||
os.popupMenu(menuItems, els.renoteTime?.value);
|
||||
}
|
||||
}
|
||||
|
||||
// フォーカス制御
|
||||
function focus() { els.rootEl?.value?.focus(); }
|
||||
|
||||
function blur() { els.rootEl?.value?.blur(); }
|
||||
|
||||
return {
|
||||
// 状態・データ
|
||||
note: rawNote,
|
||||
appearNote,
|
||||
$appearNote,
|
||||
hideByPlugin,
|
||||
isRenote,
|
||||
showContent,
|
||||
isDeleted,
|
||||
translating,
|
||||
translation,
|
||||
muted,
|
||||
hardMuted,
|
||||
collapsed,
|
||||
renoteCollapsed,
|
||||
|
||||
// 計算プロパティ
|
||||
isMyRenote,
|
||||
parsed,
|
||||
urls,
|
||||
isLong,
|
||||
showTicker,
|
||||
canRenote,
|
||||
|
||||
// アクション関数
|
||||
renote,
|
||||
reply,
|
||||
react,
|
||||
reactViaMfmEmoji,
|
||||
toggleReact,
|
||||
onContextmenu,
|
||||
showMenu,
|
||||
clip,
|
||||
showRenoteMenu,
|
||||
focus,
|
||||
blur,
|
||||
};
|
||||
}
|
||||
@@ -76,6 +76,12 @@ const note = ref<null | Misskey.entities.Note>(CTX_NOTE);
|
||||
const clips = ref<Misskey.entities.Clip[]>();
|
||||
const showPrev = ref<'user' | 'channel' | false>(false);
|
||||
const showNext = ref<'user' | 'channel' | false>(false);
|
||||
const initialTab = computed<'reactions' | 'replies' | 'renotes' | undefined>(() => {
|
||||
if (['reactions', 'replies', 'renotes'].includes(props.initialTab ?? '')) {
|
||||
return props.initialTab as 'reactions' | 'replies' | 'renotes';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const error = ref();
|
||||
|
||||
const prevUserPaginator = markRaw(new Paginator('users/notes', {
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
export type Awaitable <T> = T | Promise<T>;
|
||||
export type Awaitable<T> = T | Promise<T>;
|
||||
|
||||
export type ExtractInjectedType<T extends InjectionKey<any>> = T extends InjectionKey<infer U> ? U : never;
|
||||
|
||||
@@ -33,7 +33,7 @@ const isInBrowserTranslationAvailable = (
|
||||
|
||||
export async function getNoteClipMenu(props: {
|
||||
note: Misskey.entities.Note;
|
||||
currentClip?: Misskey.entities.Clip;
|
||||
currentClip?: Misskey.entities.Clip | null;
|
||||
}) {
|
||||
function getClipName(clip: Misskey.entities.Clip) {
|
||||
if ($i && clip.userId === $i.id && clip.notesCount != null) {
|
||||
@@ -181,8 +181,8 @@ export function getNoteMenu(props: {
|
||||
note: Misskey.entities.Note;
|
||||
translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
|
||||
translating: Ref<boolean>;
|
||||
currentClip?: Misskey.entities.Clip;
|
||||
currentAntenna?: Misskey.entities.Antenna;
|
||||
currentClip?: Misskey.entities.Clip | null;
|
||||
currentAntenna?: Misskey.entities.Antenna | null;
|
||||
}) {
|
||||
const appearNote = getAppearNote(props.note) ?? props.note;
|
||||
const link = appearNote.url ?? appearNote.uri;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2026.6.1-alpha.1",
|
||||
"version": "2026.7.0-alpha.0",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
|
||||
Reference in New Issue
Block a user