1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-04 01:34:43 +02:00

Compare commits

...

3 Commits

Author SHA1 Message Date
github-actions[bot]
5432984af8 Bump version to 2026.7.0-alpha.0 2026-07-03 10:15:24 +00:00
かっこかり
c29a3d902b refactor(frontend): MkNote/MkNoteDetailedのロジックを統合 (#17636)
* refactor(frontend): MkNote/MkNoteDetailedのロジックを統合

* refactor

* fix

* fix: 差分を解消

* fix lint

* fix types

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-03 19:14:48 +09:00
かっこかり
721b1b06a0 fix(backend/test): backend e2e test の beforeAll でdispose前にschema dropが走るのを修正 (#17653)
fix(backend): setup.e2e.ts の beforeAll でdispose前にschema dropが走る順序バグを修正 (Phase 6) (tiramiss-community/endolphin#52)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 15:57:39 +09:00
9 changed files with 688 additions and 798 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.6.1-alpha.1",
"version": "2026.7.0-alpha.0",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@@ -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);
});

View File

@@ -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>

View File

@@ -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>

View 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,
};
}

View File

@@ -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', {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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",