summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkChannelList.vue4
-rw-r--r--packages/frontend/src/components/MkDateSeparatedList.vue4
-rw-r--r--packages/frontend/src/components/MkForgotPassword.vue4
-rw-r--r--packages/frontend/src/components/MkNote.vue176
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue167
-rw-r--r--packages/frontend/src/components/MkNotesTimeline.vue (renamed from packages/frontend/src/components/MkNotes.vue)54
-rw-r--r--packages/frontend/src/components/MkNotifications.vue142
-rw-r--r--packages/frontend/src/components/MkPagination.vue499
-rw-r--r--packages/frontend/src/components/MkPoll.vue18
-rw-r--r--packages/frontend/src/components/MkPostForm.vue6
-rw-r--r--packages/frontend/src/components/MkRange.vue7
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue48
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.vue45
-rw-r--r--packages/frontend/src/components/MkRemoteEmojiEditDialog.vue4
-rw-r--r--packages/frontend/src/components/MkRoleSelectDialog.vue4
-rw-r--r--packages/frontend/src/components/MkStreamingNotesTimeline.vue531
-rw-r--r--packages/frontend/src/components/MkStreamingNotificationsTimeline.vue199
-rw-r--r--packages/frontend/src/components/MkTimeline.vue372
-rw-r--r--packages/frontend/src/components/MkTutorialDialog.Note.vue2
-rw-r--r--packages/frontend/src/components/MkUserAnnouncementEditDialog.vue4
-rw-r--r--packages/frontend/src/components/MkUserList.vue4
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Follow.vue6
-rw-r--r--packages/frontend/src/components/MkVisitorDashboard.vue4
-rw-r--r--packages/frontend/src/components/grid/MkGrid.vue4
-rw-r--r--packages/frontend/src/components/grid/MkHeaderCell.vue8
25 files changed, 1135 insertions, 1181 deletions
diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue
index d0b50f04f2..5562be682b 100644
--- a/packages/frontend/src/components/MkChannelList.vue
+++ b/packages/frontend/src/components/MkChannelList.vue
@@ -14,13 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import type { Paging } from '@/components/MkPagination.vue';
+import type { PagingCtx } from '@/use/use-pagination.js';
import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
- pagination: Paging;
+ pagination: PagingCtx;
noGap?: boolean;
extractor?: (item: any) => any;
}>(), {
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index 1cf6f0b744..82561055bc 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -7,8 +7,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { defineComponent, h, TransitionGroup, useCssModule } from 'vue';
-import type { PropType } from 'vue';
-import type { MisskeyEntity } from '@/types/date-separated-list.js';
import MkAd from '@/components/global/MkAd.vue';
import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
import * as os from '@/os.js';
@@ -19,7 +17,7 @@ import { getDateText } from '@/utility/timeline-date-separate.js';
export default defineComponent({
props: {
items: {
- type: Array as PropType<MisskeyEntity[]>,
+ type: Array,
required: true,
},
direction: {
diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue
index 57946aaf2b..a2843a3503 100644
--- a/packages/frontend/src/components/MkForgotPassword.vue
+++ b/packages/frontend/src/components/MkForgotPassword.vue
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
+import { ref, useTemplateRef } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@@ -53,7 +53,7 @@ const emit = defineEmits<{
(ev: 'closed'): void;
}>();
-const dialog = ref<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
const username = ref('');
const email = ref('');
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 980636f551..9b7658292d 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -6,11 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!hardMuted && muted === false"
- v-show="!isDeleted"
ref="rootEl"
v-hotkey="keymap"
:class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]"
- :tabindex="isDeleted ? '-1' : '0'"
+ tabindex="0"
>
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
@@ -87,7 +86,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="$appearNote.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 +109,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="$appearNote.reactions"
+ :reactionEmojis="$appearNote.reactionEmojis"
+ :myReaction="$appearNote.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 +142,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' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
+ <i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<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) && $appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
@@ -176,7 +193,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, reactive } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
@@ -210,7 +227,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
-import { useNoteCapture } from '@/use/use-note-capture.js';
+import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
@@ -223,6 +240,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
+import { globalEvents } from '@/events.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -245,29 +263,33 @@ 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;
- if (result === null) {
- isDeleted.value = true;
- return;
- }
} catch (err) {
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 $appearNote = reactive({
+ reactions: appearNote.reactions,
+ reactionCount: appearNote.reactionCount,
+ reactionEmojis: appearNote.reactionEmojis,
+ myReaction: appearNote.myReaction,
+ pollChoices: appearNote.poll?.choices,
+});
const rootEl = useTemplateRef('rootEl');
const menuButton = useTemplateRef('menuButton');
@@ -275,32 +297,30 @@ 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 isDeleted = ref(false);
-const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
-const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
+const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
+const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
+const isLong = shouldCollapsed(appearNote, urls.value ?? []);
+const collapsed = ref(appearNote.cw == null && isLong);
+const muted = ref(checkMute(appearNote, $i?.mutedWords));
+const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
const 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
+ ($appearNote.myReaction != 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 +377,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,28 +400,28 @@ 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.id}`, {
+ userId: $i!.id,
+ reaction: reaction,
+ });
});
});
-if (props.mock) {
- watch(() => props.note, (to) => {
- note.value = deepClone(to);
- }, { deep: true });
-} else {
+if (!props.mock) {
useNoteCapture({
- rootEl: rootEl,
note: appearNote,
- pureNote: note,
- isDeletedRef: isDeleted,
+ parentNote: note,
+ $note: $appearNote,
});
}
if (!props.mock) {
useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', {
- noteId: appearNote.value.id,
+ noteId: appearNote.id,
limit: 11,
});
@@ -412,19 +432,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_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
@@ -435,7 +455,7 @@ if (!props.mock) {
showing,
reaction: '❤️',
users,
- count: appearNote.value.reactionCount,
+ count: $appearNote.reactionCount,
targetElement: reactButton.value!,
}, {
closed: () => dispose(),
@@ -448,7 +468,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,
});
@@ -460,8 +480,8 @@ function reply(): void {
return;
}
os.post({
- reply: appearNote.value,
- channel: appearNote.value.channel,
+ reply: appearNote,
+ channel: appearNote.channel,
}).then(() => {
focus();
});
@@ -470,7 +490,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) {
@@ -478,8 +498,13 @@ function react(): void {
}
misskeyApi('notes/reactions/create', {
- noteId: appearNote.value.id,
+ noteId: appearNote.id,
reaction: '❤️',
+ }).then(() => {
+ noteEvents.emit(`reacted:${appearNote.id}`, {
+ userId: $i!.id,
+ reaction: '❤️',
+ });
});
const el = reactButton.value;
if (el && prefer.s.animation) {
@@ -492,7 +517,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',
@@ -506,14 +531,23 @@ function react(): void {
if (props.mock) {
emit('reaction', reaction);
+ $appearNote.reactions[reaction] = 1;
+ $appearNote.reactionCount++;
+ $appearNote.myReaction = reaction;
return;
}
misskeyApi('notes/reactions/create', {
- noteId: appearNote.value.id,
+ noteId: appearNote.id,
reaction: reaction,
+ }).then(() => {
+ 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');
}
}, () => {
@@ -522,8 +556,8 @@ function react(): void {
}
}
-function undoReact(targetNote: Misskey.entities.Note): void {
- const oldReaction = targetNote.myReaction;
+function undoReact(): void {
+ const oldReaction = $appearNote.myReaction;
if (!oldReaction) return;
if (props.mock) {
@@ -532,15 +566,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 ($appearNote.myReaction == null) {
react();
} else {
- undoReact(appearNote.value);
+ undoReact();
}
}
@@ -556,7 +590,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, currentClip: currentClip?.value });
os.contextMenu(menu, ev).then(focus).finally(cleanup);
}
}
@@ -566,7 +600,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, currentClip: currentClip?.value });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
@@ -575,7 +609,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, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
function showRenoteMenu(): void {
@@ -590,9 +624,10 @@ function showRenoteMenu(): void {
danger: true,
action: () => {
misskeyApi('notes/delete', {
- noteId: note.value.id,
+ noteId: note.id,
+ }).then(() => {
+ globalEvents.emit('noteDeleted', note.id);
});
- isDeleted.value = true;
},
};
}
@@ -601,23 +636,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);
}
@@ -641,9 +676,8 @@ function focusAfter() {
function readPromo() {
misskeyApi('promo/read', {
- noteId: appearNote.value.id,
+ noteId: appearNote.id,
});
- isDeleted.value = true;
}
function emitUpdReaction(emoji: string, delta: number) {
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 17a348affe..93e79e7c1f 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -5,12 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
- v-if="!muted"
- v-show="!isDeleted"
+ v-if="!muted && !isDeleted"
ref="rootEl"
v-hotkey="keymap"
:class="$style.root"
- :tabindex="isDeleted ? '-1' : '0'"
+ tabindex="0"
>
<div v-if="appearNote.reply && appearNote.reply.replyId">
<div v-if="!conversationLoaded" style="padding: 16px">
@@ -110,7 +109,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="$appearNote.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 +132,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="$appearNote.reactions"
+ :reactionEmojis="$appearNote.reactionEmojis"
+ :myReaction="$appearNote.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 +160,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' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
+ <i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<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) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
@@ -182,9 +199,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($appearNote.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;">{{ $appearNote.reactions[reaction] }}</span>
</button>
</div>
<MkPagination v-if="reactionTabType" :key="reactionTabType" :pagination="reactionsPagination" :disableAutoLoad="true">
@@ -199,7 +216,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
-<div v-else class="_panel" :class="$style.muted" @click="muted = false">
+<div v-else-if="muted" 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)">
@@ -211,13 +228,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue';
+import { computed, inject, onMounted, provide, reactive, ref, useTemplateRef } from 'vue';
import * as mfm from 'mfm-js';
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';
@@ -242,7 +258,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
-import { useNoteCapture } from '@/use/use-note-capture.js';
+import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
@@ -257,6 +273,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
+import { globalEvents, useGlobalEvent } from '@/events.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -267,29 +284,33 @@ 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;
- if (result === null) {
- isDeleted.value = true;
- return;
- }
} catch (err) {
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 $appearNote = reactive({
+ reactions: appearNote.reactions,
+ reactionCount: appearNote.reactionCount,
+ reactionEmojis: appearNote.reactionEmojis,
+ myReaction: appearNote.myReaction,
+ pollChoices: appearNote.poll?.choices,
+});
const rootEl = useTemplateRef('rootEl');
const menuButton = useTemplateRef('menuButton');
@@ -297,24 +318,29 @@ 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);
+
+useGlobalEvent('noteDeleted', (noteId) => {
+ if (noteId === note.id || noteId === appearNote.id) {
+ isDeleted.value = true;
+ }
+});
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
- url: `https://${host}/notes/${appearNote.value.id}`,
+ url: `https://${host}/notes/${appearNote.id}`,
}));
const keymap = {
@@ -328,7 +354,7 @@ const keymap = {
},
'o': () => galleryEl.value?.openGallery(),
'v|enter': () => {
- if (appearNote.value.cw != null) {
+ if (appearNote.cw != null) {
showContent.value = !showContent.value;
}
},
@@ -341,41 +367,45 @@ 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.id}`, {
+ userId: $i!.id,
+ reaction: reaction,
+ });
});
});
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
-const renotesPagination = computed<Paging>(() => ({
+const renotesPagination = computed(() => ({
endpoint: 'notes/renotes',
limit: 10,
params: {
- noteId: appearNote.value.id,
+ noteId: appearNote.id,
},
}));
-const reactionsPagination = computed<Paging>(() => ({
+const reactionsPagination = computed(() => ({
endpoint: 'notes/reactions',
limit: 10,
params: {
- noteId: appearNote.value.id,
+ noteId: appearNote.id,
type: reactionTabType.value,
},
}));
useNoteCapture({
- rootEl: rootEl,
note: appearNote,
- pureNote: note,
- isDeletedRef: isDeleted,
+ parentNote: note,
+ $note: $appearNote,
});
useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', {
- noteId: appearNote.value.id,
+ noteId: appearNote.id,
limit: 11,
});
@@ -386,19 +416,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_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
@@ -409,7 +439,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
showing,
reaction: '❤️',
users,
- count: appearNote.value.reactionCount,
+ count: $appearNote.reactionCount,
targetElement: reactButton.value!,
}, {
closed: () => dispose(),
@@ -421,7 +451,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);
}
@@ -429,8 +459,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();
});
@@ -439,12 +469,17 @@ 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.id}`, {
+ userId: $i!.id,
+ reaction: '❤️',
+ });
});
const el = reactButton.value;
if (el && prefer.s.animation) {
@@ -457,7 +492,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',
@@ -470,10 +505,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.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');
}
}, () => {
@@ -491,10 +531,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);
}
}
@@ -506,18 +546,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 });
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 });
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 }), clipButton.value).then(focus);
}
function showRenoteMenu(): void {
@@ -529,9 +569,10 @@ function showRenoteMenu(): void {
danger: true,
action: () => {
misskeyApi('notes/delete', {
- noteId: note.value.id,
+ noteId: note.id,
+ }).then(() => {
+ globalEvents.emit('noteDeleted', note.id);
});
- isDeleted.value = true;
},
}], renoteTime.value);
}
@@ -549,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;
@@ -560,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();
});
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotesTimeline.vue
index 509099e0b9..71dd8e51a0 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotesTimeline.vue
@@ -4,13 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad">
+<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad" :pullToRefresh="pullToRefresh">
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<template #default="{ items: notes }">
- <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]">
+ <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]">
<template v-for="(note, i) in notes" :key="note.id">
- <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
+ <div v-if="i > 0 && isSeparatorNeeded(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id">
+ <div :class="$style.date">
+ <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span>
+ <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
+ <span>{{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span>
+ </div>
+ <MkNote :class="$style.note" :note="note" :withHardMute="true"/>
+ </div>
+ <div v-else-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
@@ -25,30 +33,38 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
-import type { Paging } from '@/components/MkPagination.vue';
+import type { PagingCtx } from '@/use/use-pagination.js';
import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
+import { globalEvents, useGlobalEvent } from '@/events.js';
+import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
-const props = defineProps<{
- pagination: Paging;
+const props = withDefaults(defineProps<{
+ pagination: PagingCtx;
noGap?: boolean;
disableAutoLoad?: boolean;
-}>();
+ pullToRefresh?: boolean;
+}>(), {
+ pullToRefresh: true,
+});
const pagingComponent = useTemplateRef('pagingComponent');
+useGlobalEvent('noteDeleted', (noteId) => {
+ pagingComponent.value?.paginator.removeItem(noteId);
+});
+
+function reload() {
+ return pagingComponent.value?.paginator.reload();
+}
+
defineExpose({
- pagingComponent,
+ reload,
});
</script>
<style lang="scss" module>
-.reverse {
- display: flex;
- flex-direction: column-reverse;
-}
-
.root {
container-type: inline-size;
@@ -77,6 +93,18 @@ defineExpose({
}
}
+.date {
+ display: flex;
+ font-size: 85%;
+ align-items: center;
+ justify-content: center;
+ gap: 1em;
+ opacity: 0.75;
+ padding: 8px 8px;
+ margin: 0 auto;
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
+
.ad:empty {
display: none;
}
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
deleted file mode 100644
index 3c88b8af0d..0000000000
--- a/packages/frontend/src/components/MkNotifications.vue
+++ /dev/null
@@ -1,142 +0,0 @@
-<!--
-SPDX-FileCopyrightText: syuilo and misskey-project
-SPDX-License-Identifier: AGPL-3.0-only
--->
-
-<template>
-<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
- <MkPagination ref="pagingComponent" :pagination="pagination">
- <template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template>
-
- <template #default="{ items: notifications }">
- <component
- :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
- :enterActiveClass="$style.transition_x_enterActive"
- :leaveActiveClass="$style.transition_x_leaveActive"
- :enterFromClass="$style.transition_x_enterFrom"
- :leaveToClass="$style.transition_x_leaveTo"
- :moveClass=" $style.transition_x_move"
- tag="div"
- >
- <template v-for="(notification, i) in notifications" :key="notification.id">
- <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
- <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
- </template>
- </component>
- </template>
- </MkPagination>
-</component>
-</template>
-
-<script lang="ts" setup>
-import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue';
-import * as Misskey from 'misskey-js';
-import type { notificationTypes } from '@@/js/const.js';
-import MkPagination from '@/components/MkPagination.vue';
-import XNotification from '@/components/MkNotification.vue';
-import MkNote from '@/components/MkNote.vue';
-import { useStream } from '@/stream.js';
-import { i18n } from '@/i18n.js';
-import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
-import { prefer } from '@/preferences.js';
-
-const props = defineProps<{
- excludeTypes?: typeof notificationTypes[number][];
-}>();
-
-const pagingComponent = useTemplateRef('pagingComponent');
-
-const pagination = computed(() => prefer.r.useGroupedNotifications.value ? {
- endpoint: 'i/notifications-grouped' as const,
- limit: 20,
- params: computed(() => ({
- excludeTypes: props.excludeTypes ?? undefined,
- })),
-} : {
- endpoint: 'i/notifications' as const,
- limit: 20,
- params: computed(() => ({
- excludeTypes: props.excludeTypes ?? undefined,
- })),
-});
-
-function onNotification(notification) {
- const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
- if (isMuted || window.document.visibilityState === 'visible') {
- useStream().send('readNotification');
- }
-
- if (!isMuted) {
- pagingComponent.value?.prepend(notification);
- }
-}
-
-function reload() {
- return new Promise<void>((res) => {
- pagingComponent.value?.reload().then(() => {
- res();
- });
- });
-}
-
-let connection: Misskey.ChannelConnection<Misskey.Channels['main']>;
-
-onMounted(() => {
- connection = useStream().useChannel('main');
- connection.on('notification', onNotification);
- connection.on('notificationFlushed', reload);
-});
-
-onUnmounted(() => {
- if (connection) connection.dispose();
-});
-
-defineExpose({
- reload,
-});
-</script>
-
-<style lang="scss" module>
-.transition_x_move {
- transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
-}
-
-.transition_x_enterActive {
- transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
-
- &.item,
- .item {
- /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
- content-visibility: visible !important;
- }
-}
-
-.transition_x_leaveActive {
- transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
-}
-
-.transition_x_enterFrom {
- opacity: 0;
- transform: translateY(max(-64px, -100%));
-}
-
-@supports (interpolate-size: allow-keywords) {
- .transition_x_enterFrom {
- interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
- height: 0;
- }
-}
-
-.transition_x_leaveTo {
- opacity: 0;
-}
-
-.notifications {
- container-type: inline-size;
- background: var(--MI_THEME-panel);
-}
-
-.item {
- border-bottom: solid 0.5px var(--MI_THEME-divider);
-}
-</style>
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 54da5a889d..37e15df39b 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -4,483 +4,74 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<Transition
- :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
- :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
- :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
- :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
- mode="out-in"
->
- <MkLoading v-if="fetching"/>
+<component :is="prefer.s.enablePullToRefresh && pullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => paginator.reload()">
+ <Transition
+ :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
+ :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
+ :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
+ :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
+ :css="prefer.s.animation"
+ mode="out-in"
+ >
+ <MkLoading v-if="paginator.fetching.value"/>
- <MkError v-else-if="error" @retry="init()"/>
+ <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
- <div v-else-if="empty" key="_empty_">
- <slot name="empty"><MkResult type="empty"/></slot>
- </div>
-
- <div v-else ref="rootEl" class="_gaps">
- <div v-show="pagination.reversed && more" key="_more_">
- <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead">
- {{ i18n.ts.loadMore }}
- </MkButton>
- <MkLoading v-else/>
+ <div v-else-if="paginator.items.value.length === 0" key="_empty_">
+ <slot name="empty"><MkResult type="empty"/></slot>
</div>
- <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
- <div v-show="!pagination.reversed && more" key="_more_">
- <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">
- {{ i18n.ts.loadMore }}
- </MkButton>
- <MkLoading v-else/>
+
+ <div v-else ref="rootEl" class="_gaps">
+ <div v-show="pagination.reversed && paginator.canFetchOlder.value" key="_more_">
+ <MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchNewer">
+ {{ i18n.ts.loadMore }}
+ </MkButton>
+ <MkLoading v-else/>
+ </div>
+ <slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot>
+ <div v-show="!pagination.reversed && paginator.canFetchOlder.value" key="_more_">
+ <MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder">
+ {{ i18n.ts.loadMore }}
+ </MkButton>
+ <MkLoading v-else/>
+ </div>
</div>
- </div>
-</Transition>
+ </Transition>
+</component>
</template>
-<script lang="ts">
-import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue';
-import * as Misskey from 'misskey-js';
-import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
-import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scrollInContainer, isTailVisible } from '@@/js/scroll.js';
-import type { ComputedRef } from 'vue';
-import type { MisskeyEntity } from '@/types/date-separated-list.js';
-import { misskeyApi } from '@/utility/misskey-api.js';
-import { i18n } from '@/i18n.js';
-import { prefer } from '@/preferences.js';
-
-const SECOND_FETCH_LIMIT = 30;
-const TOLERANCE = 16;
-const APPEAR_MINIMUM_INTERVAL = 600;
-
-export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
- endpoint: E;
- limit: number;
- params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
-
- /**
- * 検索APIのような、ページング不可なエンドポイントを利用する場合
- * (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
- */
- noPaging?: boolean;
-
- /**
- * items 配列の中身を逆順にする(新しい方が最後)
- */
- reversed?: boolean;
-
- offsetMode?: boolean;
-};
-
-type MisskeyEntityMap = Map<string, MisskeyEntity>;
-
-function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
- return entities.map(en => [en.id, en]);
-}
-
-function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
- return new Map([...map, ...arrayToEntries(entities)]);
-}
-
-</script>
<script lang="ts" setup>
+import type { PagingCtx } from '@/use/use-pagination.js';
import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
+import { usePagination } from '@/use/use-pagination.js';
+import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
const props = withDefaults(defineProps<{
- pagination: Paging;
+ pagination: PagingCtx;
disableAutoLoad?: boolean;
displayLimit?: number;
+ pullToRefresh?: boolean;
}>(), {
displayLimit: 20,
+ pullToRefresh: true,
});
-const emit = defineEmits<{
- (ev: 'queue', count: number): void;
- (ev: 'status', error: boolean): void;
-}>();
-
-const rootEl = useTemplateRef('rootEl');
-
-// 遡り中かどうか
-const backed = ref(false);
-
-const scrollRemove = ref<(() => void) | null>(null);
-
-/**
- * 表示するアイテムのソース
- * 最新が0番目
- */
-const items = ref<MisskeyEntityMap>(new Map());
-
-/**
- * タブが非アクティブなどの場合に更新を貯めておく
- * 最新が0番目
- */
-const queue = ref<MisskeyEntityMap>(new Map());
-
-/**
- * 初期化中かどうか(trueならMkLoadingで全て隠す)
- */
-const fetching = ref(true);
-
-const moreFetching = ref(false);
-const more = ref(false);
-const preventAppearFetchMore = ref(false);
-const preventAppearFetchMoreTimer = ref<number | null>(null);
-const isBackTop = ref(false);
-const empty = computed(() => items.value.size === 0);
-const error = ref(false);
-const {
- enableInfiniteScroll,
-} = prefer.r;
-
-const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body);
-
-const visibility = useDocumentVisibility();
-
-let isPausingUpdate = false;
-let timerForSetPause: number | null = null;
-const BACKGROUND_PAUSE_WAIT_SEC = 10;
-
-// 先頭が表示されているかどうかを検出
-// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
-const scrollObserver = ref<IntersectionObserver>();
-
-watch([() => props.pagination.reversed, scrollableElement], () => {
- if (scrollObserver.value) scrollObserver.value.disconnect();
-
- scrollObserver.value = new IntersectionObserver(entries => {
- backed.value = entries[0].isIntersecting;
- }, {
- root: scrollableElement.value,
- rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
- threshold: 0.01,
- });
-}, { immediate: true });
-
-watch(rootEl, () => {
- scrollObserver.value?.disconnect();
- nextTick(() => {
- if (rootEl.value) scrollObserver.value?.observe(rootEl.value);
- });
-});
-
-watch([backed, rootEl], () => {
- if (!backed.value) {
- if (!rootEl.value) return;
-
- scrollRemove.value = props.pagination.reversed
- ? onScrollBottom(rootEl.value, executeQueue, TOLERANCE)
- : onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
- } else {
- if (scrollRemove.value) scrollRemove.value();
- scrollRemove.value = null;
- }
-});
-
-// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
-watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
-
-watch(queue, (a, b) => {
- if (a.size === 0 && b.size === 0) return;
- emit('queue', queue.value.size);
-}, { deep: true });
-
-watch(error, (n, o) => {
- if (n === o) return;
- emit('status', n);
-});
-
-async function init(): Promise<void> {
- items.value = new Map();
- queue.value = new Map();
- fetching.value = true;
- const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
- await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
- ...params,
- limit: props.pagination.limit ?? 10,
- allowPartial: true,
- }).then(res => {
- for (let i = 0; i < res.length; i++) {
- const item = res[i];
- if (i === 3) item._shouldInsertAd_ = true;
- }
-
- if (res.length === 0 || props.pagination.noPaging) {
- concatItems(res);
- more.value = false;
- } else {
- if (props.pagination.reversed) moreFetching.value = true;
- concatItems(res);
- more.value = true;
- }
-
- error.value = false;
- fetching.value = false;
- }, err => {
- error.value = true;
- fetching.value = false;
- });
-}
-
-const reload = (): Promise<void> => {
- return init();
-};
-
-const fetchMore = async (): Promise<void> => {
- if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
- moreFetching.value = true;
- const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
- await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
- ...params,
- limit: SECOND_FETCH_LIMIT,
- ...(props.pagination.offsetMode ? {
- offset: items.value.size,
- } : {
- untilId: Array.from(items.value.keys()).at(-1),
- }),
- }).then(res => {
- for (let i = 0; i < res.length; i++) {
- const item = res[i];
- if (i === 10) item._shouldInsertAd_ = true;
- }
-
- const reverseConcat = _res => {
- const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
- const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
-
- items.value = concatMapWithArray(items.value, _res);
-
- return nextTick(() => {
- if (scrollableElement.value) {
- scrollInContainer(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
- } else {
- window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
- }
-
- return nextTick();
- });
- };
-
- if (res.length === 0) {
- if (props.pagination.reversed) {
- reverseConcat(res).then(() => {
- more.value = false;
- moreFetching.value = false;
- });
- } else {
- items.value = concatMapWithArray(items.value, res);
- more.value = false;
- moreFetching.value = false;
- }
- } else {
- if (props.pagination.reversed) {
- reverseConcat(res).then(() => {
- more.value = true;
- moreFetching.value = false;
- });
- } else {
- items.value = concatMapWithArray(items.value, res);
- more.value = true;
- moreFetching.value = false;
- }
- }
- }, err => {
- moreFetching.value = false;
- });
-};
-
-const fetchMoreAhead = async (): Promise<void> => {
- if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
- moreFetching.value = true;
- const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
- await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
- ...params,
- limit: SECOND_FETCH_LIMIT,
- ...(props.pagination.offsetMode ? {
- offset: items.value.size,
- } : {
- sinceId: Array.from(items.value.keys()).at(-1),
- }),
- }).then(res => {
- if (res.length === 0) {
- items.value = concatMapWithArray(items.value, res);
- more.value = false;
- } else {
- items.value = concatMapWithArray(items.value, res);
- more.value = true;
- }
- moreFetching.value = false;
- }, err => {
- moreFetching.value = false;
- });
-};
-
-/**
- * Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、
- * APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
- */
-const fetchMoreApperTimeoutFn = (): void => {
- preventAppearFetchMore.value = false;
- preventAppearFetchMoreTimer.value = null;
-};
-const fetchMoreAppearTimeout = (): void => {
- preventAppearFetchMore.value = true;
- preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
-};
-
-const appearFetchMore = async (): Promise<void> => {
- if (preventAppearFetchMore.value) return;
- await fetchMore();
- fetchMoreAppearTimeout();
-};
-
-const appearFetchMoreAhead = async (): Promise<void> => {
- if (preventAppearFetchMore.value) return;
- await fetchMoreAhead();
- fetchMoreAppearTimeout();
-};
-
-const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
-
-watch(visibility, () => {
- if (visibility.value === 'hidden') {
- timerForSetPause = window.setTimeout(() => {
- isPausingUpdate = true;
- timerForSetPause = null;
- },
- BACKGROUND_PAUSE_WAIT_SEC * 1000);
- } else { // 'visible'
- if (timerForSetPause) {
- window.clearTimeout(timerForSetPause);
- timerForSetPause = null;
- } else {
- isPausingUpdate = false;
- if (isHead()) {
- executeQueue();
- }
- }
- }
+const paginator = usePagination({
+ ctx: props.pagination,
});
-/**
- * 最新のものとして1つだけアイテムを追加する
- * ストリーミングから降ってきたアイテムはこれで追加する
- * @param item アイテム
- */
-function prepend(item: MisskeyEntity): void {
- if (items.value.size === 0) {
- items.value.set(item.id, item);
- fetching.value = false;
- return;
- }
-
- if (_DEV_) console.log(isHead(), isPausingUpdate);
-
- if (isHead() && !isPausingUpdate) unshiftItems([item]);
- else prependQueue(item);
+function appearFetchMoreAhead() {
+ paginator.fetchNewer();
}
-/**
- * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
- * @param newItems 新しいアイテムの配列
- */
-function unshiftItems(newItems: MisskeyEntity[]) {
- const length = newItems.length + items.value.size;
- items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
-
- if (length >= props.displayLimit) more.value = true;
+function appearFetchMore() {
+ paginator.fetchOlder();
}
-/**
- * 古いアイテムをitemsの末尾に追加し、displayLimitを適用する
- * @param oldItems 古いアイテムの配列
- */
-function concatItems(oldItems: MisskeyEntity[]) {
- const length = oldItems.length + items.value.size;
- items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
-
- if (length >= props.displayLimit) more.value = true;
-}
-
-function executeQueue() {
- unshiftItems(Array.from(queue.value.values()));
- queue.value = new Map();
-}
-
-function prependQueue(newItem: MisskeyEntity) {
- queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
-}
-
-/*
- * アイテムを末尾に追加する(使うの?)
- */
-const appendItem = (item: MisskeyEntity): void => {
- items.value.set(item.id, item);
-};
-
-const removeItem = (id: string) => {
- items.value.delete(id);
- queue.value.delete(id);
-};
-
-const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
- const item = items.value.get(id);
- if (item) items.value.set(id, replacer(item));
-
- const queueItem = queue.value.get(id);
- if (queueItem) queue.value.set(id, replacer(queueItem));
-};
-
-onActivated(() => {
- isBackTop.value = false;
-});
-
-onDeactivated(() => {
- isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
-});
-
-function toBottom() {
- scrollToBottom(rootEl.value!);
-}
-
-onBeforeMount(() => {
- init().then(() => {
- if (props.pagination.reversed) {
- nextTick(() => {
- window.setTimeout(toBottom, 800);
-
- // scrollToBottomでmoreFetchingボタンが画面外まで出るまで
- // more = trueを遅らせる
- window.setTimeout(() => {
- moreFetching.value = false;
- }, 2000);
- });
- }
- });
-});
-
-onBeforeUnmount(() => {
- if (timerForSetPause) {
- window.clearTimeout(timerForSetPause);
- timerForSetPause = null;
- }
- if (preventAppearFetchMoreTimer.value) {
- window.clearTimeout(preventAppearFetchMoreTimer.value);
- preventAppearFetchMoreTimer.value = null;
- }
- scrollObserver.value?.disconnect();
-});
-
defineExpose({
- items,
- queue,
- backed: backed.value,
- more,
- reload,
- prepend,
- append: appendItem,
- removeItem,
- updateItem,
+ paginator: paginator,
});
</script>
diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue
index 2d3ec45bca..359ee08812 100644
--- a/packages/frontend/src/components/MkPoll.vue
+++ b/packages/frontend/src/components/MkPoll.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index c4857b7f65..5114e98494 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -137,6 +137,7 @@ import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
+import { globalEvents } from '@/events.js';
const $i = ensureSignin();
@@ -883,12 +884,15 @@ async function post(ev?: MouseEvent) {
}
posting.value = true;
- misskeyApi('notes/create', postData, token).then(() => {
+ misskeyApi('notes/create', postData, token).then((res) => {
if (props.freezeAfterPosted) {
posted.value = true;
} else {
clear();
}
+
+ globalEvents.emit('notePosted', res.createdNote);
+
nextTick(() => {
deleteDraft();
emit('posted');
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index 4b2e6910db..f36e68b687 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<slot name="label"></slot>
</div>
<div v-adaptive-border class="body">
+ <slot name="prefix"></slot>
<div ref="containerEl" class="container">
<div class="track">
<div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
@@ -25,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@touchstart="onMousedown"
></div>
</div>
+ <slot name="suffix"></slot>
</div>
<div class="caption">
<slot name="caption"></slot>
@@ -224,12 +226,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
$thumbWidth: 20px;
> .body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
padding: 7px 12px;
background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-panel);
border-radius: 6px;
> .container {
+ flex: 1;
position: relative;
height: $thumbHeight;
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 951447f15a..9027ffd0ae 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -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,19 +29,21 @@ 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';
import { customEmojisMap } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js';
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);
@@ -56,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',
@@ -81,12 +85,23 @@ async function toggleReaction() {
}
misskeyApi('notes/reactions/delete', {
- noteId: props.note.id,
+ noteId: props.noteId,
}).then(() => {
+ 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.noteId}`, {
+ userId: $i!.id,
+ reaction: props.reaction,
+ emoji: emoji.value,
+ });
});
}
});
@@ -108,12 +123,19 @@ async function toggleReaction() {
}
misskeyApi('notes/reactions/create', {
- noteId: props.note.id,
+ noteId: props.noteId,
reaction: props.reaction,
+ }).then(() => {
+ 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');
+ //}
}
}
@@ -157,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,
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
index e8cf6c36db..725978179e 100644
--- a/packages/frontend/src/components/MkReactionsViewer.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue
index cb50df1743..abe6466971 100644
--- a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue
+++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { computed, ref } from 'vue';
+import { computed, ref, useTemplateRef } from 'vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@@ -81,7 +81,7 @@ const emit = defineEmits<{
(ev: 'closed'): void
}>();
-const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
+const windowEl = useTemplateRef('windowEl');
const name = computed(() => props.emoji.name);
const host = computed(() => props.emoji.host);
diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue
index 6888824437..fc7ba50fb3 100644
--- a/packages/frontend/src/components/MkRoleSelectDialog.vue
+++ b/packages/frontend/src/components/MkRoleSelectDialog.vue
@@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
-import { computed, ref, toRefs } from 'vue';
+import { computed, ref, toRefs, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
@@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{
const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props);
-const windowEl = ref<InstanceType<typeof MkModalWindow>>();
+const windowEl = useTemplateRef('windowEl');
const roles = ref<Misskey.entities.Role[]>([]);
const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []);
const fetching = ref(false);
diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
new file mode 100644
index 0000000000..75b2d10100
--- /dev/null
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -0,0 +1,531 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
+ <MkLoading v-if="paginator.fetching.value"/>
+
+ <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
+
+ <div v-else-if="paginator.items.value.length === 0" key="_empty_">
+ <slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotes"/></slot>
+ </div>
+
+ <div v-else ref="rootEl">
+ <div v-if="paginator.queuedAheadItemsCount.value > 0" :class="$style.new">
+ <div :class="$style.newBg1"></div>
+ <div :class="$style.newBg2"></div>
+ <button class="_button" :class="$style.newButton" @click="releaseQueue()"><i class="ti ti-circle-arrow-up"></i> {{ i18n.ts.newNote }}</button>
+ </div>
+ <component
+ :is="prefer.s.animation ? TransitionGroup : 'div'"
+ :class="$style.notes"
+ :enterActiveClass="$style.transition_x_enterActive"
+ :leaveActiveClass="$style.transition_x_leaveActive"
+ :enterFromClass="$style.transition_x_enterFrom"
+ :leaveToClass="$style.transition_x_leaveTo"
+ :moveClass="$style.transition_x_move"
+ tag="div"
+ >
+ <template v-for="(note, i) in paginator.items.value" :key="note.id">
+ <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id">
+ <div :class="$style.date">
+ <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span>
+ <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
+ <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span>
+ </div>
+ <MkNote :class="$style.note" :note="note" :withHardMute="true"/>
+ </div>
+ <div v-else-if="note._shouldInsertAd_" :data-scroll-anchor="note.id">
+ <MkNote :class="$style.note" :note="note" :withHardMute="true"/>
+ <div :class="$style.ad">
+ <MkAd :preferForms="['horizontal', 'horizontal-big']"/>
+ </div>
+ </div>
+ <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
+ </template>
+ </component>
+ <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
+ <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
+ <MkLoading v-else :inline="true"/>
+ </button>
+ </div>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
+import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
+import type { BasicTimelineType } from '@/timelines.js';
+import type { PagingCtx } from '@/use/use-pagination.js';
+import { usePagination } from '@/use/use-pagination.js';
+import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
+import { useStream } from '@/stream.js';
+import * as sound from '@/utility/sound.js';
+import { $i } from '@/i.js';
+import { instance } from '@/instance.js';
+import { prefer } from '@/preferences.js';
+import { store } from '@/store.js';
+import MkNote from '@/components/MkNote.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { globalEvents, useGlobalEvent } from '@/events.js';
+import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
+
+const props = withDefaults(defineProps<{
+ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
+ list?: string;
+ antenna?: string;
+ channel?: string;
+ role?: string;
+ sound?: boolean;
+ withRenotes?: boolean;
+ withReplies?: boolean;
+ withSensitive?: boolean;
+ onlyFiles?: boolean;
+}>(), {
+ withRenotes: true,
+ withReplies: false,
+ withSensitive: true,
+ onlyFiles: false,
+});
+
+provide('inTimeline', true);
+provide('tl_withSensitive', computed(() => props.withSensitive));
+provide('inChannel', computed(() => props.src === 'channel'));
+
+function isTop() {
+ if (scrollContainer == null) return true;
+ if (rootEl.value == null) return true;
+ const scrollTop = scrollContainer.scrollTop;
+ const tlTop = rootEl.value.offsetTop - scrollContainer.offsetTop;
+ return scrollTop <= tlTop;
+}
+
+let scrollContainer: HTMLElement | null = null;
+
+function onScrollContainerScroll() {
+ if (isTop()) {
+ paginator.releaseQueue();
+ }
+}
+
+const rootEl = useTemplateRef('rootEl');
+watch(rootEl, (el) => {
+ if (el && scrollContainer == null) {
+ scrollContainer = getScrollContainer(el);
+ if (scrollContainer == null) return;
+ scrollContainer.addEventListener('scroll', onScrollContainerScroll, { passive: true }); // ほんとはscrollendにしたいけどiosが非対応
+ }
+}, { immediate: true });
+
+onUnmounted(() => {
+ if (scrollContainer) {
+ scrollContainer.removeEventListener('scroll', onScrollContainerScroll);
+ }
+});
+
+type TimelineQueryType = {
+ antennaId?: string,
+ withRenotes?: boolean,
+ withReplies?: boolean,
+ withFiles?: boolean,
+ visibility?: string,
+ listId?: string,
+ channelId?: string,
+ roleId?: string
+};
+
+let adInsertionCounter = 0;
+
+const MIN_POLLING_INTERVAL = 1000 * 10;
+const POLLING_INTERVAL =
+ prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
+ prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
+ prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
+ MIN_POLLING_INTERVAL;
+
+if (!store.s.realtimeMode) {
+ // TODO: 先頭のノートの作成日時が1日以上前であれば流速が遅いTLと見做してインターバルを通常より延ばす
+ useInterval(async () => {
+ paginator.fetchNewer({
+ toQueue: !isTop(),
+ });
+ }, POLLING_INTERVAL, {
+ immediate: false,
+ afterMounted: true,
+ });
+
+ useGlobalEvent('notePosted', (note) => {
+ paginator.fetchNewer({
+ toQueue: !isTop(),
+ });
+ });
+}
+
+useGlobalEvent('noteDeleted', (noteId) => {
+ paginator.removeItem(noteId);
+});
+
+function releaseQueue() {
+ paginator.releaseQueue();
+ scrollToTop(rootEl.value);
+}
+
+function prepend(note: Misskey.entities.Note) {
+ adInsertionCounter++;
+
+ if (instance.notesPerOneAd > 0 && adInsertionCounter % instance.notesPerOneAd === 0) {
+ note._shouldInsertAd_ = true;
+ }
+
+ if (isTop()) {
+ paginator.prepend(note);
+ } else {
+ paginator.enqueue(note);
+ }
+
+ if (props.sound) {
+ sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
+ }
+}
+
+let connection: Misskey.ChannelConnection | null = null;
+let connection2: Misskey.ChannelConnection | null = null;
+let paginationQuery: PagingCtx;
+
+const stream = store.s.realtimeMode ? useStream() : null;
+
+function connectChannel() {
+ if (props.src === 'antenna') {
+ if (props.antenna == null) return;
+ connection = stream.useChannel('antenna', {
+ antennaId: props.antenna,
+ });
+ } else if (props.src === 'home') {
+ connection = stream.useChannel('homeTimeline', {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
+ });
+ connection2 = stream.useChannel('main');
+ } else if (props.src === 'local') {
+ connection = stream.useChannel('localTimeline', {
+ withRenotes: props.withRenotes,
+ withReplies: props.withReplies,
+ withFiles: props.onlyFiles ? true : undefined,
+ });
+ } else if (props.src === 'social') {
+ connection = stream.useChannel('hybridTimeline', {
+ withRenotes: props.withRenotes,
+ withReplies: props.withReplies,
+ withFiles: props.onlyFiles ? true : undefined,
+ });
+ } else if (props.src === 'global') {
+ connection = stream.useChannel('globalTimeline', {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
+ });
+ } else if (props.src === 'mentions') {
+ connection = stream.useChannel('main');
+ connection.on('mention', prepend);
+ } else if (props.src === 'directs') {
+ const onNote = note => {
+ if (note.visibility === 'specified') {
+ prepend(note);
+ }
+ };
+ connection = stream.useChannel('main');
+ connection.on('mention', onNote);
+ } else if (props.src === 'list') {
+ if (props.list == null) return;
+ connection = stream.useChannel('userList', {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
+ listId: props.list,
+ });
+ } else if (props.src === 'channel') {
+ if (props.channel == null) return;
+ connection = stream.useChannel('channel', {
+ channelId: props.channel,
+ });
+ } else if (props.src === 'role') {
+ if (props.role == null) return;
+ connection = stream.useChannel('roleTimeline', {
+ roleId: props.role,
+ });
+ }
+ if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend);
+}
+
+function disconnectChannel() {
+ if (connection) connection.dispose();
+ if (connection2) connection2.dispose();
+}
+
+function updatePaginationQuery() {
+ let endpoint: keyof Misskey.Endpoints | null;
+ let query: TimelineQueryType | null;
+
+ if (props.src === 'antenna') {
+ endpoint = 'antennas/notes';
+ query = {
+ antennaId: props.antenna,
+ };
+ } else if (props.src === 'home') {
+ endpoint = 'notes/timeline';
+ query = {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
+ };
+ } else if (props.src === 'local') {
+ endpoint = 'notes/local-timeline';
+ query = {
+ withRenotes: props.withRenotes,
+ withReplies: props.withReplies,
+ withFiles: props.onlyFiles ? true : undefined,
+ };
+ } else if (props.src === 'social') {
+ endpoint = 'notes/hybrid-timeline';
+ query = {
+ withRenotes: props.withRenotes,
+ withReplies: props.withReplies,
+ withFiles: props.onlyFiles ? true : undefined,
+ };
+ } else if (props.src === 'global') {
+ endpoint = 'notes/global-timeline';
+ query = {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
+ };
+ } else if (props.src === 'mentions') {
+ endpoint = 'notes/mentions';
+ query = null;
+ } else if (props.src === 'directs') {
+ endpoint = 'notes/mentions';
+ query = {
+ visibility: 'specified',
+ };
+ } else if (props.src === 'list') {
+ endpoint = 'notes/user-list-timeline';
+ query = {
+ withRenotes: props.withRenotes,
+ withFiles: props.onlyFiles ? true : undefined,
+ listId: props.list,
+ };
+ } else if (props.src === 'channel') {
+ endpoint = 'channels/timeline';
+ query = {
+ channelId: props.channel,
+ };
+ } else if (props.src === 'role') {
+ endpoint = 'roles/notes';
+ query = {
+ roleId: props.role,
+ };
+ } else {
+ throw new Error('Unrecognized timeline type: ' + props.src);
+ }
+
+ paginationQuery = {
+ endpoint: endpoint,
+ limit: 10,
+ params: query,
+ };
+}
+
+function refreshEndpointAndChannel() {
+ if (store.s.realtimeMode) {
+ disconnectChannel();
+ connectChannel();
+ }
+
+ updatePaginationQuery();
+}
+
+// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる
+// IDが切り替わったら切り替え先のTLを表示させたい
+watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
+
+// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK
+watch(() => props.withSensitive, reloadTimeline);
+
+// 初回表示用
+refreshEndpointAndChannel();
+
+const paginator = usePagination({
+ ctx: paginationQuery,
+ useShallowRef: true,
+});
+
+onUnmounted(() => {
+ disconnectChannel();
+});
+
+function reloadTimeline() {
+ return new Promise<void>((res) => {
+ adInsertionCounter = 0;
+
+ paginator.reload().then(() => {
+ res();
+ });
+ });
+}
+
+defineExpose({
+ reloadTimeline,
+});
+</script>
+
+<style lang="scss" module>
+.transition_x_move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.transition_x_enterActive {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+
+ &.note,
+ .note {
+ /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
+ content-visibility: visible !important;
+ }
+}
+
+.transition_x_leaveActive {
+ transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
+}
+
+.transition_x_enterFrom {
+ opacity: 0;
+ transform: translateY(max(-64px, -100%));
+}
+
+@supports (interpolate-size: allow-keywords) {
+ .transition_x_leaveTo {
+ interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
+ height: 0;
+ }
+}
+
+.transition_x_leaveTo {
+ opacity: 0;
+}
+
+.notes {
+ container-type: inline-size;
+ background: var(--MI_THEME-panel);
+}
+
+.note {
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
+
+.new {
+ --gapFill: 0.5px; // 上位ヘッダーの高さにフォントの関係などで少数が含まれると、レンダリングエンジンによっては隙間が表示されてしまうため、隙間を隠すために少しずらす
+
+ position: sticky;
+ top: calc(var(--MI-stickyTop, 0px) - var(--gapFill));
+ z-index: 1000;
+ width: 100%;
+ box-sizing: border-box;
+ padding: calc(10px + var(--gapFill)) 0 10px 0;
+}
+
+/* 疑似progressive blur */
+.newBg1, .newBg2 {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+.newBg1 {
+ height: 100%;
+ -webkit-backdrop-filter: var(--MI-blur, blur(2px));
+ backdrop-filter: var(--MI-blur, blur(2px));
+ mask-image: linear-gradient( /* 疑似Easing Linear Gradients */
+ to top,
+ rgb(0 0 0 / 0%) 0%,
+ rgb(0 0 0 / 4.9%) 7.75%,
+ rgb(0 0 0 / 10.4%) 11.25%,
+ rgb(0 0 0 / 45%) 23.55%,
+ rgb(0 0 0 / 55%) 26.45%,
+ rgb(0 0 0 / 89.6%) 38.75%,
+ rgb(0 0 0 / 95.1%) 42.25%,
+ rgb(0 0 0 / 100%) 50%
+ );
+}
+
+.newBg2 {
+ height: 75%;
+ -webkit-backdrop-filter: var(--MI-blur, blur(4px));
+ backdrop-filter: var(--MI-blur, blur(4px));
+ mask-image: linear-gradient( /* 疑似Easing Linear Gradients */
+ to top,
+ rgb(0 0 0 / 0%) 0%,
+ rgb(0 0 0 / 4.9%) 15.5%,
+ rgb(0 0 0 / 10.4%) 22.5%,
+ rgb(0 0 0 / 45%) 47.1%,
+ rgb(0 0 0 / 55%) 52.9%,
+ rgb(0 0 0 / 89.6%) 77.5%,
+ rgb(0 0 0 / 95.1%) 91.9%,
+ rgb(0 0 0 / 100%) 100%
+ );
+}
+
+.newButton {
+ position: relative;
+ display: block;
+ padding: 6px 12px;
+ border-radius: 999px;
+ width: max-content;
+ margin: auto;
+ background: var(--MI_THEME-accent);
+ color: var(--MI_THEME-fgOnAccent);
+ font-size: 90%;
+
+ &:hover {
+ background: hsl(from var(--MI_THEME-accent) h s calc(l + 5));
+ }
+
+ &:active {
+ background: hsl(from var(--MI_THEME-accent) h s calc(l - 5));
+ }
+}
+
+.date {
+ display: flex;
+ font-size: 85%;
+ align-items: center;
+ justify-content: center;
+ gap: 1em;
+ opacity: 0.75;
+ padding: 8px 8px;
+ margin: 0 auto;
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
+
+.ad {
+ padding: 8px;
+ background-size: auto auto;
+ background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+
+ &:empty {
+ display: none;
+ }
+}
+
+.more {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 16px;
+ background: var(--MI_THEME-panel);
+}
+</style>
diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
new file mode 100644
index 0000000000..931f6ae115
--- /dev/null
+++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
@@ -0,0 +1,199 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
+ <MkLoading v-if="paginator.fetching.value"/>
+
+ <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
+
+ <div v-else-if="paginator.items.value.length === 0" key="_empty_">
+ <slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotifications"/></slot>
+ </div>
+
+ <div v-else ref="rootEl">
+ <component
+ :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
+ :enterActiveClass="$style.transition_x_enterActive"
+ :leaveActiveClass="$style.transition_x_leaveActive"
+ :enterFromClass="$style.transition_x_enterFrom"
+ :leaveToClass="$style.transition_x_leaveTo"
+ :moveClass="$style.transition_x_move"
+ tag="div"
+ >
+ <div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item">
+ <div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date">
+ <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).prevText }}</span>
+ <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
+ <span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span>
+ </div>
+ <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.content" :note="notification.note" :withHardMute="true"/>
+ <XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
+ </div>
+ </component>
+ <button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
+ <div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
+ <MkLoading v-else/>
+ </button>
+ </div>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue';
+import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
+import type { notificationTypes } from '@@/js/const.js';
+import XNotification from '@/components/MkNotification.vue';
+import MkNote from '@/components/MkNote.vue';
+import { useStream } from '@/stream.js';
+import { i18n } from '@/i18n.js';
+import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
+import { prefer } from '@/preferences.js';
+import { store } from '@/store.js';
+import { usePagination } from '@/use/use-pagination.js';
+import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
+
+const props = defineProps<{
+ excludeTypes?: typeof notificationTypes[number][];
+}>();
+
+const rootEl = useTemplateRef('rootEl');
+
+const paginator = usePagination({
+ ctx: prefer.s.useGroupedNotifications ? {
+ endpoint: 'i/notifications-grouped' as const,
+ limit: 20,
+ params: computed(() => ({
+ excludeTypes: props.excludeTypes ?? undefined,
+ })),
+ } : {
+ endpoint: 'i/notifications' as const,
+ limit: 20,
+ params: computed(() => ({
+ excludeTypes: props.excludeTypes ?? undefined,
+ })),
+ },
+});
+
+const MIN_POLLING_INTERVAL = 1000 * 10;
+const POLLING_INTERVAL =
+ prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
+ prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
+ prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
+ MIN_POLLING_INTERVAL;
+
+if (!store.s.realtimeMode) {
+ useInterval(async () => {
+ paginator.fetchNewer({
+ toQueue: false,
+ });
+ }, POLLING_INTERVAL, {
+ immediate: false,
+ afterMounted: true,
+ });
+}
+
+function onNotification(notification) {
+ const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
+ if (isMuted || window.document.visibilityState === 'visible') {
+ if (store.s.realtimeMode) {
+ useStream().send('readNotification');
+ }
+ }
+
+ if (!isMuted) {
+ paginator.prepend(notification);
+ }
+}
+
+function reload() {
+ return paginator.reload();
+}
+
+let connection: Misskey.ChannelConnection<Misskey.Channels['main']> | null = null;
+
+onMounted(() => {
+ if (store.s.realtimeMode) {
+ connection = useStream().useChannel('main');
+ connection.on('notification', onNotification);
+ connection.on('notificationFlushed', reload);
+ }
+});
+
+onUnmounted(() => {
+ if (connection) connection.dispose();
+});
+
+defineExpose({
+ reload,
+});
+</script>
+
+<style lang="scss" module>
+.transition_x_move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.transition_x_enterActive {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+
+ &.content,
+ .content {
+ /* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
+ content-visibility: visible !important;
+ }
+}
+
+.transition_x_leaveActive {
+ transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
+}
+
+.transition_x_enterFrom {
+ opacity: 0;
+ transform: translateY(max(-64px, -100%));
+}
+
+@supports (interpolate-size: allow-keywords) {
+ .transition_x_enterFrom {
+ interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
+ height: 0;
+ }
+}
+
+.transition_x_leaveTo {
+ opacity: 0;
+}
+
+.notifications {
+ container-type: inline-size;
+ background: var(--MI_THEME-panel);
+}
+
+.item {
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
+
+.date {
+ display: flex;
+ font-size: 85%;
+ align-items: center;
+ justify-content: center;
+ gap: 1em;
+ opacity: 0.75;
+ padding: 8px 8px;
+ margin: 0 auto;
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
+
+.more {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 16px;
+ background: var(--MI_THEME-panel);
+ border-top: solid 0.5px var(--MI_THEME-divider);
+}
+</style>
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
deleted file mode 100644
index 6a265aa836..0000000000
--- a/packages/frontend/src/components/MkTimeline.vue
+++ /dev/null
@@ -1,372 +0,0 @@
-<!--
-SPDX-FileCopyrightText: syuilo and misskey-project
-SPDX-License-Identifier: AGPL-3.0-only
--->
-
-<template>
-<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
- <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)">
- <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
-
- <template #default="{ items: notes }">
- <component
- :is="prefer.s.animation ? TransitionGroup : 'div'"
- :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]"
- :enterActiveClass="$style.transition_x_enterActive"
- :leaveActiveClass="$style.transition_x_leaveActive"
- :enterFromClass="$style.transition_x_enterFrom"
- :leaveToClass="$style.transition_x_leaveTo"
- :moveClass="$style.transition_x_move"
- tag="div"
- >
- <template v-for="(note, i) in notes" :key="note.id">
- <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
- <MkNote :class="$style.note" :note="note" :withHardMute="true"/>
- <div :class="$style.ad">
- <MkAd :preferForms="['horizontal', 'horizontal-big']"/>
- </div>
- </div>
- <MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
- </template>
- </component>
- </template>
- </MkPagination>
-</component>
-</template>
-
-<script lang="ts" setup>
-import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup } from 'vue';
-import * as Misskey from 'misskey-js';
-import type { BasicTimelineType } from '@/timelines.js';
-import type { Paging } from '@/components/MkPagination.vue';
-import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
-import { useStream } from '@/stream.js';
-import * as sound from '@/utility/sound.js';
-import { $i } from '@/i.js';
-import { instance } from '@/instance.js';
-import { prefer } from '@/preferences.js';
-import MkNote from '@/components/MkNote.vue';
-import MkPagination from '@/components/MkPagination.vue';
-import { i18n } from '@/i18n.js';
-
-const props = withDefaults(defineProps<{
- src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
- list?: string;
- antenna?: string;
- channel?: string;
- role?: string;
- sound?: boolean;
- withRenotes?: boolean;
- withReplies?: boolean;
- withSensitive?: boolean;
- onlyFiles?: boolean;
-}>(), {
- withRenotes: true,
- withReplies: false,
- withSensitive: true,
- onlyFiles: false,
-});
-
-const emit = defineEmits<{
- (ev: 'note'): void;
- (ev: 'queue', count: number): void;
-}>();
-
-provide('inTimeline', true);
-provide('tl_withSensitive', computed(() => props.withSensitive));
-provide('inChannel', computed(() => props.src === 'channel'));
-
-type TimelineQueryType = {
- antennaId?: string,
- withRenotes?: boolean,
- withReplies?: boolean,
- withFiles?: boolean,
- visibility?: string,
- listId?: string,
- channelId?: string,
- roleId?: string
-};
-
-const pagingComponent = useTemplateRef('pagingComponent');
-
-let tlNotesCount = 0;
-
-function prepend(note) {
- if (pagingComponent.value == null) return;
-
- tlNotesCount++;
-
- if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) {
- note._shouldInsertAd_ = true;
- }
-
- pagingComponent.value.prepend(note);
-
- emit('note');
-
- if (props.sound) {
- sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
- }
-}
-
-let connection: Misskey.ChannelConnection | null = null;
-let connection2: Misskey.ChannelConnection | null = null;
-let paginationQuery: Paging | null = null;
-const noGap = !prefer.s.showGapBetweenNotesInTimeline;
-
-const stream = useStream();
-
-function connectChannel() {
- if (props.src === 'antenna') {
- if (props.antenna == null) return;
- connection = stream.useChannel('antenna', {
- antennaId: props.antenna,
- });
- } else if (props.src === 'home') {
- connection = stream.useChannel('homeTimeline', {
- withRenotes: props.withRenotes,
- withFiles: props.onlyFiles ? true : undefined,
- });
- connection2 = stream.useChannel('main');
- } else if (props.src === 'local') {
- connection = stream.useChannel('localTimeline', {
- withRenotes: props.withRenotes,
- withReplies: props.withReplies,
- withFiles: props.onlyFiles ? true : undefined,
- });
- } else if (props.src === 'social') {
- connection = stream.useChannel('hybridTimeline', {
- withRenotes: props.withRenotes,
- withReplies: props.withReplies,
- withFiles: props.onlyFiles ? true : undefined,
- });
- } else if (props.src === 'global') {
- connection = stream.useChannel('globalTimeline', {
- withRenotes: props.withRenotes,
- withFiles: props.onlyFiles ? true : undefined,
- });
- } else if (props.src === 'mentions') {
- connection = stream.useChannel('main');
- connection.on('mention', prepend);
- } else if (props.src === 'directs') {
- const onNote = note => {
- if (note.visibility === 'specified') {
- prepend(note);
- }
- };
- connection = stream.useChannel('main');
- connection.on('mention', onNote);
- } else if (props.src === 'list') {
- if (props.list == null) return;
- connection = stream.useChannel('userList', {
- withRenotes: props.withRenotes,
- withFiles: props.onlyFiles ? true : undefined,
- listId: props.list,
- });
- } else if (props.src === 'channel') {
- if (props.channel == null) return;
- connection = stream.useChannel('channel', {
- channelId: props.channel,
- });
- } else if (props.src === 'role') {
- if (props.role == null) return;
- connection = stream.useChannel('roleTimeline', {
- roleId: props.role,
- });
- }
- if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend);
-}
-
-function disconnectChannel() {
- if (connection) connection.dispose();
- if (connection2) connection2.dispose();
-}
-
-function updatePaginationQuery() {
- let endpoint: keyof Misskey.Endpoints | null;
- let query: TimelineQueryType | null;
-
- if (props.src === 'antenna') {
- endpoint = 'antennas/notes';
- query = {
- antennaId: props.antenna,
- };
- } else if (props.src === 'home') {
- endpoint = 'notes/timeline';
- query = {
- withRenotes: props.withRenotes,
- withFiles: props.onlyFiles ? true : undefined,
- };
- } else if (props.src === 'local') {
- endpoint = 'notes/local-timeline';
- query = {
- withRenotes: props.withRenotes,
- withReplies: props.withReplies,
- withFiles: props.onlyFiles ? true : undefined,
- };
- } else if (props.src === 'social') {
- endpoint = 'notes/hybrid-timeline';
- query = {
- withRenotes: props.withRenotes,
- withReplies: props.withReplies,
- withFiles: props.onlyFiles ? true : undefined,
- };
- } else if (props.src === 'global') {
- endpoint = 'notes/global-timeline';
- query = {
- withRenotes: props.withRenotes,
- withFiles: props.onlyFiles ? true : undefined,
- };
- } else if (props.src === 'mentions') {
- endpoint = 'notes/mentions';
- query = null;
- } else if (props.src === 'directs') {
- endpoint = 'notes/mentions';
- query = {
- visibility: 'specified',
- };
- } else if (props.src === 'list') {
- endpoint = 'notes/user-list-timeline';
- query = {
- withRenotes: props.withRenotes,
- withFiles: props.onlyFiles ? true : undefined,
- listId: props.list,
- };
- } else if (props.src === 'channel') {
- endpoint = 'channels/timeline';
- query = {
- channelId: props.channel,
- };
- } else if (props.src === 'role') {
- endpoint = 'roles/notes';
- query = {
- roleId: props.role,
- };
- } else {
- endpoint = null;
- query = null;
- }
-
- if (endpoint && query) {
- paginationQuery = {
- endpoint: endpoint,
- limit: 10,
- params: query,
- };
- } else {
- paginationQuery = null;
- }
-}
-
-function refreshEndpointAndChannel() {
- if (!prefer.s.disableStreamingTimeline) {
- disconnectChannel();
- connectChannel();
- }
-
- updatePaginationQuery();
-}
-
-// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる
-// IDが切り替わったら切り替え先のTLを表示させたい
-watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
-
-// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK
-watch(() => props.withSensitive, reloadTimeline);
-
-// 初回表示用
-refreshEndpointAndChannel();
-
-onUnmounted(() => {
- disconnectChannel();
-});
-
-function reloadTimeline() {
- return new Promise<void>((res) => {
- if (pagingComponent.value == null) return;
-
- tlNotesCount = 0;
-
- pagingComponent.value.reload().then(() => {
- res();
- });
- });
-}
-
-defineExpose({
- reloadTimeline,
-});
-</script>
-
-<style lang="scss" module>
-.transition_x_move {
- transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
-}
-
-.transition_x_enterActive {
- transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
-
- &.note,
- .note {
- /* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
- content-visibility: visible !important;
- }
-}
-
-.transition_x_leaveActive {
- transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
-}
-
-.transition_x_enterFrom {
- opacity: 0;
- transform: translateY(max(-64px, -100%));
-}
-
-@supports (interpolate-size: allow-keywords) {
- .transition_x_leaveTo {
- interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
- height: 0;
- }
-}
-
-.transition_x_leaveTo {
- opacity: 0;
-}
-
-.reverse {
- display: flex;
- flex-direction: column-reverse;
-}
-
-.root {
- container-type: inline-size;
-
- &.noGap {
- background: var(--MI_THEME-panel);
-
- .note {
- border-bottom: solid 0.5px var(--MI_THEME-divider);
- }
-
- .ad {
- padding: 8px;
- background-size: auto auto;
- background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
- border-bottom: solid 0.5px var(--MI_THEME-divider);
- }
- }
-
- &:not(.noGap) {
- background: var(--MI_THEME-bg);
-
- .note {
- background: var(--MI_THEME-panel);
- border-radius: var(--MI-radius);
- }
- }
-}
-
-.ad:empty {
- display: none;
-}
-</style>
diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue
index 59e1b096ae..95f53e7635 100644
--- a/packages/frontend/src/components/MkTutorialDialog.Note.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue
@@ -76,8 +76,6 @@ const onceReacted = ref<boolean>(false);
function addReaction(emoji) {
onceReacted.value = true;
emit('reacted');
- exampleNote.reactions[emoji] = 1;
- exampleNote.myReaction = emoji;
doNotification(emoji);
}
diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue
index aaefa5036a..8ec48dcc3f 100644
--- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue
+++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue
@@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
+import { ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
@@ -74,7 +74,7 @@ const emit = defineEmits<{
(ev: 'closed'): void
}>();
-const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null);
+const dialog = useTemplateRef('dialog');
const title = ref(props.announcement ? props.announcement.title : '');
const text = ref(props.announcement ? props.announcement.text : '');
const icon = ref(props.announcement ? props.announcement.icon : 'info');
diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue
index 90087cb000..03ffd7e470 100644
--- a/packages/frontend/src/components/MkUserList.vue
+++ b/packages/frontend/src/components/MkUserList.vue
@@ -16,13 +16,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import type { Paging } from '@/components/MkPagination.vue';
+import type { PagingCtx } from '@/use/use-pagination.js';
import MkUserInfo from '@/components/MkUserInfo.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
- pagination: Paging;
+ pagination: PagingCtx;
noGap?: boolean;
extractor?: (item: any) => any;
}>(), {
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
index 67a06c70db..1c1247e3e8 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
@@ -39,15 +39,15 @@ import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import XUser from '@/components/MkUserSetupDialog.User.vue';
import MkPagination from '@/components/MkPagination.vue';
-import type { Paging } from '@/components/MkPagination.vue';
+import type { PagingCtx } from '@/use/use-pagination.js';
-const pinnedUsers: Paging = {
+const pinnedUsers: PagingCtx = {
endpoint: 'pinned-users',
noPaging: true,
limit: 10,
};
-const popularUsers: Paging = {
+const popularUsers: PagingCtx = {
endpoint: 'users',
limit: 10,
noPaging: true,
diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue
index 1a4d14a3f0..a809e9040d 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.vue
@@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]">
<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div>
<div :class="$style.tlBody">
- <MkTimeline src="local"/>
+ <MkStreamingNotesTimeline src="local"/>
</div>
</div>
<div :class="$style.panel">
@@ -58,7 +58,7 @@ import * as Misskey from 'misskey-js';
import XSigninDialog from '@/components/MkSigninDialog.vue';
import XSignupDialog from '@/components/MkSignupDialog.vue';
import MkButton from '@/components/MkButton.vue';
-import MkTimeline from '@/components/MkTimeline.vue';
+import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import MkInfo from '@/components/MkInfo.vue';
import { instanceName } from '@@/js/config.js';
import * as os from '@/os.js';
diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue
index f80f037285..a175485a7e 100644
--- a/packages/frontend/src/components/grid/MkGrid.vue
+++ b/packages/frontend/src/components/grid/MkGrid.vue
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
-import { computed, onMounted, ref, toRefs, watch } from 'vue';
+import { computed, onMounted, ref, toRefs, useTemplateRef, watch } from 'vue';
import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js';
import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
import type { GridContext, GridEvent } from '@/components/grid/grid-event.js';
@@ -130,7 +130,7 @@ const bus = new GridEventEmitter();
*/
const resizeObserver = new ResizeObserver((entries) => window.setTimeout(() => onResize(entries)));
-const rootEl = ref<InstanceType<typeof HTMLTableElement>>();
+const rootEl = useTemplateRef('rootEl');
/**
* グリッドの最も上位にある状態。
*/
diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue
index 69a68b6f2c..e1faed904a 100644
--- a/packages/frontend/src/components/grid/MkHeaderCell.vue
+++ b/packages/frontend/src/components/grid/MkHeaderCell.vue
@@ -31,10 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
-import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue';
-import { GridEventEmitter } from '@/components/grid/grid.js';
+import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, useTemplateRef, watch } from 'vue';
import type { Size } from '@/components/grid/grid.js';
import type { GridColumn } from '@/components/grid/column.js';
+import { GridEventEmitter } from '@/components/grid/grid.js';
const emit = defineEmits<{
(ev: 'operation:beginWidthChange', sender: GridColumn): void;
@@ -50,8 +50,8 @@ const props = defineProps<{
const { column, bus } = toRefs(props);
-const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>();
-const contentEl = ref<InstanceType<typeof HTMLDivElement>>();
+const rootEl = useTemplateRef('rootEl');
+const contentEl = useTemplateRef('contentEl');
const resizing = ref<boolean>(false);