This commit is contained in:
tamaina 2023-07-30 04:05:29 +00:00
parent b05f2bb834
commit 0c75d9a9f9
10 changed files with 241 additions and 192 deletions

View File

@ -138,8 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onUnmounted, ref, shallowRef, Ref, defineAsyncComponent, watch, onActivated, onDeactivated } from 'vue'; import { inject, onUnmounted, ref, shallowRef, Ref, defineAsyncComponent, onActivated, onDeactivated, onMounted } from 'vue';
import * as mfm from 'mfm-js';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue';
@ -156,69 +155,37 @@ import { focusPrev, focusNext } from '@/scripts/focus';
import { checkWordMute } from '@/scripts/check-word-mute'; import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user'; import { userPage } from '@/filters/user';
import * as os from '@/os'; import * as os from '@/os';
import { defaultStore, noteViewInterruptors } from '@/store'; import { defaultStore } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker'; import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu'; import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
import { noteManager } from '@/scripts/entity-manager'; import { noteManager } from '@/scripts/entity-manager';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary'; import { getNoteSummary } from '@/scripts/get-note-summary';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog'; import { showMovedDialog } from '@/scripts/show-moved-dialog';
import { shouldCollapsed } from '@/scripts/collapsed';
const props = defineProps<{ const props = defineProps<{
note: { id: string }; note: { id: string };
pinned?: boolean; pinned?: boolean;
setNote?: boolean;
}>(); }>();
const inChannel = inject('inChannel', null); const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<misskey.entities.Clip> | null>('currentClip', null); const currentClip = inject<Ref<misskey.entities.Clip> | null>('currentClip', null);
const cachedNote = noteManager.get(props.note.id); if (props.setNote) {
const overridingNote = shallowRef<Partial<misskey.entities.Note>>({}); noteManager.set(props.note as any);
// plugin }
watch(cachedNote, async () => {
if (cachedNote.value == null) {
isDeleted.value = true;
overridingNote.value = {};
return;
}
isDeleted.value = false;
if (noteViewInterruptors.length > 0) {
overridingNote.value = {};
return;
}
let result = deepClone(cachedNote.value); const {
for (const interruptor of noteViewInterruptors) { note, interruptorUnwatch, executeInterruptor,
result = await interruptor.handler(result) as misskey.entities.Note; isRenote, isMyRenote, appearNote,
} urls, isLong, canRenote,
overridingNote.value = result; } = noteManager.getNoteViewBase(props.note.id);
});
const note = $computed<misskey.entities.Note | null>(() => {
if (cachedNote.value == null) {
return null;
}
return {
...cachedNote.value,
...overridingNote.value
};
});
const isRenote = computed(() => (
note != null &&
note.renote != null &&
note.text == null &&
note.fileIds?.length === 0 &&
note.poll == null
));
const el = shallowRef<HTMLElement>(); const el = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>();
@ -226,26 +193,20 @@ const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote.value ? note?.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note?.userId);
const showContent = ref(false); const showContent = ref(false);
const urls = appearNote?.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; const collapsed = ref(appearNote.value?.cw == null && isLong);
const isLong = appearNote ? shouldCollapsed(appearNote) : false;
const collapsed = ref(appearNote?.cw == null && isLong);
const isDeleted = ref(note === null); const isDeleted = ref(note === null);
const muted = ref(appearNote ? checkWordMute(appearNote, $i, defaultStore.state.mutedWords) : false); const muted = ref(appearNote.value ? checkWordMute(appearNote.value, $i, defaultStore.state.mutedWords) : false);
const translation = ref<any>(null); const translation = ref<any>(null);
const translating = ref(false); const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && !!appearNote?.user.instance);
const canRenote = computed(() => (!!appearNote && !!$i) && (['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id));
let renoteCollapsed = $ref( let renoteCollapsed = $ref(
note && note.value &&
appearNote && appearNote.value &&
defaultStore.state.collapseRenotes && defaultStore.state.collapseRenotes &&
isRenote.value && isRenote.value &&
( (
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) ||
(appearNote.myReaction != null) (appearNote.value.myReaction != null)
) )
); );
@ -261,10 +222,10 @@ const keymap = {
}; };
useTooltip(renoteButton, async (showing) => { useTooltip(renoteButton, async (showing) => {
if (!appearNote) return; if (!appearNote.value) return;
const renotes = await os.api('notes/renotes', { const renotes = await os.api('notes/renotes', {
noteId: appearNote.id, noteId: appearNote.value.id,
limit: 11, limit: 11,
}); });
@ -275,7 +236,7 @@ useTooltip(renoteButton, async (showing) => {
os.popup(MkUsersTooltip, { os.popup(MkUsersTooltip, {
showing, showing,
users, users,
count: appearNote.renoteCount, count: appearNote.value.renoteCount,
targetElement: renoteButton.value, targetElement: renoteButton.value,
}, {}, 'closed'); }, {}, 'closed');
}); });
@ -292,19 +253,19 @@ function smallerVisibility(a: Visibility | string, b: Visibility | string): Visi
} }
function renote(viaKeyboard = false) { function renote(viaKeyboard = false) {
if (!appearNote || !canRenote.value) return; if (!appearNote.value || !canRenote.value) return;
pleaseLogin(); pleaseLogin();
showMovedDialog(); showMovedDialog();
let items = [] as MenuItem[]; let items = [] as MenuItem[];
if (appearNote.channel) { if (appearNote.value.channel) {
items = items.concat([{ items = items.concat([{
text: i18n.ts.inChannelRenote, text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
if (!appearNote) return; if (!appearNote.value) return;
const el = renoteButton.value as HTMLElement | null | undefined; const el = renoteButton.value as HTMLElement | null | undefined;
if (el) { if (el) {
@ -315,8 +276,8 @@ function renote(viaKeyboard = false) {
} }
os.api('notes/create', { os.api('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.value.id,
channelId: appearNote.channelId, channelId: appearNote.value.channelId,
}).then(() => { }).then(() => {
os.toast(i18n.ts.renoted); os.toast(i18n.ts.renoted);
}); });
@ -325,10 +286,10 @@ function renote(viaKeyboard = false) {
text: i18n.ts.inChannelQuote, text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote', icon: 'ti ti-quote',
action: () => { action: () => {
if (!appearNote) return; if (!appearNote.value) return;
os.post({ os.post({
renote: appearNote, renote: appearNote.value,
channel: appearNote.channel, channel: appearNote.value.channel,
}); });
}, },
}, null]); }, null]);
@ -338,7 +299,7 @@ function renote(viaKeyboard = false) {
text: i18n.ts.renote, text: i18n.ts.renote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
if (!appearNote) return; if (!appearNote.value) return;
const el = renoteButton.value as HTMLElement | null | undefined; const el = renoteButton.value as HTMLElement | null | undefined;
if (el) { if (el) {
@ -353,8 +314,8 @@ function renote(viaKeyboard = false) {
os.api('notes/create', { os.api('notes/create', {
localOnly, localOnly,
visibility: smallerVisibility(appearNote.visibility, configuredVisibility), visibility: smallerVisibility(appearNote.value.visibility, configuredVisibility),
renoteId: appearNote.id, renoteId: appearNote.value.id,
}).then(() => { }).then(() => {
os.toast(i18n.ts.renoted); os.toast(i18n.ts.renoted);
}); });
@ -363,9 +324,9 @@ function renote(viaKeyboard = false) {
text: i18n.ts.quote, text: i18n.ts.quote,
icon: 'ti ti-quote', icon: 'ti ti-quote',
action: () => { action: () => {
if (!appearNote) return; if (!appearNote.value) return;
os.post({ os.post({
renote: appearNote, renote: appearNote.value,
}); });
}, },
}]); }]);
@ -376,23 +337,23 @@ function renote(viaKeyboard = false) {
} }
async function reply(viaKeyboard = false): void { async function reply(viaKeyboard = false): void {
if (!appearNote) return; if (!appearNote.value) return;
pleaseLogin(); pleaseLogin();
await os.post({ await os.post({
reply: appearNote, reply: appearNote.value,
channel: appearNote.channel, channel: appearNote.value.channel,
animation: !viaKeyboard, animation: !viaKeyboard,
}); });
focus(); focus();
} }
function react(viaKeyboard = false): void { function react(viaKeyboard = false): void {
if (!appearNote) return; if (!appearNote.value) return;
pleaseLogin(); pleaseLogin();
showMovedDialog(); showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', { os.api('notes/reactions/create', {
noteId: appearNote.id, noteId: appearNote.value.id,
reaction: '❤️', reaction: '❤️',
}); });
const el = reactButton.value as HTMLElement | null | undefined; const el = reactButton.value as HTMLElement | null | undefined;
@ -406,12 +367,12 @@ function react(viaKeyboard = false): void {
if (!reactButton.value) return; if (!reactButton.value) return;
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(reactButton.value, reaction => {
if (!appearNote) return; if (!appearNote.value) return;
os.api('notes/reactions/create', { os.api('notes/reactions/create', {
noteId: appearNote.id, noteId: appearNote.value.id,
reaction: reaction, reaction: reaction,
}); });
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead'); claimAchievement('reactWithoutRead');
} }
}, () => { }, () => {
@ -438,39 +399,40 @@ function onContextmenu(ev: MouseEvent): void {
} }
}; };
if (isLink(ev.target)) return; if (isLink(ev.target)) return;
if (window.getSelection().toString() !== '') return; if (window.getSelection()?.toString() !== '') return;
if (defaultStore.state.useReactionPickerForContextMenu) { if (defaultStore.state.useReactionPickerForContextMenu) {
ev.preventDefault(); ev.preventDefault();
react(); react();
} else { } else {
if (!note) return; if (!note.value) return;
os.contextMenu(getNoteMenu({ note: note, translating, translation, isDeleted, currentClip: currentClip?.value }), ev).then(focus); os.contextMenu(getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }), ev).then(focus);
} }
} }
function menu(viaKeyboard = false): void { function menu(viaKeyboard = false): void {
if (!note) return; if (!note.value) return;
os.popupMenu(getNoteMenu({ note: note, translating, translation, isDeleted, currentClip: currentClip?.value }), menuButton.value, { os.popupMenu(getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }), menuButton.value, {
viaKeyboard, viaKeyboard,
}).then(focus); }).then(focus);
} }
async function clip() { async function clip() {
if (!note) return; if (!note.value) return;
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
} }
function showRenoteMenu(viaKeyboard = false): void { function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return; if (!isMyRenote.value) return;
pleaseLogin(); pleaseLogin();
os.popupMenu([{ os.popupMenu([{
text: i18n.ts.unrenote, text: i18n.ts.unrenote,
icon: 'ti ti-trash', icon: 'ti ti-trash',
danger: true, danger: true,
action: () => { action: () => {
if (!note.value) return;
os.api('notes/delete', { os.api('notes/delete', {
noteId: note.id, noteId: note.value.id,
}); });
isDeleted.value = true; isDeleted.value = true;
}, },
@ -496,28 +458,33 @@ function focusAfter() {
} }
function readPromo() { function readPromo() {
if (!appearNote) return; if (!appearNote.value) return;
os.api('promo/read', { os.api('promo/read', {
noteId: appearNote.id, noteId: appearNote.value.id,
}); });
isDeleted.value = true; isDeleted.value = true;
} }
function showReactions(): void { function showReactions(): void {
if (!appearNote) return; if (!appearNote.value) return;
os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), {
noteId: appearNote.id, noteId: appearNote.value.id,
}, {}, 'closed'); }, {}, 'closed');
} }
const unuse = ref<() => void>(); const unuse = ref<() => void>();
unuse.value = noteManager.useNote(props.note.id, true).unuse; unuse.value = noteManager.useNote(props.note.id, true).unuse;
onMounted(() => {
executeInterruptor();
});
onUnmounted(() => { onUnmounted(() => {
if (unuse.value) { if (unuse.value) {
unuse.value(); unuse.value();
unuse.value = undefined; unuse.value = undefined;
} }
interruptorUnwatch();
}); });
onActivated(() => { onActivated(() => {

View File

@ -4,14 +4,23 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div v-if="muted && note && appearNote" class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</I18n>
</div>
<div <div
v-if="!muted" v-else-if="note && appearNote"
v-show="!isDeleted" v-show="!isDeleted"
ref="el" ref="el"
v-hotkey="keymap" v-hotkey="keymap"
:class="$style.root" :class="$style.root"
> >
<MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/> <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note" :setNote="true"/>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/> <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="isRenote" :class="$style.renote"> <div v-if="isRenote" :class="$style.renote">
<MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/>
@ -125,23 +134,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</button> </button>
</footer> </footer>
</article> </article>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/> <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true" :setNote="true"/>
</div>
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</I18n>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, ref, shallowRef } from 'vue'; import { inject, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import * as mfm from 'mfm-js';
import * as misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@ -156,46 +154,36 @@ import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user'; import { userPage } from '@/filters/user';
import { notePage } from '@/filters/note'; import { notePage } from '@/filters/note';
import * as os from '@/os'; import * as os from '@/os';
import { defaultStore, noteViewInterruptors } from '@/store'; import { defaultStore } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker'; import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu'; import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements'; import { claimAchievement } from '@/scripts/achievements';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog'; import { showMovedDialog } from '@/scripts/show-moved-dialog';
import { noteManager } from '@/scripts/entity-manager';
import { Note } from 'misskey-js/built/entities';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: { id: string };
pinned?: boolean; pinned?: boolean;
setNote?: boolean;
}>(); }>();
const inChannel = inject('inChannel', null); const inChannel = inject('inChannel', null);
let note = $ref(deepClone(props.note)); if (props.setNote) {
noteManager.set(props.note as any);
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = deepClone(note);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
note = result;
});
} }
const isRenote = ( const {
note.renote != null && note, interruptorUnwatch, executeInterruptor,
note.text == null && isRenote, isMyRenote, appearNote,
note.fileIds.length === 0 && urls, canRenote, showTicker,
note.poll == null } = noteManager.getNoteViewBase(props.note.id);
);
const el = shallowRef<HTMLElement>(); const el = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>();
@ -203,18 +191,13 @@ const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref(appearNote.value && checkWordMute(appearNote.value, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; const conversation = ref<Note[]>([]);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const replies = ref<Note[]>([]);
const conversation = ref<misskey.entities.Note[]>([]);
const replies = ref<misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
const keymap = { const keymap = {
'r': () => reply(true), 'r': () => reply(true),
@ -225,15 +208,11 @@ const keymap = {
's': () => showContent.value !== showContent.value, 's': () => showContent.value !== showContent.value,
}; };
useNoteCapture({
rootEl: el,
note: $$(appearNote),
isDeletedRef: isDeleted,
});
useTooltip(renoteButton, async (showing) => { useTooltip(renoteButton, async (showing) => {
if (!appearNote.value) return;
const renotes = await os.api('notes/renotes', { const renotes = await os.api('notes/renotes', {
noteId: appearNote.id, noteId: appearNote.value.id,
limit: 11, limit: 11,
}); });
@ -244,7 +223,7 @@ useTooltip(renoteButton, async (showing) => {
os.popup(MkUsersTooltip, { os.popup(MkUsersTooltip, {
showing, showing,
users, users,
count: appearNote.renoteCount, count: appearNote.value.renoteCount,
targetElement: renoteButton.value, targetElement: renoteButton.value,
}, {}, 'closed'); }, {}, 'closed');
}); });
@ -255,7 +234,7 @@ function renote(viaKeyboard = false) {
let items = [] as MenuItem[]; let items = [] as MenuItem[];
if (appearNote.channel) { if (appearNote.value.channel) {
items = items.concat([{ items = items.concat([{
text: i18n.ts.inChannelRenote, text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
@ -269,8 +248,8 @@ function renote(viaKeyboard = false) {
} }
os.api('notes/create', { os.api('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.value.id,
channelId: appearNote.channelId, channelId: appearNote.value.channelId,
}).then(() => { }).then(() => {
os.toast(i18n.ts.renoted); os.toast(i18n.ts.renoted);
}); });
@ -280,8 +259,8 @@ function renote(viaKeyboard = false) {
icon: 'ti ti-quote', icon: 'ti ti-quote',
action: () => { action: () => {
os.post({ os.post({
renote: appearNote, renote: appearNote.value,
channel: appearNote.channel, channel: appearNote.value.channel,
}); });
}, },
}, null]); }, null]);
@ -300,7 +279,7 @@ function renote(viaKeyboard = false) {
} }
os.api('notes/create', { os.api('notes/create', {
renoteId: appearNote.id, renoteId: appearNote.value.id,
}).then(() => { }).then(() => {
os.toast(i18n.ts.renoted); os.toast(i18n.ts.renoted);
}); });
@ -310,7 +289,7 @@ function renote(viaKeyboard = false) {
icon: 'ti ti-quote', icon: 'ti ti-quote',
action: () => { action: () => {
os.post({ os.post({
renote: appearNote, renote: appearNote.value,
}); });
}, },
}]); }]);
@ -324,8 +303,8 @@ function reply(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
showMovedDialog(); showMovedDialog();
os.post({ os.post({
reply: appearNote, reply: appearNote.value,
channel: appearNote.channel, channel: appearNote.value.channel,
animation: !viaKeyboard, animation: !viaKeyboard,
}, () => { }, () => {
focus(); focus();
@ -335,9 +314,9 @@ function reply(viaKeyboard = false): void {
function react(viaKeyboard = false): void { function react(viaKeyboard = false): void {
pleaseLogin(); pleaseLogin();
showMovedDialog(); showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', { os.api('notes/reactions/create', {
noteId: appearNote.id, noteId: appearNote.value.id,
reaction: '❤️', reaction: '❤️',
}); });
const el = reactButton.value as HTMLElement | null | undefined; const el = reactButton.value as HTMLElement | null | undefined;
@ -351,10 +330,10 @@ function react(viaKeyboard = false): void {
blur(); blur();
reactionPicker.show(reactButton.value, reaction => { reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', { os.api('notes/reactions/create', {
noteId: appearNote.id, noteId: appearNote.value.id,
reaction: reaction, reaction: reaction,
}); });
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead'); claimAchievement('reactWithoutRead');
} }
}, () => { }, () => {
@ -379,28 +358,31 @@ function onContextmenu(ev: MouseEvent): void {
} }
}; };
if (isLink(ev.target)) return; if (isLink(ev.target)) return;
if (window.getSelection().toString() !== '') return; if (window.getSelection()?.toString() !== '') return;
if (defaultStore.state.useReactionPickerForContextMenu) { if (defaultStore.state.useReactionPickerForContextMenu) {
ev.preventDefault(); ev.preventDefault();
react(); react();
} else { } else {
os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus); if (!note.value) return;
os.contextMenu(getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }), ev).then(focus);
} }
} }
function menu(viaKeyboard = false): void { function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, { if (!note.value) return;
os.popupMenu(getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }), menuButton.value, {
viaKeyboard, viaKeyboard,
}).then(focus); }).then(focus);
} }
async function clip() { async function clip() {
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted }), clipButton.value).then(focus); if (!note.value) return;
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
} }
function showRenoteMenu(viaKeyboard = false): void { function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return; if (!isMyRenote.value) return;
pleaseLogin(); pleaseLogin();
os.popupMenu([{ os.popupMenu([{
text: i18n.ts.unrenote, text: i18n.ts.unrenote,
@ -418,27 +400,44 @@ function showRenoteMenu(viaKeyboard = false): void {
} }
function focus() { function focus() {
el.value.focus(); el.value?.focus();
} }
function blur() { function blur() {
el.value.blur(); el.value?.blur();
} }
os.api('notes/children', { const { note: fetching, unuse } = noteManager.useNote(props.note.id, true);
noteId: appearNote.id,
limit: 30, onMounted(async () => {
}).then(res => { await fetching;
replies.value = res; await executeInterruptor();
muted.value = appearNote.value && checkWordMute(appearNote.value, $i, defaultStore.state.mutedWords);
if (appearNote.value) {
os.api('notes/children', {
noteId: appearNote.value.id,
limit: 30,
}).then(res => {
replies.value = res;
});
if (appearNote.value.replyId) {
os.api('notes/conversation', {
noteId: appearNote.value.replyId,
}).then(res => {
if (!res) return;
conversation.value = res;
});
}
};
}); });
if (appearNote.replyId) { onUnmounted(() => {
os.api('notes/conversation', { unuse();
noteId: appearNote.replyId, interruptorUnwatch();
}).then(res => { });
conversation.value = res.reverse();
});
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<template v-if="depth < 5"> <template v-if="depth < 5">
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1"/> <MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1" :setNote="true"/>
</template> </template>
<div v-else :class="$style.more"> <div v-else :class="$style.more">
<MkA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></MkA> <MkA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></MkA>
@ -32,7 +32,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import * as misskey from 'misskey-js';
import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue'; import MkCwButton from '@/components/MkCwButton.vue';
@ -40,10 +39,12 @@ import { notePage } from '@/filters/note';
import * as os from '@/os'; import * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { $i } from '@/account'; import { $i } from '@/account';
import { noteManager } from '@/scripts/entity-manager';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: misskey.entities.Note; note: { id: string };
detail?: boolean; detail?: boolean;
setNote?: boolean;
// how many notes are in between this one and the note being viewed in detail // how many notes are in between this one and the note being viewed in detail
depth?: number; depth?: number;
@ -51,8 +52,14 @@ const props = withDefaults(defineProps<{
depth: 1, depth: 1,
}); });
if (props.setNote) {
noteManager.set(props.note as any);
}
const note = noteManager.get(props.note.id);
let showContent = $ref(false); let showContent = $ref(false);
let replies: misskey.entities.Note[] = $ref([]); let replies: { id: string }[] = $ref([]);
if (props.detail) { if (props.detail) {
os.api('notes/children', { os.api('notes/children', {

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notifications, denyMoveTransition }"> <template #default="{ items: notifications, denyMoveTransition }">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true" :denyMoveTransition="denyMoveTransition"> <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true" :denyMoveTransition="denyMoveTransition">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="`showNotificationAsNote:${notification.id}`" :note="notification.note"/> <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="`showNotificationAsNote:${notification.id}`" :note="notification.note" :setNote="true"/>
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/>
</MkDateSeparatedList> </MkDateSeparatedList>
</template> </template>

View File

@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div style="margin: 1em 0;"> <div style="margin: 1em 0;">
<MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note" :setNote="true"/>
<MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note" :setNote="true"/>
</div> </div>
</template> </template>

View File

@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFoldableSection> <MkFoldableSection>
<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template> <template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template>
<div v-if="channel.pinnedNotes.length > 0" class="_gaps"> <div v-if="channel.pinnedNotes.length > 0" class="_gaps">
<MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/> <MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note" :setNote="true"/>
</div> </div>
</MkFoldableSection> </MkFoldableSection>
</div> </div>

View File

@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="!showNext && hasNext" :class="$style.loadNext" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton> <MkButton v-if="!showNext && hasNext" :class="$style.loadNext" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton>
<div class="_margin _gaps_s"> <div class="_margin _gaps_s">
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
<MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note"/> <MkNoteDetailed :key="note.id" v-model:note="note" :setNote="true" :class="$style.note"/>
</div> </div>
<div v-if="clips && clips.length > 0" class="_margin"> <div v-if="clips && clips.length > 0" class="_margin">
<div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div>
@ -56,6 +56,7 @@ import { i18n } from '@/i18n';
import { dateString } from '@/filters/date'; import { dateString } from '@/filters/date';
import MkClipPreview from '@/components/MkClipPreview.vue'; import MkClipPreview from '@/components/MkClipPreview.vue';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { noteManager } from '@/scripts/entity-manager';
const props = defineProps<{ const props = defineProps<{
noteId: string; noteId: string;
@ -115,7 +116,13 @@ function fetchNote() {
]).then(([_clips, prev, next]) => { ]).then(([_clips, prev, next]) => {
clips = _clips; clips = _clips;
hasPrev = prev.length !== 0; hasPrev = prev.length !== 0;
prev.map(n => {
noteManager.set(n);
});
hasNext = next.length !== 0; hasNext = next.length !== 0;
next.map(n => {
noteManager.set(n);
});
}); });
}).catch(err => { }).catch(err => {
error = err; error = err;

View File

@ -15,8 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput> </MkInput>
<MkSwitch v-model="props.modelValue.detailed"><span>{{ i18n.ts._pages.blocks._note.detailed }}</span></MkSwitch> <MkSwitch v-model="props.modelValue.detailed"><span>{{ i18n.ts._pages.blocks._note.detailed }}</span></MkSwitch>
<MkNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/> <MkNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" :setNote="true" style="margin-bottom: 16px;"/>
<MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/> <MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" :setNote="true" style="margin-bottom: 16px;"/>
</section> </section>
</XContainer> </XContainer>
</template> </template>

View File

@ -123,7 +123,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="contents _gaps"> <div class="contents _gaps">
<div v-if="user.pinnedNotes.length > 0" class="_gaps"> <div v-if="user.pinnedNotes.length > 0" class="_gaps">
<MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> <MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true" :setNote="true"/>
</div> </div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow"> <template v-if="narrow">

View File

@ -4,11 +4,16 @@
*/ */
import { Note, UserLite, DriveFile } from "misskey-js/built/entities"; import { Note, UserLite, DriveFile } from "misskey-js/built/entities";
import { Ref, ref, ComputedRef, computed } from "vue"; import { Ref, ref, ComputedRef, computed, watch, unref } from "vue";
import { api } from "./api"; import { api } from "./api";
import { useStream } from '@/stream'; import { useStream } from '@/stream';
import { Stream } from "misskey-js"; import { Stream } from "misskey-js";
import { $i } from "@/account"; import { $i } from "@/account";
import { defaultStore, noteViewInterruptors } from '@/store';
import { deepClone } from "./clone";
import { shouldCollapsed } from "./collapsed";
import { extractUrlFromMfm } from "./extract-url-from-mfm";
import * as mfm from 'mfm-js';
export class EntitiyManager<T extends { id: string }> { export class EntitiyManager<T extends { id: string }> {
private entities: Map<T['id'], Ref<T>>; private entities: Map<T['id'], Ref<T>>;
@ -40,6 +45,7 @@ export const driveFileManager = new EntitiyManager<DriveFile>('driveFile');
type OmittedNote = Omit<Note, 'user' | 'renote' | 'reply'>; type OmittedNote = Omit<Note, 'user' | 'renote' | 'reply'>;
type CachedNoteSource = Ref<OmittedNote | null>; type CachedNoteSource = Ref<OmittedNote | null>;
type CachedNote = ComputedRef<Note | null>; type CachedNote = ComputedRef<Note | null>;
type InterruptedCachedNote = Ref<Note | null>;
/** /**
* *
@ -61,6 +67,7 @@ export class NoteManager {
* 0 * 0
*/ */
private notesComputed: Map<Note['id'], CachedNote>; private notesComputed: Map<Note['id'], CachedNote>;
private updatedAt: Map<Note['id'], number>; private updatedAt: Map<Note['id'], number>;
private captureing: Map<Note['id'], number>; private captureing: Map<Note['id'], number>;
private connection: Stream | null; private connection: Stream | null;
@ -147,6 +154,68 @@ export class NoteManager {
return this.notesComputed.get(id)!; return this.notesComputed.get(id)!;
} }
/**
* Interruptorを適用する
*
*/
public getInterrupted(id: string): {
interruptedNote: InterruptedCachedNote,
interruptorUnwatch: () => void,
executeInterruptor: () => Promise<void>,
} {
const note = this.get(id);
const interruptedNote = ref<Note | null>(unref(note));
async function executeInterruptor() {
if (note.value == null) {
interruptedNote.value = null;
return;
}
if (noteViewInterruptors.length > 0) {
interruptedNote.value = unref(note);
return;
}
let result = deepClone(note.value);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result) as Note;
}
interruptedNote.value = result;
}
const interruptorUnwatch = watch(note, executeInterruptor);
return {
interruptedNote,
interruptorUnwatch,
executeInterruptor,
};
}
/**
*
*/
public getNoteViewBase(id: string) {
const { interruptedNote: note, interruptorUnwatch, executeInterruptor } = this.getInterrupted(id);
const isRenote = computed(() => (
note.value != null &&
note.value.renote != null &&
note.value.text == null &&
note.value.fileIds?.length === 0 &&
note.value.poll == null
));
const isMyRenote = computed(() => $i && ($i.id === note.value?.userId));
const appearNote = computed(() => (isRenote.value ? note.value?.renote : note.value) ?? null);
return {
note, interruptorUnwatch, executeInterruptor,
isRenote, isMyRenote, appearNote,
urls: computed(() => appearNote.value?.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)) : null),
isLong: computed(() => appearNote.value ? shouldCollapsed(appearNote.value) : false),
canRenote: computed(() => (!!appearNote.value && !!$i) && (['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id)),
showTicker: computed(() => !!appearNote.value && ((defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance))),
};
}
public async fetch(id: string, force = false): Promise<CachedNote> { public async fetch(id: string, force = false): Promise<CachedNote> {
if (!force) { if (!force) {
const updatedAt = this.updatedAt.get(id); const updatedAt = this.updatedAt.get(id);