wip
This commit is contained in:
parent
7fe3b4f86c
commit
66c3666d0c
|
@ -87,7 +87,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/>
|
||||
<MkPoll
|
||||
v-if="appearNote.poll"
|
||||
:noteId="appearNote.id"
|
||||
:multiple="appearNote.poll.multiple"
|
||||
:expiresAt="appearNote.poll.expiresAt"
|
||||
:choices="pollChoices"
|
||||
:author="appearNote.user"
|
||||
:emojiUrls="appearNote.emojis"
|
||||
:class="$style.poll"
|
||||
/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
|
||||
</div>
|
||||
|
@ -101,7 +110,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
|
||||
</div>
|
||||
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" style="margin-top: 6px;" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
|
||||
<MkReactionsViewer
|
||||
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
|
||||
style="margin-top: 6px;"
|
||||
:reactions="reactions"
|
||||
:reactionEmojis="reactionEmojis"
|
||||
:myReaction="myReaction"
|
||||
:noteId="appearNote.id"
|
||||
:maxNumber="16"
|
||||
@mockUpdateMyReaction="emitUpdReaction"
|
||||
>
|
||||
<template #more>
|
||||
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
|
||||
</template>
|
||||
|
@ -125,11 +143,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
|
||||
<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-if="appearNote.reactionAcceptance === 'likeOnly' && myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
|
||||
<i v-else-if="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>
|
||||
<i v-else class="ti ti-plus"></i>
|
||||
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
|
||||
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && reactionCount > 0" :class="$style.footerButtonCount">{{ number(reactionCount) }}</p>
|
||||
</button>
|
||||
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
|
@ -176,7 +194,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue';
|
||||
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide, shallowRef } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
|
@ -245,13 +263,13 @@ const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
|||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
const note = ref(deepClone(props.note));
|
||||
let note = deepClone(props.note);
|
||||
|
||||
// plugin
|
||||
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
onMounted(async () => {
|
||||
let result: Misskey.entities.Note | null = deepClone(note.value);
|
||||
let result: Misskey.entities.Note | null = deepClone(note);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = await interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
|
@ -263,11 +281,17 @@ if (noteViewInterruptors.length > 0) {
|
|||
console.error(err);
|
||||
}
|
||||
}
|
||||
note.value = result as Misskey.entities.Note;
|
||||
note = result as Misskey.entities.Note;
|
||||
});
|
||||
}
|
||||
|
||||
const isRenote = Misskey.note.isPureRenote(note.value);
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note);
|
||||
const reactions = ref(appearNote.reactions);
|
||||
const reactionCount = ref(appearNote.reactionCount);
|
||||
const reactionEmojis = ref(appearNote.reactionEmojis);
|
||||
const myReaction = ref(appearNote.myReaction);
|
||||
const pollChoices = ref(appearNote.poll?.choices);
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const menuButton = useTemplateRef('menuButton');
|
||||
|
@ -275,32 +299,31 @@ const renoteButton = useTemplateRef('renoteButton');
|
|||
const renoteTime = useTemplateRef('renoteTime');
|
||||
const reactButton = useTemplateRef('reactButton');
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const appearNote = computed(() => getAppearNote(note.value));
|
||||
const galleryEl = useTemplateRef('galleryEl');
|
||||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
|
||||
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
|
||||
const collapsed = ref(appearNote.value.cw == null && isLong);
|
||||
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 isDeleted = ref(false);
|
||||
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
|
||||
const muted = ref(checkMute(appearNote, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
|
||||
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.value.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
|
||||
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.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
(appearNote.value.myReaction != null)
|
||||
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
(myReaction.value != null)
|
||||
),
|
||||
);
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.value.id}`,
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
}));
|
||||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
|
@ -357,7 +380,7 @@ const keymap = {
|
|||
'v|enter': () => {
|
||||
if (renoteCollapsed.value) {
|
||||
renoteCollapsed.value = false;
|
||||
} else if (appearNote.value.cw != null) {
|
||||
} else if (appearNote.cw != null) {
|
||||
showContent.value = !showContent.value;
|
||||
} else if (isLong) {
|
||||
collapsed.value = !collapsed.value;
|
||||
|
@ -380,10 +403,10 @@ const keymap = {
|
|||
provide(DI.mfmEmojiReactCallback, (reaction) => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.value.id}`, {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
|
@ -392,12 +415,17 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
|
|||
|
||||
if (props.mock) {
|
||||
watch(() => props.note, (to) => {
|
||||
note.value = deepClone(to);
|
||||
note = deepClone(to);
|
||||
}, { deep: true });
|
||||
} else {
|
||||
useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
reactionsRef: reactions,
|
||||
reactionCountRef: reactionCount,
|
||||
reactionEmojisRef: reactionEmojis,
|
||||
myReactionRef: myReaction,
|
||||
pollChoicesRef: pollChoices,
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
}
|
||||
|
@ -405,7 +433,7 @@ if (props.mock) {
|
|||
if (!props.mock) {
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
|
@ -416,19 +444,19 @@ if (!props.mock) {
|
|||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.value.renoteCount,
|
||||
count: appearNote.renoteCount,
|
||||
targetElement: renoteButton.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: appearNote.value.reactionCount,
|
||||
_cacheKey_: reactionCount.value,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
@ -439,7 +467,7 @@ if (!props.mock) {
|
|||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: appearNote.value.reactionCount,
|
||||
count: reactionCount.value,
|
||||
targetElement: reactButton.value!,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
|
@ -452,7 +480,7 @@ function renote(viaKeyboard = false) {
|
|||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
|
||||
os.popupMenu(menu, renoteButton.value, {
|
||||
viaKeyboard,
|
||||
});
|
||||
|
@ -464,8 +492,8 @@ function reply(): void {
|
|||
return;
|
||||
}
|
||||
os.post({
|
||||
reply: appearNote.value,
|
||||
channel: appearNote.value.channel,
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
|
@ -474,7 +502,7 @@ function reply(): void {
|
|||
function react(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
if (props.mock) {
|
||||
|
@ -482,10 +510,10 @@ function react(): void {
|
|||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.value.id}`, {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: '❤️',
|
||||
});
|
||||
|
@ -501,7 +529,7 @@ function react(): void {
|
|||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
|
||||
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
|
@ -519,16 +547,16 @@ function react(): void {
|
|||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.value.id}`, {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
|
@ -537,8 +565,8 @@ function react(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function undoReact(targetNote: Misskey.entities.Note): void {
|
||||
const oldReaction = targetNote.myReaction;
|
||||
function undoReact(): void {
|
||||
const oldReaction = myReaction.value;
|
||||
if (!oldReaction) return;
|
||||
|
||||
if (props.mock) {
|
||||
|
@ -547,15 +575,15 @@ function undoReact(targetNote: Misskey.entities.Note): void {
|
|||
}
|
||||
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: targetNote.id,
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleReact() {
|
||||
if (appearNote.value.myReaction == null) {
|
||||
if (myReaction.value == null) {
|
||||
react();
|
||||
} else {
|
||||
undoReact(appearNote.value);
|
||||
undoReact();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -571,7 +599,7 @@ function onContextmenu(ev: MouseEvent): void {
|
|||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, isDeleted, currentClip: currentClip?.value });
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
@ -581,7 +609,7 @@ function showMenu(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, isDeleted, currentClip: currentClip?.value });
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
|
@ -590,7 +618,7 @@ async function clip(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
|
@ -605,7 +633,7 @@ function showRenoteMenu(): void {
|
|||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.value.id,
|
||||
noteId: note.id,
|
||||
});
|
||||
isDeleted.value = true;
|
||||
},
|
||||
|
@ -616,23 +644,23 @@ function showRenoteMenu(): void {
|
|||
type: 'link',
|
||||
text: i18n.ts.renoteDetails,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: notePage(note.value),
|
||||
to: notePage(note),
|
||||
};
|
||||
|
||||
if (isMyRenote) {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
os.popupMenu([
|
||||
renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getUnrenote(),
|
||||
], renoteTime.value);
|
||||
} else {
|
||||
os.popupMenu([
|
||||
renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
|
||||
getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
|
||||
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
|
||||
], renoteTime.value);
|
||||
}
|
||||
|
@ -656,7 +684,7 @@ function focusAfter() {
|
|||
|
||||
function readPromo() {
|
||||
misskeyApi('promo/read', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
isDeleted.value = true;
|
||||
}
|
||||
|
|
|
@ -110,7 +110,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
|
||||
<MkPoll
|
||||
v-if="appearNote.poll"
|
||||
:noteId="appearNote.id"
|
||||
:multiple="appearNote.poll.multiple"
|
||||
:expiresAt="appearNote.poll.expiresAt"
|
||||
:choices="pollChoices"
|
||||
:author="appearNote.user"
|
||||
:emojiUrls="appearNote.emojis"
|
||||
:class="$style.poll"
|
||||
/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
|
||||
</div>
|
||||
|
@ -124,7 +133,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
|
||||
</MkA>
|
||||
</div>
|
||||
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/>
|
||||
<MkReactionsViewer
|
||||
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
|
||||
style="margin-top: 6px;"
|
||||
:reactions="reactions"
|
||||
:reactionEmojis="reactionEmojis"
|
||||
:myReaction="myReaction"
|
||||
:noteId="appearNote.id"
|
||||
:maxNumber="16"
|
||||
@mockUpdateMyReaction="emitUpdReaction"
|
||||
/>
|
||||
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
||||
<i class="ti ti-arrow-back-up"></i>
|
||||
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
||||
|
@ -143,11 +161,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
|
||||
<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-if="appearNote.reactionAcceptance === 'likeOnly' && myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
|
||||
<i v-else-if="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>
|
||||
<i v-else class="ti ti-plus"></i>
|
||||
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
|
||||
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(reactionCount) }}</p>
|
||||
</button>
|
||||
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
|
@ -182,9 +200,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
|
||||
<div :class="$style.reactionTabs">
|
||||
<button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
|
||||
<button v-for="reaction in Object.keys(reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
|
||||
<MkReactionIcon :reaction="reaction"/>
|
||||
<span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span>
|
||||
<span style="margin-left: 4px;">{{ reactions[reaction] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<MkPagination v-if="reactionTabType" :key="reactionTabType" :pagination="reactionsPagination" :disableAutoLoad="true">
|
||||
|
@ -217,7 +235,6 @@ 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 type { Paging } from '@/components/MkPagination.vue';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
|
@ -267,13 +284,13 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
const inChannel = inject('inChannel', null);
|
||||
|
||||
const note = ref(deepClone(props.note));
|
||||
let note = deepClone(props.note);
|
||||
|
||||
// plugin
|
||||
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
onMounted(async () => {
|
||||
let result: Misskey.entities.Note | null = deepClone(note.value);
|
||||
let result: Misskey.entities.Note | null = deepClone(note);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = await interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
|
@ -285,11 +302,17 @@ if (noteViewInterruptors.length > 0) {
|
|||
console.error(err);
|
||||
}
|
||||
}
|
||||
note.value = result as Misskey.entities.Note;
|
||||
note = result as Misskey.entities.Note;
|
||||
});
|
||||
}
|
||||
|
||||
const isRenote = Misskey.note.isPureRenote(note.value);
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note);
|
||||
const reactions = ref(appearNote.reactions);
|
||||
const reactionCount = ref(appearNote.reactionCount);
|
||||
const reactionEmojis = ref(appearNote.reactionEmojis);
|
||||
const myReaction = ref(appearNote.myReaction);
|
||||
const pollChoices = ref(appearNote.poll?.choices);
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const menuButton = useTemplateRef('menuButton');
|
||||
|
@ -297,24 +320,23 @@ const renoteButton = useTemplateRef('renoteButton');
|
|||
const renoteTime = useTemplateRef('renoteTime');
|
||||
const reactButton = useTemplateRef('reactButton');
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const appearNote = computed(() => getAppearNote(note.value));
|
||||
const galleryEl = useTemplateRef('galleryEl');
|
||||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : 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.value.text ? mfm.parse(appearNote.value.text) : null;
|
||||
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
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.value.visibility) || appearNote.value.userId === $i?.id);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id);
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.value.id}`,
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
}));
|
||||
|
||||
const keymap = {
|
||||
|
@ -328,7 +350,7 @@ const keymap = {
|
|||
},
|
||||
'o': () => galleryEl.value?.openGallery(),
|
||||
'v|enter': () => {
|
||||
if (appearNote.value.cw != null) {
|
||||
if (appearNote.cw != null) {
|
||||
showContent.value = !showContent.value;
|
||||
}
|
||||
},
|
||||
|
@ -341,10 +363,10 @@ const keymap = {
|
|||
provide(DI.mfmEmojiReactCallback, (reaction) => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.value.id}`, {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
|
@ -358,7 +380,7 @@ const renotesPagination = computed<Paging>(() => ({
|
|||
endpoint: 'notes/renotes',
|
||||
limit: 10,
|
||||
params: {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
},
|
||||
}));
|
||||
|
||||
|
@ -366,7 +388,7 @@ const reactionsPagination = computed<Paging>(() => ({
|
|||
endpoint: 'notes/reactions',
|
||||
limit: 10,
|
||||
params: {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
type: reactionTabType.value,
|
||||
},
|
||||
}));
|
||||
|
@ -374,12 +396,17 @@ const reactionsPagination = computed<Paging>(() => ({
|
|||
useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
reactionsRef: reactions,
|
||||
reactionCountRef: reactionCount,
|
||||
reactionEmojisRef: reactionEmojis,
|
||||
myReactionRef: myReaction,
|
||||
pollChoicesRef: pollChoices,
|
||||
isDeletedRef: isDeleted,
|
||||
});
|
||||
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
|
@ -390,19 +417,19 @@ useTooltip(renoteButton, async (showing) => {
|
|||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.value.renoteCount,
|
||||
count: appearNote.renoteCount,
|
||||
targetElement: renoteButton.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: appearNote.value.reactionCount,
|
||||
_cacheKey_: reactionCount.value,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
@ -413,7 +440,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
|||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: appearNote.value.reactionCount,
|
||||
count: reactionCount.value,
|
||||
targetElement: reactButton.value!,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
|
@ -425,7 +452,7 @@ function renote() {
|
|||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton });
|
||||
os.popupMenu(menu, renoteButton.value);
|
||||
}
|
||||
|
||||
|
@ -433,8 +460,8 @@ function reply(): void {
|
|||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
os.post({
|
||||
reply: appearNote.value,
|
||||
channel: appearNote.value.channel,
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
|
@ -443,14 +470,14 @@ function reply(): void {
|
|||
function react(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.value.id}`, {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: '❤️',
|
||||
});
|
||||
|
@ -466,7 +493,7 @@ function react(): void {
|
|||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
|
||||
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
|
@ -479,15 +506,15 @@ function react(): void {
|
|||
sound.playMisskeySfx('reaction');
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.value.id}`, {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
|
@ -505,10 +532,10 @@ function undoReact(targetNote: Misskey.entities.Note): void {
|
|||
}
|
||||
|
||||
function toggleReact() {
|
||||
if (appearNote.value.myReaction == null) {
|
||||
if (appearNote.myReaction == null) {
|
||||
react();
|
||||
} else {
|
||||
undoReact(appearNote.value);
|
||||
undoReact(appearNote);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -520,18 +547,18 @@ function onContextmenu(ev: MouseEvent): void {
|
|||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, isDeleted });
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(): void {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, isDeleted });
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip(): Promise<void> {
|
||||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
|
@ -543,7 +570,7 @@ function showRenoteMenu(): void {
|
|||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.value.id,
|
||||
noteId: note.id,
|
||||
});
|
||||
isDeleted.value = true;
|
||||
},
|
||||
|
@ -563,7 +590,7 @@ const repliesLoaded = ref(false);
|
|||
function loadReplies() {
|
||||
repliesLoaded.value = true;
|
||||
misskeyApi('notes/children', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
limit: 30,
|
||||
}).then(res => {
|
||||
replies.value = res;
|
||||
|
@ -574,9 +601,9 @@ const conversationLoaded = ref(false);
|
|||
|
||||
function loadConversation() {
|
||||
conversationLoaded.value = true;
|
||||
if (appearNote.value.replyId == null) return;
|
||||
if (appearNote.replyId == null) return;
|
||||
misskeyApi('notes/conversation', {
|
||||
noteId: appearNote.value.replyId,
|
||||
noteId: appearNote.replyId,
|
||||
}).then(res => {
|
||||
conversation.value = res.reverse();
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div :class="{ [$style.done]: closed || isVoted }">
|
||||
<ul :class="$style.choices">
|
||||
<li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice" @click="vote(i)">
|
||||
<li v-for="(choice, i) in choices" :key="i" :class="$style.choice" @click="vote(i)">
|
||||
<div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
|
||||
<span :class="$style.fg">
|
||||
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template>
|
||||
|
@ -40,7 +40,9 @@ import { i18n } from '@/i18n.js';
|
|||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
poll: NonNullable<Misskey.entities.Note['poll']>;
|
||||
multiple: NonNullable<Misskey.entities.Note['poll']>['multiple'];
|
||||
expiresAt: NonNullable<Misskey.entities.Note['poll']>['expiresAt'];
|
||||
choices: NonNullable<Misskey.entities.Note['poll']>['choices'];
|
||||
readOnly?: boolean;
|
||||
emojiUrls?: Record<string, string>;
|
||||
author?: Misskey.entities.UserLite;
|
||||
|
@ -48,9 +50,9 @@ const props = defineProps<{
|
|||
|
||||
const remaining = ref(-1);
|
||||
|
||||
const total = computed(() => sum(props.poll.choices.map(x => x.votes)));
|
||||
const total = computed(() => sum(props.choices.map(x => x.votes)));
|
||||
const closed = computed(() => remaining.value === 0);
|
||||
const isVoted = computed(() => !props.poll.multiple && props.poll.choices.some(c => c.isVoted));
|
||||
const isVoted = computed(() => !props.multiple && props.choices.some(c => c.isVoted));
|
||||
const timer = computed(() => i18n.tsx._poll[
|
||||
remaining.value >= 86400 ? 'remainingDays' :
|
||||
remaining.value >= 3600 ? 'remainingHours' :
|
||||
|
@ -70,9 +72,9 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
|||
}));
|
||||
|
||||
// 期限付きアンケート
|
||||
if (props.poll.expiresAt) {
|
||||
if (props.expiresAt) {
|
||||
const tick = () => {
|
||||
remaining.value = Math.floor(Math.max(new Date(props.poll.expiresAt!).getTime() - Date.now(), 0) / 1000);
|
||||
remaining.value = Math.floor(Math.max(new Date(props.expiresAt!).getTime() - Date.now(), 0) / 1000);
|
||||
if (remaining.value === 0) {
|
||||
showResult.value = true;
|
||||
}
|
||||
|
@ -91,7 +93,7 @@ const vote = async (id) => {
|
|||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }),
|
||||
text: i18n.tsx.voteConfirm({ choice: props.choices[id].text }),
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
|
@ -99,7 +101,7 @@ const vote = async (id) => {
|
|||
noteId: props.noteId,
|
||||
choice: id,
|
||||
});
|
||||
if (!showResult.value) showResult.value = !props.poll.multiple;
|
||||
if (!showResult.value) showResult.value = !props.multiple;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
@ -8,11 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
ref="buttonEl"
|
||||
v-ripple="canToggle"
|
||||
class="_button"
|
||||
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]"
|
||||
:class="[$style.root, { [$style.reacted]: myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]"
|
||||
@click="toggleReaction()"
|
||||
@contextmenu.prevent.stop="menu"
|
||||
>
|
||||
<MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
|
||||
<MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
|
||||
<span :class="$style.count">{{ count }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -29,7 +29,6 @@ import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
|
|||
import { useTooltip } from '@/use/use-tooltip.js';
|
||||
import { $i } from '@/i.js';
|
||||
import MkReactionEffect from '@/components/MkReactionEffect.vue';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
|
||||
|
@ -39,10 +38,12 @@ import { DI } from '@/di.js';
|
|||
import { noteEvents } from '@/use/use-note-capture.js';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: Misskey.entities.Note['id'];
|
||||
reaction: string;
|
||||
reactionEmojis: Misskey.entities.Note['reactionEmojis'];
|
||||
myReaction: Misskey.entities.Note['myReaction'];
|
||||
count: number;
|
||||
isInitial: boolean;
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const mock = inject(DI.mock, false);
|
||||
|
@ -57,14 +58,16 @@ const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./,
|
|||
const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
|
||||
|
||||
const canToggle = computed(() => {
|
||||
return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
|
||||
// TODO
|
||||
//return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
|
||||
return !props.reaction.match(/@\w/) && $i && emoji.value;
|
||||
});
|
||||
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
|
||||
|
||||
async function toggleReaction() {
|
||||
if (!canToggle.value) return;
|
||||
|
||||
const oldReaction = props.note.myReaction;
|
||||
const oldReaction = props.myReaction;
|
||||
if (oldReaction) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
|
@ -82,19 +85,19 @@ async function toggleReaction() {
|
|||
}
|
||||
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: props.note.id,
|
||||
noteId: props.noteId,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`unreacted:${props.note.id}`, {
|
||||
noteEvents.emit(`unreacted:${props.noteId}`, {
|
||||
userId: $i!.id,
|
||||
reaction: props.reaction,
|
||||
emoji: emoji.value,
|
||||
});
|
||||
if (oldReaction !== props.reaction) {
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: props.note.id,
|
||||
noteId: props.noteId,
|
||||
reaction: props.reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${props.note.id}`, {
|
||||
noteEvents.emit(`reacted:${props.noteId}`, {
|
||||
userId: $i!.id,
|
||||
reaction: props.reaction,
|
||||
emoji: emoji.value,
|
||||
|
@ -120,18 +123,19 @@ async function toggleReaction() {
|
|||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: props.note.id,
|
||||
noteId: props.noteId,
|
||||
reaction: props.reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${props.note.id}`, {
|
||||
noteEvents.emit(`reacted:${props.noteId}`, {
|
||||
userId: $i!.id,
|
||||
reaction: props.reaction,
|
||||
emoji: emoji.value,
|
||||
});
|
||||
});
|
||||
if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
// TODO: 上位コンポーネントでやる
|
||||
//if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
|
||||
// claimAchievement('reactWithoutRead');
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -175,7 +179,7 @@ onMounted(() => {
|
|||
if (!mock) {
|
||||
useTooltip(buttonEl, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: props.note.id,
|
||||
noteId: props.noteId,
|
||||
type: props.reaction,
|
||||
limit: 10,
|
||||
_cacheKey_: props.count,
|
||||
|
|
|
@ -13,7 +13,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:moveClass="$style.transition_x_move"
|
||||
tag="div" :class="$style.root"
|
||||
>
|
||||
<XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
|
||||
<XReaction
|
||||
v-for="[reaction, count] in _reactions"
|
||||
:key="reaction"
|
||||
:reaction="reaction"
|
||||
:reactionEmojis="props.reactionEmojis"
|
||||
:count="count"
|
||||
:isInitial="initialReactions.has(reaction)"
|
||||
:noteId="props.noteId"
|
||||
:myReaction="props.myReaction"
|
||||
@reactionToggled="onMockToggleReaction"
|
||||
/>
|
||||
<slot v-if="hasMoreReactions" name="more"/>
|
||||
</component>
|
||||
</template>
|
||||
|
@ -27,7 +37,10 @@ import { prefer } from '@/preferences.js';
|
|||
import { DI } from '@/di.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
noteId: Misskey.entities.Note['id'];
|
||||
reactions: Misskey.entities.Note['reactions'];
|
||||
reactionEmojis: Misskey.entities.Note['reactionEmojis'];
|
||||
myReaction: Misskey.entities.Note['myReaction'];
|
||||
maxNumber?: number;
|
||||
}>(), {
|
||||
maxNumber: Infinity,
|
||||
|
@ -39,33 +52,33 @@ const emit = defineEmits<{
|
|||
(ev: 'mockUpdateMyReaction', emoji: string, delta: number): void;
|
||||
}>();
|
||||
|
||||
const initialReactions = new Set(Object.keys(props.note.reactions));
|
||||
const initialReactions = new Set(Object.keys(props.reactions));
|
||||
|
||||
const reactions = ref<[string, number][]>([]);
|
||||
const _reactions = ref<[string, number][]>([]);
|
||||
const hasMoreReactions = ref(false);
|
||||
|
||||
if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) {
|
||||
reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction];
|
||||
if (props.myReaction && !Object.keys(_reactions.value).includes(props.myReaction)) {
|
||||
_reactions.value[props.myReaction] = props.reactions[props.myReaction];
|
||||
}
|
||||
|
||||
function onMockToggleReaction(emoji: string, count: number) {
|
||||
if (!mock) return;
|
||||
|
||||
const i = reactions.value.findIndex((item) => item[0] === emoji);
|
||||
const i = _reactions.value.findIndex((item) => item[0] === emoji);
|
||||
if (i < 0) return;
|
||||
|
||||
emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1]));
|
||||
emit('mockUpdateMyReaction', emoji, (count - _reactions.value[i][1]));
|
||||
}
|
||||
|
||||
watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
|
||||
watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
|
||||
let newReactions: [string, number][] = [];
|
||||
hasMoreReactions.value = Object.keys(newSource).length > maxNumber;
|
||||
|
||||
for (let i = 0; i < reactions.value.length; i++) {
|
||||
const reaction = reactions.value[i][0];
|
||||
for (let i = 0; i < _reactions.value.length; i++) {
|
||||
const reaction = _reactions.value[i][0];
|
||||
if (reaction in newSource && newSource[reaction] !== 0) {
|
||||
reactions.value[i][1] = newSource[reaction];
|
||||
newReactions.push(reactions.value[i]);
|
||||
_reactions.value[i][1] = newSource[reaction];
|
||||
newReactions.push(_reactions.value[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,11 +92,11 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe
|
|||
|
||||
newReactions = newReactions.slice(0, props.maxNumber);
|
||||
|
||||
if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) {
|
||||
newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]);
|
||||
if (props.myReaction && !newReactions.map(([x]) => x).includes(props.myReaction)) {
|
||||
newReactions.push([props.myReaction, newSource[props.myReaction]]);
|
||||
}
|
||||
|
||||
reactions.value = newReactions;
|
||||
_reactions.value = newReactions;
|
||||
}, { immediate: true, deep: true });
|
||||
</script>
|
||||
|
||||
|
|
|
@ -321,6 +321,7 @@ refreshEndpointAndChannel();
|
|||
|
||||
const paginator = usePagination({
|
||||
ctx: paginationQuery,
|
||||
useShallowRef: true,
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
|
@ -27,7 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/>
|
||||
</div>
|
||||
<div v-if="note.reactionCount > 0" :class="$style.reactions">
|
||||
<MkReactionsViewer :note="note" :maxNumber="16"/>
|
||||
<!-- TODO -->
|
||||
<!--<MkReactionsViewer :note="note" :maxNumber="16"/>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { onUnmounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import type { Ref, ShallowRef } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { store } from '@/store.js';
|
||||
|
@ -28,7 +28,7 @@ const pollingQueue = new Map<string, {
|
|||
lastAddedAt: number;
|
||||
}>();
|
||||
|
||||
function pollingEnqueue(note: Misskey.entities.Note) {
|
||||
function pollingEnqueue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
|
||||
if (pollingQueue.has(note.id)) {
|
||||
const data = pollingQueue.get(note.id)!;
|
||||
pollingQueue.set(note.id, {
|
||||
|
@ -44,7 +44,7 @@ function pollingEnqueue(note: Misskey.entities.Note) {
|
|||
}
|
||||
}
|
||||
|
||||
function pollingDequeue(note: Misskey.entities.Note) {
|
||||
function pollingDequeue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
|
||||
const data = pollingQueue.get(note.id);
|
||||
if (data == null) return;
|
||||
|
||||
|
@ -85,28 +85,31 @@ window.setInterval(() => {
|
|||
}, POLLING_INTERVAL);
|
||||
|
||||
function pollingSubscribe(props: {
|
||||
note: Ref<Misskey.entities.Note>;
|
||||
note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
|
||||
reactionsRef: Ref<Misskey.entities.Note['reactions']>;
|
||||
reactionCountRef: Ref<Misskey.entities.Note['reactionCount']>;
|
||||
reactionEmojisRef: Ref<Misskey.entities.Note['reactionEmojis']>;
|
||||
isDeletedRef: Ref<boolean>;
|
||||
}) {
|
||||
const note = props.note;
|
||||
const { note, reactionsRef, reactionCountRef, reactionEmojisRef } = props;
|
||||
|
||||
function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void {
|
||||
note.value.reactions = data.reactions;
|
||||
note.value.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0);
|
||||
note.value.reactionEmojis = data.reactionEmojis;
|
||||
reactionsRef.value = data.reactions;
|
||||
reactionCountRef.value = Object.values(data.reactions).reduce((a, b) => a + b, 0);
|
||||
reactionEmojisRef.value = data.reactionEmojis;
|
||||
}
|
||||
|
||||
pollingEnqueue(note.value);
|
||||
fetchEvent.on(note.value.id, onFetched);
|
||||
pollingEnqueue(note);
|
||||
fetchEvent.on(note.id, onFetched);
|
||||
|
||||
onUnmounted(() => {
|
||||
pollingDequeue(note.value);
|
||||
fetchEvent.off(note.value.id, onFetched);
|
||||
pollingDequeue(note);
|
||||
fetchEvent.off(note.id, onFetched);
|
||||
});
|
||||
}
|
||||
|
||||
function realtimeSubscribe(props: {
|
||||
note: Ref<Misskey.entities.Note>;
|
||||
note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
|
||||
isDeletedRef: Ref<boolean>;
|
||||
}): void {
|
||||
const note = props.note;
|
||||
|
@ -115,7 +118,7 @@ function realtimeSubscribe(props: {
|
|||
function onStreamNoteUpdated(noteData): void {
|
||||
const { type, id, body } = noteData;
|
||||
|
||||
if (id !== note.value.id) return;
|
||||
if (id !== note.id) return;
|
||||
|
||||
switch (type) {
|
||||
case 'reacted': {
|
||||
|
@ -152,12 +155,12 @@ function realtimeSubscribe(props: {
|
|||
}
|
||||
|
||||
function capture(withHandler = false): void {
|
||||
connection.send('sr', { id: note.value.id });
|
||||
connection.send('sr', { id: note.id });
|
||||
if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
|
||||
}
|
||||
|
||||
function decapture(withHandler = false): void {
|
||||
connection.send('un', { id: note.value.id });
|
||||
connection.send('un', { id: note.id });
|
||||
if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
|
||||
}
|
||||
|
||||
|
@ -175,39 +178,42 @@ function realtimeSubscribe(props: {
|
|||
}
|
||||
|
||||
export function useNoteCapture(props: {
|
||||
note: Ref<Misskey.entities.Note>;
|
||||
parentNote: Ref<Misskey.entities.Note> | null;
|
||||
note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
|
||||
parentNote: Misskey.entities.Note | null;
|
||||
reactionsRef: Ref<Misskey.entities.Note['reactions']>;
|
||||
reactionCountRef: Ref<Misskey.entities.Note['reactionCount']>;
|
||||
reactionEmojisRef: Ref<Misskey.entities.Note['reactionEmojis']>;
|
||||
myReactionRef: Ref<Misskey.entities.Note['myReaction']>;
|
||||
pollChoicesRef: Ref<NonNullable<Misskey.entities.Note['poll']>['choices'] | null>;
|
||||
isDeletedRef: Ref<boolean>;
|
||||
}) {
|
||||
const note = props.note;
|
||||
const parentNote = props.parentNote;
|
||||
const { note, parentNote, reactionsRef, reactionCountRef, reactionEmojisRef, myReactionRef, pollChoicesRef } = props;
|
||||
|
||||
noteEvents.on(`reacted:${note.value.id}`, onReacted);
|
||||
noteEvents.on(`unreacted:${note.value.id}`, onUnreacted);
|
||||
noteEvents.on(`pollVoted:${note.value.id}`, onPollVoted);
|
||||
noteEvents.on(`deleted:${note.value.id}`, onDeleted);
|
||||
noteEvents.on(`reacted:${note.id}`, onReacted);
|
||||
noteEvents.on(`unreacted:${note.id}`, onUnreacted);
|
||||
noteEvents.on(`pollVoted:${note.id}`, onPollVoted);
|
||||
noteEvents.on(`deleted:${note.id}`, onDeleted);
|
||||
|
||||
let latestReactedKey: string | null = null;
|
||||
let latestUnreactedKey: string | null = null;
|
||||
let latestPollVotedKey: string | null = null;
|
||||
|
||||
function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
|
||||
console.log('reacted', ctx);
|
||||
const newReactedKey = `${ctx.userId}:${ctx.reaction}`;
|
||||
if (newReactedKey === latestReactedKey) return;
|
||||
latestReactedKey = newReactedKey;
|
||||
|
||||
if (ctx.emoji && !(ctx.emoji.name in note.value.reactionEmojis)) {
|
||||
note.value.reactionEmojis[ctx.emoji.name] = ctx.emoji.url;
|
||||
if (ctx.emoji && !(ctx.emoji.name in reactionEmojisRef.value)) {
|
||||
reactionEmojisRef.value[ctx.emoji.name] = ctx.emoji.url;
|
||||
}
|
||||
|
||||
const currentCount = note.value.reactions[ctx.reaction] || 0;
|
||||
const currentCount = reactionsRef.value[ctx.reaction] || 0;
|
||||
|
||||
note.value.reactions[ctx.reaction] = currentCount + 1;
|
||||
note.value.reactionCount += 1;
|
||||
reactionsRef.value[ctx.reaction] = currentCount + 1;
|
||||
reactionCountRef.value += 1;
|
||||
|
||||
if ($i && (ctx.userId === $i.id)) {
|
||||
note.value.myReaction = ctx.reaction;
|
||||
myReactionRef.value = ctx.reaction;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -216,14 +222,14 @@ export function useNoteCapture(props: {
|
|||
if (newUnreactedKey === latestUnreactedKey) return;
|
||||
latestUnreactedKey = newUnreactedKey;
|
||||
|
||||
const currentCount = note.value.reactions[ctx.reaction] || 0;
|
||||
const currentCount = reactionsRef.value[ctx.reaction] || 0;
|
||||
|
||||
note.value.reactions[ctx.reaction] = Math.max(0, currentCount - 1);
|
||||
note.value.reactionCount = Math.max(0, note.value.reactionCount - 1);
|
||||
if (note.value.reactions[ctx.reaction] === 0) delete note.value.reactions[ctx.reaction];
|
||||
reactionsRef.value[ctx.reaction] = Math.max(0, currentCount - 1);
|
||||
reactionCountRef.value = Math.max(0, reactionCountRef.value - 1);
|
||||
if (reactionsRef.value[ctx.reaction] === 0) delete reactionsRef.value[ctx.reaction];
|
||||
|
||||
if ($i && (ctx.userId === $i.id)) {
|
||||
note.value.myReaction = null;
|
||||
myReactionRef.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -232,7 +238,7 @@ export function useNoteCapture(props: {
|
|||
if (newPollVotedKey === latestPollVotedKey) return;
|
||||
latestPollVotedKey = newPollVotedKey;
|
||||
|
||||
const choices = [...note.value.poll.choices];
|
||||
const choices = [...pollChoicesRef.value];
|
||||
choices[ctx.choice] = {
|
||||
...choices[ctx.choice],
|
||||
votes: choices[ctx.choice].votes + 1,
|
||||
|
@ -241,7 +247,7 @@ export function useNoteCapture(props: {
|
|||
} : {}),
|
||||
};
|
||||
|
||||
note.value.poll.choices = choices;
|
||||
pollChoicesRef.value = choices;
|
||||
}
|
||||
|
||||
function onDeleted(): void {
|
||||
|
@ -249,22 +255,22 @@ export function useNoteCapture(props: {
|
|||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
noteEvents.off(`reacted:${note.value.id}`, onReacted);
|
||||
noteEvents.off(`unreacted:${note.value.id}`, onUnreacted);
|
||||
noteEvents.off(`pollVoted:${note.value.id}`, onPollVoted);
|
||||
noteEvents.off(`deleted:${note.value.id}`, onDeleted);
|
||||
noteEvents.off(`reacted:${note.id}`, onReacted);
|
||||
noteEvents.off(`unreacted:${note.id}`, onUnreacted);
|
||||
noteEvents.off(`pollVoted:${note.id}`, onPollVoted);
|
||||
noteEvents.off(`deleted:${note.id}`, onDeleted);
|
||||
});
|
||||
|
||||
// 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない
|
||||
// ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する
|
||||
// TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない
|
||||
if (parentNote == null) {
|
||||
if ((Date.now() - new Date(note.value.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
|
||||
if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
|
||||
// リノートで表示されているノートでもないし、投稿からある程度経過しているので購読しない
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if ((Date.now() - new Date(parentNote.value.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
|
||||
if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
|
||||
// リノートで表示されているノートだが、リノートされてからある程度経過しているので購読しない
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { computed, isRef, onMounted, ref, watch } from 'vue';
|
||||
import { computed, isRef, onMounted, ref, shallowRef, triggerRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
@ -40,9 +40,10 @@ export type PagingCtx<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoint
|
|||
|
||||
export function usePagination<T extends MisskeyEntity>(props: {
|
||||
ctx: PagingCtx;
|
||||
useShallowRef?: boolean;
|
||||
}) {
|
||||
const items = ref<T[]>([]);
|
||||
const queue = ref<T[]>([]);
|
||||
const items = props.useShallowRef ? shallowRef<T[]>([]) : ref<T[]>([]);
|
||||
const queue = props.useShallowRef ? shallowRef<T[]>([]) : ref<T[]>([]);
|
||||
const fetching = ref(true);
|
||||
const moreFetching = ref(false);
|
||||
const canFetchMore = ref(false);
|
||||
|
@ -142,8 +143,10 @@ export function usePagination<T extends MisskeyEntity>(props: {
|
|||
}).then(res => {
|
||||
if (options.toQueue) {
|
||||
queue.value.unshift(...res.toReversed());
|
||||
if (props.useShallowRef) triggerRef(queue);
|
||||
} else {
|
||||
items.value.unshift(...res.toReversed());
|
||||
if (props.useShallowRef) triggerRef(items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -155,18 +158,22 @@ export function usePagination<T extends MisskeyEntity>(props: {
|
|||
|
||||
function unshiftItems(newItems: T[]) {
|
||||
items.value.unshift(...newItems);
|
||||
if (props.useShallowRef) triggerRef(items);
|
||||
}
|
||||
|
||||
function pushItems(oldItems: T[]) {
|
||||
items.value.push(...oldItems);
|
||||
if (props.useShallowRef) triggerRef(items);
|
||||
}
|
||||
|
||||
function prepend(item: T) {
|
||||
items.value.unshift(item);
|
||||
if (props.useShallowRef) triggerRef(items);
|
||||
}
|
||||
|
||||
function enqueue(item: T) {
|
||||
queue.value.unshift(item);
|
||||
if (props.useShallowRef) triggerRef(queue);
|
||||
}
|
||||
|
||||
function releaseQueue() {
|
||||
|
|
Loading…
Reference in New Issue