This commit is contained in:
syuilo 2025-05-04 12:57:51 +00:00 committed by GitHub
commit 96388989cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
93 changed files with 2184 additions and 1857 deletions

36
locales/index.d.ts vendored
View File

@ -2322,6 +2322,10 @@ export interface Locale extends ILocale {
*
*/
"newNoteRecived": string;
/**
*
*/
"newNote": string;
/**
*
*/
@ -4970,10 +4974,6 @@ export interface Locale extends ILocale {
*
*/
"pullDownToRefresh": string;
/**
*
*/
"disableStreamingTimeline": string;
/**
*
*/
@ -5417,6 +5417,18 @@ export interface Locale extends ILocale {
*
*/
"scrollToClose": string;
/**
*
*/
"realtimeMode": string;
/**
*
*/
"turnItOn": string;
/**
*
*/
"turnItOff": string;
"_chat": {
/**
*
@ -5721,6 +5733,22 @@ export interface Locale extends ILocale {
*
*/
"enablePullToRefresh_description": string;
/**
*
*/
"realtimeMode_description": string;
/**
*
*/
"contentsUpdateFrequency": string;
/**
*
*/
"contentsUpdateFrequency_description": string;
/**
*
*/
"contentsUpdateFrequency_description2": string;
"_chat": {
/**
*

View File

@ -576,6 +576,7 @@ showFixedPostForm: "タイムライン上部に投稿フォームを表示する
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
withRepliesByDefaultForNewlyFollowed: "フォローする際、デフォルトで返信をTLに含むようにする"
newNoteRecived: "新しいノートがあります"
newNote: "新しいノート"
sounds: "サウンド"
sound: "サウンド"
listen: "聴く"
@ -1238,7 +1239,6 @@ showAvatarDecorations: "アイコンのデコレーションを表示"
releaseToRefresh: "離してリロード"
refreshing: "リロード中"
pullDownToRefresh: "引っ張ってリロード"
disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする"
useGroupedNotifications: "通知をグルーピング"
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
@ -1349,6 +1349,9 @@ goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ"
driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。<br>\nートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。<br>\n<b>ファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。</b><br>\nフォルダを作って整理することもできます。"
scrollToClose: "スクロールして閉じる"
realtimeMode: "リアルタイムモード"
turnItOn: "オンにする"
turnItOff: "オフにする"
_chat:
noMessagesYet: "まだメッセージはありません"
@ -1430,6 +1433,10 @@ _settings:
enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期"
enablePullToRefresh: "ひっぱって更新"
enablePullToRefresh_description: "マウスでは、ホイールを押し込みながらドラッグします。"
realtimeMode_description: "サーバーと接続を確立し、リアルタイムでコンテンツを更新します。通信量とバッテリーの消費が多くなる場合があります。"
contentsUpdateFrequency: "コンテンツの取得頻度"
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されますが、パフォーマンスが低下し、通信量とバッテリーの消費が多くなります。"
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
_chat:
showSenderName: "送信者の名前を表示"

View File

@ -593,4 +593,42 @@ export class NoteEntityService implements OnModuleInit {
relations: ['user'],
});
}
@bindThis
public async fetchDiffs(noteIds: MiNote['id'][]) {
if (noteIds.length === 0) return [];
const notes = await this.notesRepository.find({
where: {
id: In(noteIds),
},
select: {
id: true,
userHost: true,
reactions: true,
reactionAndUserPairCache: true,
},
});
const bufferedReactionsMap = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(noteIds) : null;
const packings = notes.map(note => {
const bufferedReactions = bufferedReactionsMap?.get(note.id);
//const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/')));
const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.deltas ?? {}));
const reactionEmojiNames = Object.keys(reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
return this.customEmojiService.populateEmojis(reactionEmojiNames, note.userHost).then(reactionEmojis => ({
id: note.id,
reactions,
reactionEmojis,
}));
});
return await Promise.all(packings);
}
}

View File

@ -323,6 +323,7 @@ export * as 'notes/replies' from './endpoints/notes/replies.js';
export * as 'notes/search' from './endpoints/notes/search.js';
export * as 'notes/search-by-tag' from './endpoints/notes/search-by-tag.js';
export * as 'notes/show' from './endpoints/notes/show.js';
export * as 'notes/show-partial-bulk' from './endpoints/notes/show-partial-bulk.js';
export * as 'notes/state' from './endpoints/notes/state.js';
export * as 'notes/thread-muting/create' from './endpoints/notes/thread-muting/create.js';
export * as 'notes/thread-muting/delete' from './endpoints/notes/thread-muting/delete.js';

View File

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteIds: { type: 'array', items: { type: 'string', format: 'misskey:id' }, maxItems: 100, minItems: 1 },
},
required: ['noteIds'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private noteEntityService: NoteEntityService,
) {
super(meta, paramDef, async (ps, me) => {
return await this.noteEntityService.fetchDiffs(ps.noteIds);
});
}
}

View File

@ -79,39 +79,6 @@ export async function mainBoot() {
}
}
const stream = useStream();
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
window.location.reload();
}
}
});
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
launchPlugins();
try {
@ -169,8 +136,6 @@ export async function mainBoot() {
}
}
stream.on('announcementCreated', onAnnouncementCreated);
if ($i.isDeleted) {
alert({
type: 'warning',
@ -348,50 +313,81 @@ export async function mainBoot() {
}
}
const main = markRaw(stream.useChannel('main', null, 'System'));
if (store.s.realtimeMode) {
const stream = useStream();
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateCurrentAccountPartial(i);
});
main.on('readAllNotifications', () => {
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
window.location.reload();
}
}
});
});
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
});
main.on('unreadAntenna', () => {
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
stream.on('announcementCreated', onAnnouncementCreated);
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
const main = markRaw(stream.useChannel('main', null, 'System'));
// トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => {
signout();
});
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateCurrentAccountPartial(i);
});
main.on('readAllNotifications', () => {
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
});
});
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
});
});
main.on('unreadAntenna', () => {
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
}
}
// shortcut

View File

@ -19,14 +19,14 @@ 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';
import { infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{
pagination: Paging;
pagination: PagingCtx;
noGap?: boolean;
extractor?: (item: any) => any;
}>(), {

View File

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

View File

@ -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('');

View File

@ -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 FunctionLint
@ -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) {

View File

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

View File

@ -4,7 +4,7 @@ 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>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</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">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
@ -30,31 +30,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 { infoImageUrl } from '@/instance.js';
import { globalEvents, useGlobalEvent } from '@/events.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;

View File

@ -1,148 +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>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.noNotifications }}</div>
</div>
</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 { infoImageUrl } from '@/instance.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; // heighttransition
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>

View File

@ -4,489 +4,81 @@ 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">
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
<div v-else-if="paginator.items.value.length === 0" key="_empty_">
<slot name="empty">
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</slot>
</div>
<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>
</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>
<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>
</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 { onMounted } from 'vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import { infoImageUrl } from '@/instance.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);
});
const paginator = usePagination({
ctx: props.pagination,
});
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;
});
function appearFetchMoreAhead() {
paginator.fetchNewer();
}
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;
});
};
/**
* AppearIntersectionObserverによって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();
}
}
}
});
/**
* 最新のものとして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 appearFetchMore() {
paginator.fetchOlder();
}
/**
* 新着アイテムを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;
}
/**
* 古いアイテムを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);
// scrollToBottommoreFetching
// 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,55 +5,73 @@ 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>
<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">
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.noNotes }}</div>
</div>
</template>
</slot>
</div>
<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 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, { [$style.noGap]: noGap, '_gaps': !noGap }]"
: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="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>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
</template>
</MkPagination>
</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 } from 'vue';
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 { Paging } from '@/components/MkPagination.vue';
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 MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
const props = withDefaults(defineProps<{
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@ -73,15 +91,40 @@ const props = withDefaults(defineProps<{
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'));
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)!;
scrollContainer.addEventListener('scroll', onScrollContainerScroll, { passive: true }); // scrollendios
}
}, { immediate: true });
onUnmounted(() => {
if (scrollContainer) {
scrollContainer.removeEventListener('scroll', onScrollContainerScroll);
}
});
type TimelineQueryType = {
antennaId?: string,
withRenotes?: boolean,
@ -93,22 +136,54 @@ type TimelineQueryType = {
roleId?: string
};
const pagingComponent = useTemplateRef('pagingComponent');
let adInsertionCounter = 0;
let tlNotesCount = 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;
function prepend(note) {
if (pagingComponent.value == null) return;
if (!store.s.realtimeMode) {
// TODO: 1TL
useInterval(async () => {
paginator.fetchNewer({
toQueue: !isTop(),
});
}, POLLING_INTERVAL, {
immediate: false,
afterMounted: true,
});
tlNotesCount++;
useGlobalEvent('notePosted', (note) => {
paginator.fetchNewer({
toQueue: !isTop(),
});
});
}
if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) {
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;
}
pagingComponent.value.prepend(note);
emit('note');
if (isTop()) {
paginator.prepend(note);
} else {
paginator.enqueue(note);
}
if (props.sound) {
sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
@ -117,10 +192,10 @@ function prepend(note) {
let connection: Misskey.ChannelConnection | null = null;
let connection2: Misskey.ChannelConnection | null = null;
let paginationQuery: Paging | null = null;
let paginationQuery: PagingCtx;
const noGap = !prefer.s.showGapBetweenNotesInTimeline;
const stream = useStream();
const stream = store.s.realtimeMode ? useStream() : null;
function connectChannel() {
if (props.src === 'antenna') {
@ -249,23 +324,18 @@ function updatePaginationQuery() {
roleId: props.role,
};
} else {
endpoint = null;
query = null;
throw new Error('Unrecognized timeline type: ' + props.src);
}
if (endpoint && query) {
paginationQuery = {
endpoint: endpoint,
limit: 10,
params: query,
};
} else {
paginationQuery = null;
}
paginationQuery = {
endpoint: endpoint,
limit: 10,
params: query,
};
}
function refreshEndpointAndChannel() {
if (!prefer.s.disableStreamingTimeline) {
if (store.s.realtimeMode) {
disconnectChannel();
connectChannel();
}
@ -283,17 +353,20 @@ watch(() => props.withSensitive, reloadTimeline);
//
refreshEndpointAndChannel();
const paginator = usePagination({
ctx: paginationQuery,
useShallowRef: true,
});
onUnmounted(() => {
disconnectChannel();
});
function reloadTimeline() {
return new Promise<void>((res) => {
if (pagingComponent.value == null) return;
adInsertionCounter = 0;
tlNotesCount = 0;
pagingComponent.value.reload().then(() => {
paginator.reload().then(() => {
res();
});
});
@ -339,18 +412,13 @@ defineExpose({
opacity: 0;
}
.reverse {
display: flex;
flex-direction: column-reverse;
}
.root {
.notes {
container-type: inline-size;
&.noGap {
background: var(--MI_THEME-panel);
.note {
.note:not(:last-child) {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
@ -372,7 +440,90 @@ defineExpose({
}
}
.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));
}
}
.ad:empty {
display: none;
}
.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>

View File

@ -0,0 +1,187 @@
<!--
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">
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.noNotifications }}</div>
</div>
</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"
>
<template v-for="(notification, i) in paginator.items.value" :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>
<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 { infoImageUrl } from '@/instance.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import { usePagination } from '@/use/use-pagination.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);
&.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; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notifications {
container-type: inline-size;
background: var(--MI_THEME-panel);
}
.item:not(:last-child) {
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>

View File

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

View File

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

View File

@ -21,14 +21,14 @@ 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';
import { infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{
pagination: Paging;
pagination: PagingCtx;
noGap?: boolean;
extractor?: (item: any) => any;
}>(), {

View File

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

View File

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

View File

@ -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');
/**
* グリッドの最も上位にある状態
*/

View File

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

View File

@ -5,9 +5,24 @@
import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import { onBeforeUnmount } from 'vue';
export const globalEvents = new EventEmitter<{
type Events = {
themeChanging: () => void;
themeChanged: () => void;
clientNotification: (notification: Misskey.entities.Notification) => void;
}>();
notePosted: (note: Misskey.entities.Note) => void;
noteDeleted: (noteId: Misskey.entities.Note['id']) => void;
};
export const globalEvents = new EventEmitter<Events>();
export function useGlobalEvent<T extends keyof Events>(
event: T,
callback: Events[T],
): void {
globalEvents.on(event, callback);
onBeforeUnmount(() => {
globalEvents.off(event, callback);
});
}

View File

@ -12,7 +12,6 @@ import { $i } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { get, set } from '@/utility/idb-proxy.js';
import { store } from '@/store.js';
import { useStream } from '@/stream.js';
import { deepClone } from '@/utility/clone.js';
import { deepMerge } from '@/utility/merge.js';
@ -129,25 +128,6 @@ export class Pizzax<T extends StateDef> {
if (where === 'deviceAccount' && !($i && userId !== $i.id)) return;
this.r[key].value = this.s[key] = value;
});
if ($i) {
const connection = useStream().useChannel('main');
// streamingのuser storage updateイベントを監視して更新
connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => {
if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return;
this.r[key].value = this.s[key] = value;
this.addIdbSetJob(async () => {
const cache = await get(this.registryCacheKeyName);
if (cache[key] !== value) {
cache[key] = value;
await set(this.registryCacheKeyName, cache);
}
});
});
}
}
private load(): Promise<void> {

View File

@ -55,7 +55,7 @@ import { computed, ref } from 'vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPagination from '@/components/MkPagination.vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
import FormSplit from '@/components/form/split.vue';
import { i18n } from '@/i18n.js';
@ -81,7 +81,7 @@ const pagination = {
state.value === 'notResponding' ? { notResponding: true } :
{}),
})),
} as Paging;
} as PagingCtx;
function getStatus(instance) {
if (instance.isSuspended) return 'Suspended';

View File

@ -87,7 +87,7 @@ const pagination = {
};
function resolved(reportId) {
reports.value?.removeItem(reportId);
reports.value?.paginator.removeItem(reportId);
}
function closeTutorial() {

View File

@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, useTemplateRef } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -73,7 +73,7 @@ const pagingComponent = useTemplateRef('pagingComponent');
const type = ref('all');
const sort = ref('+createdAt');
const pagination: Paging = {
const pagination: PagingCtx = {
endpoint: 'admin/invite/list' as const,
limit: 10,
params: computed(() => ({
@ -100,12 +100,12 @@ async function createWithOptions() {
text: tickets.map(x => x.code).join('\n'),
});
tickets.forEach(ticket => pagingComponent.value?.prepend(ticket));
tickets.forEach(ticket => pagingComponent.value?.paginator.prepend(ticket));
}
function deleted(id: string) {
if (pagingComponent.value) {
pagingComponent.value.items.delete(id);
pagingComponent.value.paginator.removeItem(id);
}
}

View File

@ -121,7 +121,7 @@ async function addUser() {
username: username,
password: password,
}).then(res => {
paginationComponent.value?.reload();
paginationComponent.value?.paginator.reload();
});
}

View File

@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, useTemplateRef } from 'vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
@ -71,7 +71,7 @@ const paginationPast = {
},
};
const paginationEl = ref<InstanceType<typeof MkPagination>>();
const paginationEl = useTemplateRef('paginationEl');
const tab = ref('current');
@ -86,10 +86,10 @@ async function read(target) {
}
if (!paginationEl.value) return;
paginationEl.value.updateItem(target.id, a => {
a.isRead = true;
return a;
});
paginationEl.value.paginator.updateItem(target.id, a => ({
...a,
isRead: true,
}));
misskeyApi('i/read-announcement', { announcementId: target.id });
updateCurrentAccountPartial({
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),

View File

@ -6,17 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div ref="rootEl">
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<div :class="$style.tl">
<MkTimeline
ref="tlEl" :key="antennaId"
src="antenna"
:antenna="antennaId"
:sound="true"
@queue="queueUpdated"
/>
</div>
<div :class="$style.tl">
<MkStreamingNotesTimeline
ref="tlEl" :key="antennaId"
src="antenna"
:antenna="antennaId"
:sound="true"
/>
</div>
</div>
</PageWithHeader>
@ -25,8 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { scrollInContainer } from '@@/js/scroll.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
@ -40,18 +35,8 @@ const props = defineProps<{
}>();
const antenna = ref<Misskey.entities.Antenna | null>(null);
const queue = ref(0);
const rootEl = useTemplateRef('rootEl');
const tlEl = useTemplateRef('tlEl');
function queueUpdated(q) {
queue.value = q;
}
function top() {
scrollInContainer(rootEl.value, { top: 0 });
}
async function timetravel() {
const { canceled, result: date } = await os.inputDate({
title: i18n.ts.date,
@ -94,25 +79,6 @@ definePage(() => ({
</script>
<style lang="scss" module>
.new {
position: sticky;
top: calc(var(--MI-stickyTop, 0px) + 16px);
z-index: 1000;
width: 100%;
margin: calc(-0.675em - 8px) 0;
&:first-child {
margin-top: calc(-0.675em - 8px - var(--MI-margin));
}
}
.newButton {
display: block;
margin: var(--MI-margin) auto 0 auto;
padding: 8px 16px;
border-radius: 32px;
}
.tl {
background: var(--MI_THEME-bg);
border-radius: var(--MI-radius);

View File

@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import { computed, watch, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkButton from '@/components/MkButton.vue';
@ -86,7 +86,7 @@ const emit = defineEmits<{
(ev: 'closed'): void
}>();
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const windowEl = useTemplateRef('windowEl');
const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : '');
const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : '');
const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : '');

View File

@ -37,10 +37,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i && prefer.r.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
<MkStreamingNotesTimeline :key="channelId" src="channel" :channel="channelId"/>
</div>
<div v-else-if="tab === 'featured'">
<MkNotes :pagination="featuredPagination"/>
<MkNotesTimeline :pagination="featuredPagination"/>
</div>
<div v-else-if="tab === 'search'">
<div v-if="notesSearchAvailable" class="_gaps">
@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton>
</div>
<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
<MkNotesTimeline v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
</div>
<div v-else>
<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
@ -73,9 +73,10 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import type { PageHeaderItem } from '@/types/page-header.js';
import MkPostForm from '@/components/MkPostForm.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -83,7 +84,7 @@ import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { deviceKind } from '@/utility/device-kind.js';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import { favoritedChannelsCache } from '@/cache.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -118,6 +119,14 @@ const featuredPagination = computed(() => ({
},
}));
useInterval(() => {
if (channel.value == null) return;
miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.value.id}`, Date.now());
}, 3000, {
immediate: true,
afterMounted: true,
});
watch(() => props.channelId, async () => {
channel.value = await misskeyApi('channels/show', {
channelId: props.channelId,

View File

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<MkNotes :pagination="pagination" :detail="true"/>
<MkNotesTimeline :pagination="pagination" :detail="true"/>
</div>
</div>
</PageWithHeader>
@ -34,7 +34,7 @@ import { computed, watch, provide, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';

View File

@ -115,7 +115,7 @@ const selectAll = () => {
if (selectedEmojis.value.length > 0) {
selectedEmojis.value = [];
} else {
selectedEmojis.value = Array.from(emojisPaginationComponent.value?.items.values(), item => item.id);
selectedEmojis.value = emojisPaginationComponent.value?.paginator.items.value.map(item => item.id);
}
};
@ -132,7 +132,7 @@ const add = async (ev: MouseEvent) => {
}, {
done: result => {
if (result.created) {
emojisPaginationComponent.value?.prepend(result.created);
emojisPaginationComponent.value?.paginator.prepend(result.created);
}
},
closed: () => dispose(),
@ -145,12 +145,12 @@ const edit = (emoji) => {
}, {
done: result => {
if (result.updated) {
emojisPaginationComponent.value?.updateItem(result.updated.id, (oldEmoji) => ({
emojisPaginationComponent.value?.paginator.updateItem(result.updated.id, (oldEmoji) => ({
...oldEmoji,
...result.updated,
}));
} else if (result.deleted) {
emojisPaginationComponent.value?.removeItem(emoji.id);
emojisPaginationComponent.value?.paginator.removeItem(emoji.id);
}
},
closed: () => dispose(),
@ -242,7 +242,7 @@ const setCategoryBulk = async () => {
ids: selectedEmojis.value,
category: result,
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const setLicenseBulk = async () => {
@ -254,7 +254,7 @@ const setLicenseBulk = async () => {
ids: selectedEmojis.value,
license: result,
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const addTagBulk = async () => {
@ -266,7 +266,7 @@ const addTagBulk = async () => {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const removeTagBulk = async () => {
@ -278,7 +278,7 @@ const removeTagBulk = async () => {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const setTagBulk = async () => {
@ -290,7 +290,7 @@ const setTagBulk = async () => {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const delBulk = async () => {
@ -302,7 +302,7 @@ const delBulk = async () => {
await os.apiWithDialog('admin/emoji/delete-bulk', {
ids: selectedEmojis.value,
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const headerActions = computed(() => [{

View File

@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps">
<MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo>
<MkNotes ref="tlComponent" :pagination="pagination"/>
<MkNotesTimeline ref="tlComponent" :pagination="pagination"/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { i18n } from '@/i18n.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkInfo from '@/components/MkInfo.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
const props = defineProps<{
fileId: string;
@ -23,7 +23,7 @@ const props = defineProps<{
const realFileId = computed(() => props.fileId);
const pagination = ref<Paging>({
const pagination = ref<PagingCtx>({
endpoint: 'drive/files/attached-notes',
limit: 10,
params: {

View File

@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import { computed, watch, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkButton from '@/components/MkButton.vue';
@ -103,7 +103,7 @@ const emit = defineEmits<{
(ev: 'closed'): void
}>();
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const windowEl = useTemplateRef('windowEl');
const name = ref<string>(props.emoji ? props.emoji.name : '');
const category = ref<string>(props.emoji?.category ? props.emoji.category : '');
const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : '');

View File

@ -9,14 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="notes">{{ i18n.ts.notes }}</option>
<option value="polls">{{ i18n.ts.poll }}</option>
</MkTab>
<MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
<MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
<MkNotesTimeline v-if="tab === 'notes'" :pagination="paginationForNotes"/>
<MkNotesTimeline v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import MkTab from '@/components/MkTab.vue';
import { i18n } from '@/i18n.js';

View File

@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { useTemplateRef, computed, ref } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import { userPage, acct } from '@/filters/user.js';
@ -53,7 +53,7 @@ import { $i } from '@/i.js';
const paginationComponent = useTemplateRef('paginationComponent');
const pagination = computed<Paging>(() => tab.value === 'list' ? {
const pagination = computed<PagingCtx>(() => tab.value === 'list' ? {
endpoint: 'following/requests/list',
limit: 10,
} : {
@ -63,19 +63,19 @@ const pagination = computed<Paging>(() => tab.value === 'list' ? {
function accept(user: Misskey.entities.UserLite) {
os.apiWithDialog('following/requests/accept', { userId: user.id }).then(() => {
paginationComponent.value?.reload();
paginationComponent.value?.paginator.reload();
});
}
function reject(user: Misskey.entities.UserLite) {
os.apiWithDialog('following/requests/reject', { userId: user.id }).then(() => {
paginationComponent.value?.reload();
paginationComponent.value?.paginator.reload();
});
}
function cancel(user: Misskey.entities.UserLite) {
os.apiWithDialog('following/requests/cancel', { userId: user.id }).then(() => {
paginationComponent.value?.reload();
paginationComponent.value?.paginator.reload();
});
}

View File

@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import type { ChartSrc } from '@/components/MkChart.vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkChart from '@/components/MkChart.vue';
import MkObjectView from '@/components/MkObjectView.vue';
import FormLink from '@/components/form/link.vue';
@ -180,7 +180,7 @@ const usersPagination = {
hostname: props.host,
},
offsetMode: true,
} satisfies Paging;
} satisfies PagingCtx;
if (iAmModerator) {
watch(moderationNote, async () => {

View File

@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -51,7 +51,7 @@ const currentInviteLimit = ref<null | number>(null);
const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number;
const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number;
const pagination: Paging = {
const pagination: PagingCtx = {
endpoint: 'invite/list' as const,
limit: 10,
};
@ -74,13 +74,13 @@ async function create() {
text: ticket.code,
});
pagingComponent.value?.prepend(ticket);
pagingComponent.value?.paginator.prepend(ticket);
update();
}
function deleted(id: string) {
if (pagingComponent.value) {
pagingComponent.value.items.delete(id);
pagingComponent.value.paginator.removeItem(id);
}
update();
}

View File

@ -73,15 +73,15 @@ async function create() {
clipsCache.delete();
pagingComponent.value?.reload();
pagingComponent.value?.paginator.reload();
}
function onClipCreated() {
pagingComponent.value?.reload();
pagingComponent.value?.paginator.reload();
}
function onClipDeleted() {
pagingComponent.value?.reload();
pagingComponent.value?.paginator.reload();
}
const headerActions = computed(() => []);

View File

@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
@ -80,7 +80,7 @@ const props = defineProps<{
listId: string;
}>();
const paginationEl = ref<InstanceType<typeof MkPagination>>();
const paginationEl = useTemplateRef('paginationEl');
const list = ref<Misskey.entities.UserList | null>(null);
const isPublic = ref(false);
const name = ref('');
@ -109,7 +109,7 @@ function addUser() {
listId: list.value.id,
userId: user.id,
}).then(() => {
paginationEl.value?.reload();
paginationEl.value?.paginator.reload();
});
});
}
@ -125,7 +125,7 @@ async function removeUser(item, ev) {
listId: list.value.id,
userId: item.userId,
}).then(() => {
paginationEl.value?.removeItem(item.id);
paginationEl.value?.paginator.removeItem(item.id);
});
},
}], ev.currentTarget ?? ev.target);
@ -147,7 +147,7 @@ async function showMembershipMenu(item, ev) {
userId: item.userId,
withReplies,
}).then(() => {
paginationEl.value!.updateItem(item.id, (old) => ({
paginationEl.value!.paginator.updateItem(item.id, (old) => ({
...old,
withReplies,
}));

View File

@ -6,42 +6,40 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div>
<Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in">
<div v-if="note">
<div v-if="showNext" class="_margin">
<MkNotes class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/>
</div>
<Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in">
<div v-if="note">
<div v-if="showNext" class="_margin">
<MkNotesTimeline :pullToRefresh="false" class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/>
</div>
<div class="_margin">
<div v-if="!showNext" class="_buttons" :class="$style.loadNext">
<MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showNext = 'channel'"><i class="ti ti-chevron-up"></i> <i class="ti ti-device-tv"></i></MkButton>
<MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ti ti-chevron-up"></i> <i class="ti ti-user"></i></MkButton>
</div>
<div class="_margin _gaps_s">
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
<MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/>
</div>
<div v-if="clips && clips.length > 0" class="_margin">
<div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div>
<div class="_gaps">
<MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/>
</div>
</div>
<div v-if="!showPrev" class="_buttons" :class="$style.loadPrev">
<MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showPrev = 'channel'"><i class="ti ti-chevron-down"></i> <i class="ti ti-device-tv"></i></MkButton>
<MkButton rounded :class="$style.loadButton" @click="showPrev = 'user'"><i class="ti ti-chevron-down"></i> <i class="ti ti-user"></i></MkButton>
<div class="_margin">
<div v-if="!showNext" class="_buttons" :class="$style.loadNext">
<MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showNext = 'channel'"><i class="ti ti-chevron-up"></i> <i class="ti ti-device-tv"></i></MkButton>
<MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ti ti-chevron-up"></i> <i class="ti ti-user"></i></MkButton>
</div>
<div class="_margin _gaps_s">
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
<MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note"/>
</div>
<div v-if="clips && clips.length > 0" class="_margin">
<div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div>
<div class="_gaps">
<MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/>
</div>
</div>
<div v-if="showPrev" class="_margin">
<MkNotes class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/>
<div v-if="!showPrev" class="_buttons" :class="$style.loadPrev">
<MkButton v-if="note.channelId" rounded :class="$style.loadButton" @click="showPrev = 'channel'"><i class="ti ti-chevron-down"></i> <i class="ti ti-device-tv"></i></MkButton>
<MkButton rounded :class="$style.loadButton" @click="showPrev = 'user'"><i class="ti ti-chevron-down"></i> <i class="ti ti-user"></i></MkButton>
</div>
</div>
<MkError v-else-if="error" @retry="fetchNote()"/>
<MkLoading v-else/>
</Transition>
</div>
<div v-if="showPrev" class="_margin">
<MkNotesTimeline :pullToRefresh="false" class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/>
</div>
</div>
<MkError v-else-if="error" @retry="fetchNote()"/>
<MkLoading v-else/>
</Transition>
</div>
</PageWithHeader>
</template>
@ -50,9 +48,9 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -80,26 +78,27 @@ const showPrev = ref<'user' | 'channel' | false>(false);
const showNext = ref<'user' | 'channel' | false>(false);
const error = ref();
const prevUserPagination: Paging = {
const prevUserPagination: PagingCtx = {
endpoint: 'users/notes',
limit: 10,
baseId: props.noteId,
direction: 'older',
params: computed(() => note.value ? ({
userId: note.value.userId,
untilId: note.value.id,
}) : undefined),
};
const nextUserPagination: Paging = {
reversed: true,
const nextUserPagination: PagingCtx = {
endpoint: 'users/notes',
limit: 10,
baseId: props.noteId,
direction: 'newer',
params: computed(() => note.value ? ({
userId: note.value.userId,
sinceId: note.value.id,
}) : undefined),
};
const prevChannelPagination: Paging = {
const prevChannelPagination: PagingCtx = {
endpoint: 'channels/timeline',
limit: 10,
params: computed(() => note.value ? ({
@ -108,7 +107,7 @@ const prevChannelPagination: Paging = {
}) : undefined),
};
const nextChannelPagination: Paging = {
const nextChannelPagination: PagingCtx = {
reversed: true,
endpoint: 'channels/timeline',
limit: 10,

View File

@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div v-if="tab === 'all'">
<XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/>
<MkStreamingNotificationsTimeline :class="$style.notifications" :excludeTypes="excludeTypes"/>
</div>
<div v-else-if="tab === 'mentions'">
<MkNotes :pagination="mentionsPagination"/>
<MkNotesTimeline :pagination="mentionsPagination"/>
</div>
<div v-else-if="tab === 'directNotes'">
<MkNotes :pagination="directNotesPagination"/>
<MkNotesTimeline :pagination="directNotesPagination"/>
</div>
</div>
</PageWithHeader>
@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { notificationTypes } from '@@/js/const.js';
import XNotifications from '@/components/MkNotifications.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';

View File

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-else-if="tab === 'timeline'" class="_spacer" style="--MI_SPACER-w: 700px;">
<MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/>
<MkStreamingNotesTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/>
<div v-else-if="!visible" class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
@ -42,7 +42,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import MkUserList from '@/components/MkUserList.vue';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import { serverErrorImageUrl, infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{

View File

@ -105,7 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFoldableSection v-if="notePagination">
<template #header>{{ i18n.ts.searchResult }}</template>
<MkNotes :key="`searchNotes:${key}`" :pagination="notePagination"/>
<MkNotesTimeline :key="`searchNotes:${key}`" :pagination="notePagination"/>
</MkFoldableSection>
</div>
</template>
@ -113,7 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, shallowRef, toRef } from 'vue';
import type * as Misskey from 'misskey-js';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import { $i } from '@/i.js';
import { host as localHost } from '@@/js/config.js';
import { i18n } from '@/i18n.js';
@ -125,7 +125,7 @@ import { useRouter } from '@/router.js';
import MkButton from '@/components/MkButton.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkInput from '@/components/MkInput.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
@ -144,7 +144,7 @@ const props = withDefaults(defineProps<{
const router = useRouter();
const key = ref(0);
const notePagination = ref<Paging<'notes/search'>>();
const notePagination = ref<PagingCtx<'notes/search'>>();
const searchQuery = ref(toRef(props, 'query').value);
const hostInput = ref(toRef(props, 'host').value);

View File

@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, toRef } from 'vue';
import type { Endpoints } from 'misskey-js';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkUserList from '@/components/MkUserList.vue';
import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
@ -50,7 +50,7 @@ const props = withDefaults(defineProps<{
const router = useRouter();
const key = ref(0);
const userPagination = ref<Paging<'users/search'>>();
const userPagination = ref<PagingCtx<'users/search'>>();
const searchQuery = ref(toRef(props, 'query').value);
const searchOrigin = ref(toRef(props, 'origin').value);

View File

@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import FormPagination from '@/components/MkPagination.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -65,7 +65,7 @@ import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import { infoImageUrl } from '@/instance.js';
const list = ref<InstanceType<typeof FormPagination>>();
const list = useTemplateRef('list');
const pagination = {
endpoint: 'i/apps' as const,
@ -78,7 +78,7 @@ const pagination = {
function revoke(token) {
misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => {
list.value?.reload();
list.value?.paginator.reload();
});
}

View File

@ -41,6 +41,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRadios>
</SearchMarker>
<SearchMarker :keywords="['realtimemode']">
<MkSwitch v-model="realtimeMode">
<template #label><i class="ti ti-bolt"></i> <SearchLabel>{{ i18n.ts.realtimeMode }}</SearchLabel></template>
<template #caption><SearchKeyword>{{ i18n.ts._settings.realtimeMode_description }}</SearchKeyword></template>
</MkSwitch>
</SearchMarker>
<MkDisableSection :disabled="realtimeMode">
<SearchMarker :keywords="['polling', 'interval']">
<MkPreferenceContainer k="pollingInterval">
<MkRange v-model="pollingInterval" :min="1" :max="3" :step="1" easing :showTicks="true" :textConverter="(v) => v === 1 ? i18n.ts.low : v === 2 ? i18n.ts.middle : v === 3 ? i18n.ts.high : ''">
<template #label><SearchLabel>{{ i18n.ts._settings.contentsUpdateFrequency }}</SearchLabel></template>
<template #caption><SearchKeyword>{{ i18n.ts._settings.contentsUpdateFrequency_description }}</SearchKeyword><br><SearchKeyword>{{ i18n.ts._settings.contentsUpdateFrequency_description2 }}</SearchKeyword></template>
<template #prefix><i class="ti ti-player-play"></i></template>
<template #suffix><i class="ti ti-player-track-next"></i></template>
</MkRange>
</MkPreferenceContainer>
</SearchMarker>
</MkDisableSection>
<div class="_gaps_s">
<SearchMarker :keywords="['titlebar', 'show']">
<MkPreferenceContainer k="showTitlebar">
@ -156,14 +176,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['disable', 'streaming', 'timeline']">
<MkPreferenceContainer k="disableStreamingTimeline">
<MkSwitch v-model="disableStreamingTimeline">
<template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['pinned', 'list']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template>
@ -734,7 +746,7 @@ import MkRadios from '@/components/MkRadios.vue';
import MkRange from '@/components/MkRange.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkDisableSection from '@/components/MkDisableSection.vue';
import FormLink from '@/components/form/link.vue';
import MkLink from '@/components/MkLink.vue';
import MkInfo from '@/components/MkInfo.vue';
@ -757,8 +769,10 @@ const $i = ensureSignin();
const lang = ref(miLocalStorage.getItem('lang'));
const dataSaver = ref(prefer.s.dataSaver);
const realtimeMode = computed(store.makeGetterSetter('realtimeMode'));
const overridedDeviceKind = prefer.model('overridedDeviceKind');
const pollingInterval = prefer.model('pollingInterval');
const showTitlebar = prefer.model('showTitlebar');
const keepCw = prefer.model('keepCw');
const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior');
@ -777,7 +791,6 @@ const showFixedPostFormInChannel = prefer.model('showFixedPostFormInChannel');
const numberOfPageCache = prefer.model('numberOfPageCache');
const enableInfiniteScroll = prefer.model('enableInfiniteScroll');
const useReactionPickerForContextMenu = prefer.model('useReactionPickerForContextMenu');
const disableStreamingTimeline = prefer.model('disableStreamingTimeline');
const useGroupedNotifications = prefer.model('useGroupedNotifications');
const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow');
const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia');
@ -843,10 +856,11 @@ watch(useSystemFont, () => {
watch([
hemisphere,
lang,
realtimeMode,
pollingInterval,
enableInfiniteScroll,
showNoteActionsOnlyHover,
overridedDeviceKind,
disableStreamingTimeline,
alwaysConfirmFollow,
confirmWhenRevealingSensitiveMedia,
showGapBetweenNotesInTimeline,

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<MkNotes ref="notes" class="" :pagination="pagination"/>
<MkNotesTimeline ref="tlComponent" class="" :pagination="pagination"/>
</div>
<template v-if="$i" #footer>
<div :class="$style.footer">
@ -19,8 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
import { computed, ref, useTemplateRef } from 'vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import MkButton from '@/components/MkButton.vue';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
@ -40,7 +40,8 @@ const pagination = {
tag: props.tag,
})),
};
const notes = ref<InstanceType<typeof MkNotes>>();
const tlComponent = useTemplateRef('tlComponent');
async function post() {
store.set('postFormHashtags', props.tag);
@ -48,7 +49,7 @@ async function post() {
await os.post();
store.set('postFormHashtags', '');
store.set('postFormWithHashtags', false);
notes.value?.pagingComponent?.reload();
tlComponent.value?.reload();
}
const headerActions = computed(() => [{

View File

@ -4,14 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<PageWithHeader ref="pageComponent" v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true">
<PageWithHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :swipable="true" :displayMyAvatar="true">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<MkInfo v-if="isBasicTimeline(src) && !store.r.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()">
{{ i18n.ts._timelineDescription[src] }}
</MkInfo>
<MkPostForm v-if="prefer.r.showFixedPostForm.value" :class="$style.postForm" class="_panel" fixed style="margin-bottom: var(--MI-margin);"/>
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<MkTimeline
<MkStreamingNotesTimeline
ref="tlComponent"
:key="src + withRenotes + withReplies + onlyFiles + withSensitive"
:class="$style.tl"
@ -22,7 +21,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:withSensitive="withSensitive"
:onlyFiles="onlyFiles"
:sound="true"
@queue="queueUpdated"
/>
</div>
</PageWithHeader>
@ -33,7 +31,7 @@ import { computed, watch, provide, useTemplateRef, ref, onMounted, onActivated }
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import type { MenuItem } from '@/types/menu.js';
import type { BasicTimelineType } from '@/timelines.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkPostForm from '@/components/MkPostForm.vue';
import * as os from '@/os.js';
@ -51,11 +49,9 @@ import { prefer } from '@/preferences.js';
provide('shouldOmitHeaderTitle', true);
const tlComponent = useTemplateRef('tlComponent');
const pageComponent = useTemplateRef('pageComponent');
type TimelinePageSrc = BasicTimelineType | `list:${string}`;
const queue = ref(0);
const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global');
const src = computed<TimelinePageSrc>({
get: () => ($i ? store.r.tl.value.src : srcWhenNotSignin.value),
@ -110,18 +106,6 @@ const withSensitive = computed<boolean>({
set: (x) => saveTlFilter('withSensitive', x),
});
watch(src, () => {
queue.value = 0;
});
function queueUpdated(q: number): void {
queue.value = q;
}
function top(): void {
if (pageComponent.value) pageComponent.value.scrollToTop();
}
async function chooseList(ev: MouseEvent): Promise<void> {
const lists = await userListsCache.fetch();
const items: MenuItem[] = [

View File

@ -6,17 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div ref="rootEl">
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<div :class="$style.tl">
<MkTimeline
ref="tlEl" :key="listId"
src="list"
:list="listId"
:sound="true"
@queue="queueUpdated"
/>
</div>
<div :class="$style.tl">
<MkStreamingNotesTimeline
ref="tlEl" :key="listId"
src="list"
:list="listId"
:sound="true"
/>
</div>
</div>
</PageWithHeader>
@ -25,8 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { scrollInContainer } from '@@/js/scroll.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
@ -39,9 +34,6 @@ const props = defineProps<{
}>();
const list = ref<Misskey.entities.UserList | null>(null);
const queue = ref(0);
const tlEl = useTemplateRef('tlEl');
const rootEl = useTemplateRef('rootEl');
watch(() => props.listId, async () => {
list.value = await misskeyApi('users/lists/show', {
@ -49,14 +41,6 @@ watch(() => props.listId, async () => {
});
}, { immediate: true });
function queueUpdated(q) {
queue.value = q;
}
function top() {
scrollInContainer(rootEl.value, { top: 0 });
}
function settings() {
router.push(`/my/lists/${props.listId}`);
}
@ -76,25 +60,6 @@ definePage(() => ({
</script>
<style lang="scss" module>
.new {
position: sticky;
top: calc(var(--MI-stickyTop, 0px) + 16px);
z-index: 1000;
width: 100%;
margin: calc(-0.675em - 8px) 0;
&:first-child {
margin-top: calc(-0.675em - 8px - var(--MI-margin));
}
}
.newButton {
display: block;
margin: var(--MI-margin) auto 0 auto;
padding: 8px 16px;
border-radius: 32px;
}
.tl {
background: var(--MI_THEME-bg);
border-radius: var(--MI-radius);

View File

@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_spacer" style="--MI_SPACER-w: 1100px;">
<div :class="$style.root">
<MkPagination v-slot="{items}" :pagination="pagination">
<div :class="$style.stream">
<MkNoteMediaGrid v-for="note in items" :note="note" square/>
</div>
</MkPagination>
</div>
<div class="_spacer" style="--MI_SPACER-w: 1100px;">
<div :class="$style.root">
<MkPagination v-slot="{items}" :pagination="pagination">
<div :class="$style.stream">
<MkNoteMediaGrid v-for="note in items" :note="note" square/>
</div>
</MkPagination>
</div>
</div>
</template>
<script lang="ts" setup>

View File

@ -4,158 +4,160 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_spacer" :style="{ '--MI_SPACER-w': narrow ? '800px' : '1100px' }">
<div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;">
<div class="main _gaps">
<!-- TODO -->
<!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> -->
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
<div class="_spacer" :style="{ '--MI_SPACER-w': narrow ? '800px' : '1100px' }">
<div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;">
<div class="main _gaps">
<!-- TODO -->
<!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> -->
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile _gaps">
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/>
<MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo>
<div class="profile _gaps">
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/>
<MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo>
<div :key="user.id" class="main _panel">
<div class="banner-container" :style="style">
<div ref="bannerEl" class="banner" :style="style"></div>
<div class="fade"></div>
<div :key="user.id" class="main _panel">
<div class="banner-container" :style="style">
<div ref="bannerEl" class="banner" :style="style"></div>
<div class="fade"></div>
<div class="title">
<MkUserName class="name" :user="user" :nowrap="true"/>
<div class="bottom">
<span class="username"><MkAcct :user="user" :detail="true"/></span>
<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span>
<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
<button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea">
<i class="ti ti-edit"/> {{ i18n.ts.addMemo }}
</button>
</div>
</div>
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
<div class="actions">
<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
</div>
</div>
<MkAvatar class="avatar" :user="user" indicator/>
<div class="title">
<MkUserName class="name" :user="user" :nowrap="true"/>
<MkUserName :user="user" :nowrap="false" class="name"/>
<div class="bottom">
<span class="username"><MkAcct :user="user" :detail="true"/></span>
<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span>
<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
<button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea">
<i class="ti ti-edit"/> {{ i18n.ts.addMemo }}
</button>
</div>
</div>
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
<div class="actions">
<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
<div v-if="user.followedMessage != null" class="followedMessage">
<MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin>
<div class="messageHeader">{{ i18n.ts.messageToFollower }}</div>
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div>
</MkFukidashi>
</div>
</div>
<MkAvatar class="avatar" :user="user" indicator/>
<div class="title">
<MkUserName :user="user" :nowrap="false" class="name"/>
<div class="bottom">
<span class="username"><MkAcct :user="user" :detail="true"/></span>
<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--MI_THEME-badge);"><i class="ti ti-shield"></i></span>
<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
<div v-if="user.roles.length > 0" class="roles">
<span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }">
<MkA v-adaptive-bg :to="`/roles/${role.id}`">
<img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/>
{{ role.name }}
</MkA>
</span>
</div>
</div>
<div v-if="user.followedMessage != null" class="followedMessage">
<MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin>
<div class="messageHeader">{{ i18n.ts.messageToFollower }}</div>
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div>
</MkFukidashi>
</div>
<div v-if="user.roles.length > 0" class="roles">
<span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }">
<MkA v-adaptive-bg :to="`/roles/${role.id}`">
<img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/>
{{ role.name }}
<div v-if="iAmModerator" class="moderationNote">
<MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave>
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
<div v-else>
<MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton>
</div>
</div>
<div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}">
<div class="heading" v-text="i18n.ts.memo"/>
<textarea
ref="memoTextareaEl"
v-model="memoDraft"
rows="1"
@focus="isEditingMemo = true"
@blur="updateMemo"
@input="adjustMemoTextarea"
/>
</div>
<div class="description">
<MkOmit>
<Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" class="_selectable"/>
<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
</MkOmit>
</div>
<div class="fields system">
<dl v-if="user.location" class="field">
<dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt>
<dd class="value">{{ user.location }}</dd>
</dl>
<dl v-if="user.birthday" class="field">
<dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt>
<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd>
</dl>
<dl class="field">
<dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt>
<dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd>
</dl>
</div>
<div v-if="user.fields.length > 0" class="fields">
<dl v-for="(field, i) in user.fields" :key="i" class="field">
<dt class="name">
<Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/>
</dt>
<dd class="value">
<Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/>
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i>
</dd>
</dl>
</div>
<div class="status">
<MkA :to="userPage(user)">
<b>{{ number(user.notesCount) }}</b>
<span>{{ i18n.ts.notes }}</span>
</MkA>
<MkA v-if="isFollowingVisibleForMe(user)" :to="userPage(user, 'following')">
<b>{{ number(user.followingCount) }}</b>
<span>{{ i18n.ts.following }}</span>
</MkA>
<MkA v-if="isFollowersVisibleForMe(user)" :to="userPage(user, 'followers')">
<b>{{ number(user.followersCount) }}</b>
<span>{{ i18n.ts.followers }}</span>
</MkA>
</span>
</div>
<div v-if="iAmModerator" class="moderationNote">
<MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave>
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
<div v-else>
<MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton>
</div>
</div>
<div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}">
<div class="heading" v-text="i18n.ts.memo"/>
<textarea
ref="memoTextareaEl"
v-model="memoDraft"
rows="1"
@focus="isEditingMemo = true"
@blur="updateMemo"
@input="adjustMemoTextarea"
/>
</div>
<div class="description">
<MkOmit>
<Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" class="_selectable"/>
<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
</MkOmit>
</div>
<div class="fields system">
<dl v-if="user.location" class="field">
<dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt>
<dd class="value">{{ user.location }}</dd>
</dl>
<dl v-if="user.birthday" class="field">
<dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt>
<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd>
</dl>
<dl class="field">
<dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt>
<dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd>
</dl>
</div>
<div v-if="user.fields.length > 0" class="fields">
<dl v-for="(field, i) in user.fields" :key="i" class="field">
<dt class="name">
<Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/>
</dt>
<dd class="value">
<Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/>
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i>
</dd>
</dl>
</div>
<div class="status">
<MkA :to="userPage(user)">
<b>{{ number(user.notesCount) }}</b>
<span>{{ i18n.ts.notes }}</span>
</MkA>
<MkA v-if="isFollowingVisibleForMe(user)" :to="userPage(user, 'following')">
<b>{{ number(user.followingCount) }}</b>
<span>{{ i18n.ts.following }}</span>
</MkA>
<MkA v-if="isFollowersVisibleForMe(user)" :to="userPage(user, 'followers')">
<b>{{ number(user.followersCount) }}</b>
<span>{{ i18n.ts.followers }}</span>
</MkA>
</div>
</div>
</div>
<div class="contents _gaps">
<div v-if="user.pinnedNotes.length > 0" class="_gaps">
<MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/>
</div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow">
<MkLazy>
<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
</MkLazy>
<MkLazy>
<XActivity :key="user.id" :user="user"/>
</MkLazy>
</template>
<div v-if="!disableNotes">
<MkLazy>
<XTimeline :user="user"/>
</MkLazy>
<div class="contents _gaps">
<div v-if="user.pinnedNotes.length > 0" class="_gaps">
<MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/>
</div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow">
<MkLazy>
<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
</MkLazy>
<MkLazy>
<XActivity :key="user.id" :user="user"/>
</MkLazy>
</template>
<div v-if="!disableNotes">
<MkLazy>
<XTimeline :user="user"/>
</MkLazy>
</div>
</div>
</div>
</div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
<XActivity :key="user.id" :user="user"/>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
<XActivity :key="user.id" :user="user"/>
</div>
</div>
</div>
</div>
</component>
</template>
<script lang="ts" setup>
@ -185,6 +187,7 @@ import { useRouter } from '@/router.js';
import { getStaticImageUrl } from '@/utility/media-proxy.js';
import MkSparkle from '@/components/MkSparkle.vue';
import { prefer } from '@/preferences.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
function calcAge(birthdate: string): number {
const date = new Date(birthdate);
@ -207,7 +210,7 @@ const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed;
/** Test only; MkNotes currently causes problems in vitest */
/** Test only; MkNotesTimeline currently causes problems in vitest */
disableNotes: boolean;
}>(), {
disableNotes: false,
@ -299,6 +302,10 @@ watch([props.user], () => {
memoDraft.value = props.user.memo;
});
async function reload() {
// TODO
}
onMounted(() => {
window.requestAnimationFrame(parallaxLoop);
narrow.value = rootEl.value!.clientWidth < 1000;

View File

@ -8,19 +8,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
<MkTab v-model="tab" :class="$style.tab">
<option value="featured">{{ i18n.ts.featured }}</option>
<option :value="null">{{ i18n.ts.notes }}</option>
<option value="notes">{{ i18n.ts.notes }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="files">{{ i18n.ts.withFiles }}</option>
</MkTab>
</template>
<MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/>
<MkNotesTimeline :key="tab" :noGap="true" :pagination="pagination" :pullToRefresh="false" :class="$style.tl"/>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import MkTab from '@/components/MkTab.vue';
import { i18n } from '@/i18n.js';
@ -28,7 +28,7 @@ const props = defineProps<{
user: Misskey.entities.UserDetailed;
}>();
const tab = ref<string | null>('all');
const tab = ref<string>('all');
const pagination = computed(() => tab.value === 'featured' ? {
endpoint: 'users/featured-notes' as const,

View File

@ -7,9 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader v-model:tab="tab" :tabs="headerTabs" :actions="headerActions" :swipable="true">
<div v-if="user">
<XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/>
<div v-else-if="tab === 'notes'" class="_spacer" style="--MI_SPACER-w: 800px;">
<XTimeline :user="user"/>
</div>
<XNotes v-else-if="tab === 'notes'" :user="user"/>
<XFiles v-else-if="tab === 'files'" :user="user"/>
<XActivity v-else-if="tab === 'activity'" :user="user"/>
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
@ -37,7 +35,7 @@ import { $i } from '@/i.js';
import { serverContext, assertServerContext } from '@/server-context.js';
const XHome = defineAsyncComponent(() => import('./home.vue'));
const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
const XNotes = defineAsyncComponent(() => import('./notes.vue'));
const XFiles = defineAsyncComponent(() => import('./files.vue'));
const XActivity = defineAsyncComponent(() => import('./activity.vue'));
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));

View File

@ -0,0 +1,67 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div :class="$style.root">
<MkStickyContainer>
<template #header>
<MkTab v-model="tab" :class="$style.tab">
<option value="featured">{{ i18n.ts.featured }}</option>
<option value="notes">{{ i18n.ts.notes }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="files">{{ i18n.ts.withFiles }}</option>
</MkTab>
</template>
<MkNotesTimeline :key="tab" :noGap="true" :pagination="pagination" :class="$style.tl"/>
</MkStickyContainer>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import MkTab from '@/components/MkTab.vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
user: Misskey.entities.UserDetailed;
}>();
const tab = ref<string>('all');
const pagination = computed(() => tab.value === 'featured' ? {
endpoint: 'users/featured-notes' as const,
limit: 10,
params: {
userId: props.user.id,
},
} : {
endpoint: 'users/notes' as const,
limit: 10,
params: {
userId: props.user.id,
withRenotes: tab.value === 'all',
withReplies: tab.value === 'all',
withChannelNotes: tab.value === 'all',
withFiles: tab.value === 'files',
},
});
</script>
<style lang="scss" module>
.tab {
padding: calc(var(--MI-margin) / 2) 0;
background: var(--MI_THEME-bg);
}
.tl {
background: var(--MI_THEME-bg);
border-radius: var(--MI-radius);
overflow: clip;
}
</style>

View File

@ -27,7 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/>
</div>
<div v-if="note.reactionCount > 0" :class="$style.reactions">
<MkReactionsViewer :note="note" :maxNumber="16"/>
<!-- TODO -->
<!--<MkReactionsViewer :note="note" :maxNumber="16"/>-->
</div>
</div>
</div>

View File

@ -115,7 +115,6 @@ export function migrateOldSettings() {
prefer.commit('notificationStackAxis', store.s.notificationStackAxis);
prefer.commit('enableCondensedLine', store.s.enableCondensedLine);
prefer.commit('keepScreenOn', store.s.keepScreenOn);
prefer.commit('disableStreamingTimeline', store.s.disableStreamingTimeline);
prefer.commit('useGroupedNotifications', store.s.useGroupedNotifications);
prefer.commit('dataSaver', store.s.dataSaver);
prefer.commit('enableSeasonalScreenEffect', store.s.enableSeasonalScreenEffect);

View File

@ -241,6 +241,12 @@ export const PREF_DEF = {
numberOfPageCache: {
default: 3,
},
pollingInterval: {
// 1 ... 低
// 2 ... 中
// 3 ... 高
default: 2,
},
showNoteActionsOnlyHover: {
default: false,
},
@ -277,9 +283,6 @@ export const PREF_DEF = {
keepScreenOn: {
default: false,
},
disableStreamingTimeline: {
default: false,
},
useGroupedNotifications: {
default: true,
},

View File

@ -80,6 +80,10 @@ export const store = markRaw(new Pizzax('base', {
where: 'device',
default: false,
},
realtimeMode: {
where: 'device',
default: true,
},
recentlyUsedEmojis: {
where: 'device',
default: [] as string[],
@ -378,10 +382,6 @@ export const store = markRaw(new Pizzax('base', {
where: 'device',
default: false,
},
disableStreamingTimeline: {
where: 'device',
default: false,
},
useGroupedNotifications: {
where: 'device',
default: true,

View File

@ -309,7 +309,6 @@ rt {
max-width: 100%;
&:disabled {
opacity: 0.5;
cursor: default;
}
}

View File

@ -1,11 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type MisskeyEntity = {
id: string;
createdAt: string;
_shouldInsertAd_?: boolean;
[x: string]: any;
};

View File

@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''"
>
<div v-if="drawerMenuShowing" :class="$style.menuDrawer">
<XDrawerMenu/>
<XNavbar style="height: 100%;" :asDrawer="true" :showWidgetButton="false"/>
</div>
</Transition>
@ -112,7 +112,8 @@ import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
import { store } from '@/store.js';
import XNavbar from '@/ui/_common_/navbar.vue';
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
const XUpload = defineAsyncComponent(() => import('./upload.vue'));
@ -129,7 +130,9 @@ function onNotification(notification: Misskey.entities.Notification, isClient =
if (window.document.visibilityState === 'visible') {
if (!isClient && notification.type !== 'test') {
//
useStream().send('readNotification');
if (store.s.realtimeMode) {
useStream().send('readNotification');
}
}
notifications.value.unshift(notification);
@ -146,11 +149,12 @@ function onNotification(notification: Misskey.entities.Notification, isClient =
}
if ($i) {
const connection = useStream().useChannel('main', null, 'UI');
connection.on('notification', onNotification);
if (store.s.realtimeMode) {
const connection = useStream().useChannel('main');
connection.on('notification', onNotification);
}
globalEvents.on('clientNotification', notification => onNotification(notification, true));
//#region Listen message from SW
if ('serviceWorker' in navigator) {
swInject();
}
@ -226,12 +230,6 @@ if ($i) {
left: 0;
z-index: 1001;
height: 100dvh;
width: 240px;
box-sizing: border-box;
contain: strict;
overflow: auto;
overscroll-behavior: contain;
background: var(--MI_THEME-navBg);
}
.widgetsDrawerBg {

View File

@ -1,273 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="$style.top">
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div>
<button class="_button" :class="$style.instance" @click="openInstanceMenu">
<img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/>
</button>
</div>
<div :class="$style.middle">
<MkA :class="$style.item" :activeClass="$style.active" to="/" exact>
<i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span>
</MkA>
<template v-for="item in prefer.r.menu.value">
<div v-if="item === '-'" :class="$style.divider"></div>
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" class="_button" :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
<i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span>
<span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator" class="_blink">
<span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span>
<i v-else class="_indicatorCircle"></i>
</span>
</component>
</template>
<div :class="$style.divider"></div>
<MkA v-if="$i.isAdmin || $i.isModerator" :class="$style.item" :activeClass="$style.active" to="/admin">
<i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span>
</MkA>
<button :class="$style.item" class="_button" @click="more">
<i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span>
<span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span>
</button>
<MkA :class="$style.item" :activeClass="$style.active" to="/settings">
<i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span>
</MkA>
</div>
<div :class="$style.bottom">
<button class="_button" :class="$style.post" data-cy-open-post-form @click="os.post">
<i :class="$style.postIcon" class="ti ti-pencil ti-fw"></i><span style="position: relative;">{{ i18n.ts.note }}</span>
</button>
<button class="_button" :class="$style.account" @click="openAccountMenu">
<MkAvatar :user="$i" :class="$style.avatar"/><MkAcct :class="$style.acct" class="_nowrap" :user="$i"/>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent } from 'vue';
import { openInstanceMenu } from './common.js';
import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { openAccountMenu as openAccountMenu_ } from '@/accounts.js';
import { $i } from '@/i.js';
const otherMenuItemIndicated = computed(() => {
for (const def in navbarItemDef) {
if (prefer.r.menu.value.includes(def)) continue;
if (navbarItemDef[def].indicated) return true;
}
return false;
});
function openAccountMenu(ev: MouseEvent) {
openAccountMenu_({
withExtraOperation: true,
}, ev);
}
function more() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, {
closed: () => dispose(),
});
}
</script>
<style lang="scss" module>
.root {
--nav-bg-transparent: color(from var(--MI_THEME-navBg) srgb r g b / 0.5);
display: flex;
flex-direction: column;
}
.top {
position: sticky;
top: 0;
z-index: 1;
padding: 20px 0;
background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--MI-blur, blur(8px));
backdrop-filter: var(--MI-blur, blur(8px));
}
.banner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center center;
-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
}
.instance {
position: relative;
display: block;
text-align: center;
width: 100%;
}
.instanceIcon {
display: inline-block;
width: 38px;
aspect-ratio: 1;
border-radius: 8px;
}
.bottom {
position: sticky;
bottom: 0;
padding: 20px 0;
background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--MI-blur, blur(8px));
backdrop-filter: var(--MI-blur, blur(8px));
}
.post {
position: relative;
display: block;
width: 100%;
height: 40px;
color: var(--MI_THEME-fgOnAccent);
font-weight: bold;
text-align: left;
&::before {
content: "";
display: block;
width: calc(100% - 38px);
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
}
&:hover, &.active {
&::before {
background: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
}
}
}
.postIcon {
position: relative;
margin-left: 30px;
margin-right: 8px;
width: 32px;
}
.account {
position: relative;
display: flex;
align-items: center;
padding-left: 30px;
width: 100%;
text-align: left;
box-sizing: border-box;
margin-top: 16px;
}
.avatar {
display: block;
flex-shrink: 0;
position: relative;
width: 32px;
aspect-ratio: 1;
margin-right: 8px;
}
.acct {
display: block;
flex-shrink: 1;
padding-right: 8px;
}
.middle {
flex: 1;
}
.divider {
margin: 16px 16px;
border-top: solid 0.5px var(--MI_THEME-divider);
}
.item {
position: relative;
display: block;
padding-left: 24px;
line-height: 2.85rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
text-align: left;
box-sizing: border-box;
color: var(--MI_THEME-navFg);
&:hover {
text-decoration: none;
color: light-dark(hsl(from var(--MI_THEME-navFg) h s calc(l - 17)), hsl(from var(--MI_THEME-navFg) h s calc(l + 17)));
}
&.active {
color: var(--MI_THEME-navActive);
}
&:hover, &.active {
&::before {
content: "";
display: block;
width: calc(100% - 24px);
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 999px;
background: var(--MI_THEME-accentedBg);
}
}
}
.itemIcon {
position: relative;
width: 32px;
margin-right: 8px;
}
.itemIndicator {
position: absolute;
top: 0;
left: 20px;
color: var(--MI_THEME-navIndicator);
font-size: 8px;
&:has(.itemIndicateValueIcon) {
animation: none;
left: auto;
right: 20px;
}
}
.itemText {
position: relative;
font-size: 0.9em;
}
</style>

View File

@ -10,6 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu">
<img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/>
</button>
<button v-if="!iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode">
<i class="ti ti-bolt ti-fw"></i>
</button>
</div>
<div :class="$style.middle">
<MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact>
@ -50,6 +53,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="showWidgetButton" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')">
<i class="ti ti-apps ti-fw"></i>
</button>
<button v-if="iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode">
<i class="ti ti-bolt ti-fw"></i>
</button>
<button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="() => { os.post(); }">
<i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span>
</button>
@ -76,16 +82,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</svg>
<button class="_button" :class="$style.subButtonClickable" @click="menuEdit"><i :class="$style.subButtonIcon" class="ti ti-settings-2"></i></button>
</div>
<div :class="$style.subButtonGapFill"></div>
<div :class="$style.subButtonGapFillDivider"></div>
<div :class="[$style.subButton, $style.toggleButton]">
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
<path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/>
</g>
</svg>
<button class="_button" :class="$style.subButtonClickable" @click="toggleIconOnly"><i v-if="iconOnly" class="ti ti-chevron-right" :class="$style.subButtonIcon"></i><i v-else class="ti ti-chevron-left" :class="$style.subButtonIcon"></i></button>
</div>
<template v-if="!props.asDrawer">
<div :class="$style.subButtonGapFill"></div>
<div :class="$style.subButtonGapFillDivider"></div>
<div :class="[$style.subButton, $style.toggleButton]">
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
<path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/>
</g>
</svg>
<button class="_button" :class="$style.subButtonClickable" @click="toggleIconOnly"><i v-if="iconOnly" class="ti ti-chevron-right" :class="$style.subButtonIcon"></i><i v-else class="ti ti-chevron-left" :class="$style.subButtonIcon"></i></button>
</div>
</template>
</div>
</div>
</template>
@ -108,15 +116,16 @@ const router = useRouter();
const props = defineProps<{
showWidgetButton?: boolean;
asDrawer?: boolean;
}>();
const emit = defineEmits<{
(ev: 'widgetButtonClick'): void;
}>();
const forceIconOnly = ref(window.innerWidth <= 1279);
const forceIconOnly = ref(!props.asDrawer && window.innerWidth <= 1279);
const iconOnly = computed(() => {
return forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon');
return !props.asDrawer && (forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon'));
});
const otherMenuItemIndicated = computed(() => {
@ -147,6 +156,20 @@ function toggleIconOnly() {
}
}
function toggleRealtimeMode(ev: MouseEvent) {
os.popupMenu([{
type: 'label',
text: i18n.ts.realtimeMode,
}, {
text: store.s.realtimeMode ? i18n.ts.turnItOff : i18n.ts.turnItOn,
icon: store.s.realtimeMode ? 'ti ti-bolt-off' : 'ti ti-bolt',
action: () => {
store.set('realtimeMode', !store.s.realtimeMode);
window.location.reload();
},
}], ev.currentTarget ?? ev.target);
}
function openAccountMenu(ev: MouseEvent) {
openAccountMenu_({
withExtraOperation: true,
@ -191,21 +214,108 @@ function menuEdit() {
overscroll-behavior: contain;
background: var(--MI_THEME-navBg);
contain: strict;
/* 画面が縦に長い、設置している項目数が少ないなどの環境においても確実にbottomを最下部に表示するため */
display: flex;
flex-direction: column;
direction: rtl; //
direction: rtl; /* スクロールバーを左に表示したいため */
}
.top {
flex-shrink: 0;
direction: ltr;
/* 疑似progressive blur */
&::before {
position: absolute;
z-index: -1;
inset: 0;
content: "";
backdrop-filter: blur(8px);
mask-image: linear-gradient(
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%
);
}
&::after {
position: absolute;
z-index: -1;
inset: 0;
bottom: 25%;
content: "";
backdrop-filter: blur(16px);
mask-image: linear-gradient(
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%
);
}
}
.middle {
flex: 1;
direction: ltr;
}
.bottom {
flex-shrink: 0;
direction: ltr;
/* 疑似progressive blur */
&::before {
position: absolute;
z-index: -1;
inset: -30px 0 0 0;
content: "";
backdrop-filter: blur(8px);
mask-image: linear-gradient(
to bottom,
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%
);
pointer-events: none;
}
&::after {
position: absolute;
z-index: -1;
inset: 0;
top: 25%;
content: "";
backdrop-filter: blur(16px);
mask-image: linear-gradient(
to bottom,
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%
);
}
}
.subButtons {
@ -290,29 +400,18 @@ function menuEdit() {
}
.top {
--top-height: 80px;
position: sticky;
top: 0;
z-index: 1;
padding: 20px 0;
background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--MI-blur, blur(8px));
backdrop-filter: var(--MI-blur, blur(8px));
display: flex;
height: var(--top-height);
}
.instance {
position: relative;
display: block;
text-align: center;
width: 100%;
&:focus-visible {
outline: none;
> .instanceIcon {
outline: 2px solid var(--MI_THEME-focus);
outline-offset: 2px;
}
}
width: var(--top-height);
}
.instanceIcon {
@ -322,13 +421,20 @@ function menuEdit() {
border-radius: 8px;
}
.realtimeMode {
display: inline-block;
width: var(--top-height);
margin-left: auto;
&.on {
color: var(--MI_THEME-accent);
}
}
.bottom {
position: sticky;
bottom: 0;
padding-top: 20px;
background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--MI-blur, blur(8px));
backdrop-filter: var(--MI-blur, blur(8px));
}
.post {
@ -416,10 +522,6 @@ function menuEdit() {
padding-right: 8px;
}
.middle {
flex: 1;
}
.divider {
margin: 16px 16px;
border-top: solid 0.5px var(--MI_THEME-divider);
@ -520,9 +622,6 @@ function menuEdit() {
top: 0;
z-index: 1;
padding: 20px 0;
background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--MI-blur, blur(8px));
backdrop-filter: var(--MI-blur, blur(8px));
}
.instance {
@ -551,9 +650,6 @@ function menuEdit() {
position: sticky;
bottom: 0;
padding-top: 20px;
background: var(--nav-bg-transparent);
-webkit-backdrop-filter: var(--MI-blur, blur(8px));
backdrop-filter: var(--MI-blur, blur(8px));
}
.widget {
@ -564,6 +660,18 @@ function menuEdit() {
text-align: center;
}
.realtimeMode {
display: block;
position: relative;
width: 100%;
height: 52px;
text-align: center;
&.on {
color: var(--MI_THEME-accent);
}
}
.post {
display: block;
position: relative;
@ -637,10 +745,6 @@ function menuEdit() {
display: none;
}
.middle {
flex: 1;
}
.divider {
margin: 8px auto;
width: calc(100% - 32px);
@ -650,7 +754,7 @@ function menuEdit() {
.item {
display: block;
position: relative;
padding: 18px 0;
padding: 16px 0;
width: 100%;
text-align: center;

View File

@ -20,6 +20,7 @@ import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
const zIndex = os.claimZIndex('high');
@ -37,11 +38,13 @@ function reload() {
window.location.reload();
}
useStream().on('_disconnected_', onDisconnected);
if (store.s.realtimeMode) {
useStream().on('_disconnected_', onDisconnected);
onUnmounted(() => {
useStream().off('_disconnected_', onDisconnected);
});
onUnmounted(() => {
useStream().off('_disconnected_', onDisconnected);
});
}
</script>
<style lang="scss" module>

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name || antennaName || i18n.ts._deck._columns.antenna }}</span>
</template>
<MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @note="onNote"/>
<MkStreamingNotesTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/>
</XColumn>
</template>
@ -21,13 +21,12 @@ import type { Column } from '@/deck.js';
import type { MenuItem } from '@/types/menu.js';
import type { SoundStore } from '@/preferences/def.js';
import { updateColumn } from '@/deck.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { antennasCache } from '@/cache.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
import * as sound from '@/utility/sound.js';
const props = defineProps<{
column: Column;
@ -96,10 +95,6 @@ function editAntenna() {
os.pageWindow('my/antennas/' + props.column.antennaId);
}
function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu: MenuItem[] = [
{
icon: 'ti ti-pencil',

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="padding: 8px; text-align: center;">
<MkButton primary gradate rounded inline small @click="post"><i class="ti ti-pencil"></i></MkButton>
</div>
<MkTimeline ref="timeline" src="channel" :channel="column.channelId" @note="onNote"/>
<MkStreamingNotesTimeline ref="timeline" src="channel" :channel="column.channelId"/>
</template>
</XColumn>
</template>
@ -26,14 +26,13 @@ import type { Column } from '@/deck.js';
import type { MenuItem } from '@/types/menu.js';
import type { SoundStore } from '@/preferences/def.js';
import { updateColumn } from '@/deck.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { favoritedChannelsCache } from '@/cache.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
import * as sound from '@/utility/sound.js';
const props = defineProps<{
column: Column;
@ -90,10 +89,6 @@ async function post() {
});
}
function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu: MenuItem[] = [{
icon: 'ti ti-pencil',
text: i18n.ts.selectChannel,

View File

@ -7,15 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()">
<template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.direct }}</template>
<MkNotes ref="tlComponent" :pagination="pagination"/>
<MkNotesTimeline ref="tlComponent" :pagination="pagination"/>
</XColumn>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, useTemplateRef } from 'vue';
import XColumn from './column.vue';
import type { Column } from '@/deck.js';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import { i18n } from '@/i18n.js';
defineProps<{
@ -31,11 +31,11 @@ const pagination = {
},
};
const tlComponent = ref<InstanceType<typeof MkNotes>>();
const tlComponent = useTemplateRef('tlComponent');
function reloadTimeline() {
return new Promise<void>((res) => {
tlComponent.value?.pagingComponent?.reload().then(() => {
tlComponent.value?.reload().then(() => {
res();
});
});

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ (column.name || listName) ?? i18n.ts._deck._columns.list }}</span>
</template>
<MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes" @note="onNote"/>
<MkStreamingNotesTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/>
</XColumn>
</template>
@ -21,13 +21,12 @@ import type { Column } from '@/deck.js';
import type { MenuItem } from '@/types/menu.js';
import type { SoundStore } from '@/preferences/def.js';
import { updateColumn } from '@/deck.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { userListsCache } from '@/cache.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
import * as sound from '@/utility/sound.js';
const props = defineProps<{
column: Column;
@ -102,10 +101,6 @@ function editList() {
os.pageWindow('my/lists/' + props.column.listId);
}
function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu: MenuItem[] = [
{
icon: 'ti ti-pencil',

View File

@ -7,27 +7,27 @@ SPDX-License-Identifier: AGPL-3.0-only
<XColumn :column="column" :isStacked="isStacked" :refresher="() => reloadTimeline()">
<template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.mentions }}</template>
<MkNotes ref="tlComponent" :pagination="pagination"/>
<MkNotesTimeline ref="tlComponent" :pagination="pagination"/>
</XColumn>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, useTemplateRef } from 'vue';
import XColumn from './column.vue';
import type { Column } from '@/deck.js';
import MkNotes from '@/components/MkNotes.vue';
import { i18n } from '../../i18n.js';
import { i18n } from '@/i18n.js';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
defineProps<{
column: Column;
isStacked: boolean;
}>();
const tlComponent = ref<InstanceType<typeof MkNotes>>();
const tlComponent = useTemplateRef('tlComponent');
function reloadTimeline() {
return new Promise<void>((res) => {
tlComponent.value?.pagingComponent?.reload().then(() => {
tlComponent.value?.reload().then(() => {
res();
});
});

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XColumn :column="column" :isStacked="isStacked" :menu="menu" :refresher="async () => { await notificationsComponent?.reload() }">
<template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.notifications }}</template>
<XNotifications ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/>
<MkStreamingNotificationsTimeline ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/>
</XColumn>
</template>
@ -16,7 +16,7 @@ import { defineAsyncComponent, useTemplateRef } from 'vue';
import XColumn from './column.vue';
import type { Column } from '@/deck.js';
import { updateColumn } from '@/deck.js';
import XNotifications from '@/components/MkNotifications.vue';
import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name || roleName || i18n.ts._deck._columns.roleTimeline }}</span>
</template>
<MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @note="onNote"/>
<MkStreamingNotesTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/>
</XColumn>
</template>
@ -20,12 +20,11 @@ import type { Column } from '@/deck.js';
import type { MenuItem } from '@/types/menu.js';
import type { SoundStore } from '@/preferences/def.js';
import { updateColumn } from '@/deck.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
import * as sound from '@/utility/sound.js';
const props = defineProps<{
column: Column;
@ -68,10 +67,6 @@ async function setRole() {
});
}
function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu: MenuItem[] = [{
icon: 'ti ti-pencil',
text: i18n.ts.role,

View File

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</p>
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
</div>
<MkTimeline
<MkStreamingNotesTimeline
v-else-if="column.tl"
ref="timeline"
:key="column.tl + withRenotes + withReplies + onlyFiles"
@ -26,7 +26,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:withReplies="withReplies"
:withSensitive="withSensitive"
:onlyFiles="onlyFiles"
@note="onNote"
/>
</XColumn>
</template>
@ -38,12 +37,11 @@ import type { Column } from '@/deck.js';
import type { MenuItem } from '@/types/menu.js';
import type { SoundStore } from '@/preferences/def.js';
import { removeColumn, updateColumn } from '@/deck.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
import * as sound from '@/utility/sound.js';
const props = defineProps<{
column: Column;
@ -117,10 +115,6 @@ async function setType() {
});
}
function onNote() {
sound.playMisskeySfxFile(soundSetting.value);
}
const menu = computed<MenuItem[]>(() => {
const menuItems: MenuItem[] = [];

View File

@ -4,106 +4,166 @@
*/
import { onUnmounted } from 'vue';
import type { Ref, ShallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import { EventEmitter } from 'eventemitter3';
import type { Reactive, Ref } from 'vue';
import { useStream } from '@/stream.js';
import { $i } from '@/i.js';
import { store } from '@/store.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
export function useNoteCapture(props: {
rootEl: ShallowRef<HTMLElement | undefined>;
note: Ref<Misskey.entities.Note>;
pureNote: Ref<Misskey.entities.Note>;
isDeletedRef: Ref<boolean>;
export const noteEvents = new EventEmitter<{
[ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void;
[ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void;
[ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void;
}>();
const fetchEvent = new EventEmitter<{
[id: string]: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>;
}>();
const pollingQueue = new Map<string, {
referenceCount: number;
lastAddedAt: number;
}>();
function pollingEnqueue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
if (pollingQueue.has(note.id)) {
const data = pollingQueue.get(note.id)!;
pollingQueue.set(note.id, {
...data,
referenceCount: data.referenceCount + 1,
lastAddedAt: Date.now(),
});
} else {
pollingQueue.set(note.id, {
referenceCount: 1,
lastAddedAt: Date.now(),
});
}
}
function pollingDequeue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
const data = pollingQueue.get(note.id);
if (data == null) return;
if (data.referenceCount === 1) {
pollingQueue.delete(note.id);
} else {
pollingQueue.set(note.id, {
...data,
referenceCount: data.referenceCount - 1,
});
}
}
const CAPTURE_MAX = 30;
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;
window.setInterval(() => {
const ids = [...pollingQueue.entries()]
.filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く
.map(([k, v]) => k)
.sort((a, b) => (a > b ? -1 : 1)) // 新しいものを優先するためにIDで降順ソート
.slice(0, CAPTURE_MAX);
if (ids.length === 0) return;
if (window.document.hidden) return;
// まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない
misskeyApi('notes/show-partial-bulk', {
noteIds: ids,
}).then((items) => {
for (const item of items) {
fetchEvent.emit(item.id, {
reactions: item.reactions,
reactionEmojis: item.reactionEmojis,
});
}
});
}, POLLING_INTERVAL);
function pollingSubscribe(props: {
note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
$note: ReactiveNoteData;
}) {
const { note, $note } = props;
function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void {
$note.reactions = data.reactions;
$note.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0);
$note.reactionEmojis = data.reactionEmojis;
}
pollingEnqueue(note);
fetchEvent.on(note.id, onFetched);
onUnmounted(() => {
pollingDequeue(note);
fetchEvent.off(note.id, onFetched);
});
}
function realtimeSubscribe(props: {
note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
}): void {
const note = props.note;
const pureNote = props.pureNote;
const connection = $i ? useStream() : null;
const connection = useStream();
function onStreamNoteUpdated(noteData): void {
const { type, id, body } = noteData;
if ((id !== note.value.id) && (id !== pureNote.value.id)) return;
if (id !== note.id) return;
switch (type) {
case 'reacted': {
const reaction = body.reaction;
if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) {
note.value.reactionEmojis[body.emoji.name] = body.emoji.url;
}
// TODO: reactionsプロパティがない場合ってあったっけ なければ || {} は消せる
const currentCount = (note.value.reactions || {})[reaction] || 0;
note.value.reactions[reaction] = currentCount + 1;
note.value.reactionCount += 1;
if ($i && (body.userId === $i.id)) {
note.value.myReaction = reaction;
}
noteEvents.emit(`reacted:${id}`, {
userId: body.userId,
reaction: body.reaction,
emoji: body.emoji,
});
break;
}
case 'unreacted': {
const reaction = body.reaction;
// TODO: reactionsプロパティがない場合ってあったっけ なければ || {} は消せる
const currentCount = (note.value.reactions || {})[reaction] || 0;
note.value.reactions[reaction] = Math.max(0, currentCount - 1);
note.value.reactionCount = Math.max(0, note.value.reactionCount - 1);
if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];
if ($i && (body.userId === $i.id)) {
note.value.myReaction = null;
}
noteEvents.emit(`unreacted:${id}`, {
userId: body.userId,
reaction: body.reaction,
emoji: body.emoji,
});
break;
}
case 'pollVoted': {
const choice = body.choice;
const choices = [...note.value.poll.choices];
choices[choice] = {
...choices[choice],
votes: choices[choice].votes + 1,
...($i && (body.userId === $i.id) ? {
isVoted: true,
} : {}),
};
note.value.poll.choices = choices;
noteEvents.emit(`pollVoted:${id}`, {
userId: body.userId,
choice: body.choice,
});
break;
}
case 'deleted': {
props.isDeletedRef.value = true;
globalEvents.emit('noteDeleted', id);
break;
}
}
}
function capture(withHandler = false): void {
if (connection) {
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
connection.send(window.document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id });
if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id });
if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
}
connection.send('sr', { id: note.id });
if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
}
function decapture(withHandler = false): void {
if (connection) {
connection.send('un', {
id: note.value.id,
});
if (pureNote.value.id !== note.value.id) {
connection.send('un', {
id: pureNote.value.id,
});
}
if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
}
connection.send('un', { id: note.id });
if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
}
function onStreamConnected() {
@ -111,14 +171,113 @@ export function useNoteCapture(props: {
}
capture(true);
if (connection) {
connection.on('_connected_', onStreamConnected);
}
connection.on('_connected_', onStreamConnected);
onUnmounted(() => {
decapture(true);
if (connection) {
connection.off('_connected_', onStreamConnected);
}
connection.off('_connected_', onStreamConnected);
});
}
type ReactiveNoteData = Reactive<{
reactions: Misskey.entities.Note['reactions'];
reactionCount: Misskey.entities.Note['reactionCount'];
reactionEmojis: Misskey.entities.Note['reactionEmojis'];
myReaction: Misskey.entities.Note['myReaction'];
pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices'];
}>;
export function useNoteCapture(props: {
note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
parentNote: Misskey.entities.Note | null;
$note: ReactiveNoteData;
}) {
const { note, parentNote, $note } = props;
noteEvents.on(`reacted:${note.id}`, onReacted);
noteEvents.on(`unreacted:${note.id}`, onUnreacted);
noteEvents.on(`pollVoted:${note.id}`, onPollVoted);
let latestReactedKey: string | null = null;
let latestUnreactedKey: string | null = null;
let latestPollVotedKey: string | null = null;
function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
const newReactedKey = `${ctx.userId}:${ctx.reaction}`;
if (newReactedKey === latestReactedKey) return;
latestReactedKey = newReactedKey;
if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) {
$note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url;
}
const currentCount = $note.reactions[ctx.reaction] || 0;
$note.reactions[ctx.reaction] = currentCount + 1;
$note.reactionCount += 1;
if ($i && (ctx.userId === $i.id)) {
$note.myReaction = ctx.reaction;
}
}
function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
const newUnreactedKey = `${ctx.userId}:${ctx.reaction}`;
if (newUnreactedKey === latestUnreactedKey) return;
latestUnreactedKey = newUnreactedKey;
const currentCount = $note.reactions[ctx.reaction] || 0;
$note.reactions[ctx.reaction] = Math.max(0, currentCount - 1);
$note.reactionCount = Math.max(0, $note.reactionCount - 1);
if ($note.reactions[ctx.reaction] === 0) delete $note.reactions[ctx.reaction];
if ($i && (ctx.userId === $i.id)) {
$note.myReaction = null;
}
}
function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: string; }): void {
const newPollVotedKey = `${ctx.userId}:${ctx.choice}`;
if (newPollVotedKey === latestPollVotedKey) return;
latestPollVotedKey = newPollVotedKey;
const choices = [...$note.pollChoices];
choices[ctx.choice] = {
...choices[ctx.choice],
votes: choices[ctx.choice].votes + 1,
...($i && (ctx.userId === $i.id) ? {
isVoted: true,
} : {}),
};
$note.pollChoices = choices;
}
onUnmounted(() => {
noteEvents.off(`reacted:${note.id}`, onReacted);
noteEvents.off(`unreacted:${note.id}`, onUnreacted);
noteEvents.off(`pollVoted:${note.id}`, onPollVoted);
});
// 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない
// ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する
// TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない
if (parentNote == null) {
if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
// リノートで表示されているノートでもないし、投稿からある程度経過しているので購読しない
return;
}
} else {
if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
// リノートで表示されているノートだが、リノートされてからある程度経過しているので購読しない
return;
}
}
if ($i && store.s.realtimeMode) {
realtimeSubscribe(props);
} else {
pollingSubscribe(props);
}
}

View File

@ -0,0 +1,251 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, isRef, onMounted, ref, shallowRef, triggerRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import { misskeyApi } from '@/utility/misskey-api.js';
const MAX_ITEMS = 20;
const MAX_QUEUE_ITEMS = 100;
const FIRST_FETCH_LIMIT = 15;
const SECOND_FETCH_LIMIT = 30;
export type MisskeyEntity = {
id: string;
createdAt: string;
_shouldInsertAd_?: boolean;
[x: string]: any;
};
export type PagingCtx<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;
offsetMode?: boolean;
baseId?: MisskeyEntity['id'];
direction?: 'newer' | 'older';
};
export function usePagination<T extends MisskeyEntity>(props: {
ctx: PagingCtx;
useShallowRef?: boolean;
}) {
const items = props.useShallowRef ? shallowRef<T[]>([]) : ref<T[]>([]);
let aheadQueue: T[] = [];
const queuedAheadItemsCount = ref(0);
const fetching = ref(true);
const fetchingOlder = ref(false);
const canFetchOlder = ref(false);
const error = ref(false);
// パラメータに何らかの変更があった際、再読込したいチャンネル等のIDが変わったなど
watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true });
function getNewestId(): string | null | undefined {
// 様々な要因により並び順は保証されないのでソートが必要
if (aheadQueue.length > 0) {
return aheadQueue.map(x => x.id).sort().at(-1);
}
return items.value.map(x => x.id).sort().at(-1);
}
function getOldestId(): string | null | undefined {
// 様々な要因により並び順は保証されないのでソートが必要
return items.value.map(x => x.id).sort().at(0);
}
async function init(): Promise<void> {
items.value = [];
aheadQueue = [];
queuedAheadItemsCount.value = 0;
fetching.value = true;
const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
await misskeyApi<T[]>(props.ctx.endpoint, {
...params,
limit: props.ctx.limit ?? FIRST_FETCH_LIMIT,
allowPartial: true,
...(props.ctx.baseId && props.ctx.direction === 'newer' ? {
sinceId: props.ctx.baseId,
} : props.ctx.baseId && props.ctx.direction === 'older' ? {
untilId: props.ctx.baseId,
} : {}),
}).then(res => {
// 逆順で返ってくるので
if (props.ctx.baseId && props.ctx.direction === 'newer') {
res.reverse();
}
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 3) item._shouldInsertAd_ = true;
}
if (res.length === 0 || props.ctx.noPaging) {
pushItems(res);
canFetchOlder.value = false;
} else {
pushItems(res);
canFetchOlder.value = true;
}
error.value = false;
fetching.value = false;
}, err => {
error.value = true;
fetching.value = false;
});
}
function reload(): Promise<void> {
return init();
}
async function fetchOlder(): Promise<void> {
if (!canFetchOlder.value || fetching.value || fetchingOlder.value || items.value.length === 0) return;
fetchingOlder.value = true;
const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
await misskeyApi<T[]>(props.ctx.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.ctx.offsetMode ? {
offset: items.value.length,
} : {
untilId: getOldestId(),
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 10) item._shouldInsertAd_ = true;
}
if (res.length === 0) {
canFetchOlder.value = false;
fetchingOlder.value = false;
} else {
items.value.push(...res);
if (props.useShallowRef) triggerRef(items);
canFetchOlder.value = true;
fetchingOlder.value = false;
}
}, err => {
fetchingOlder.value = false;
});
}
async function fetchNewer(options: {
toQueue?: boolean;
} = {}): Promise<void> {
const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
await misskeyApi<T[]>(props.ctx.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.ctx.offsetMode ? {
offset: items.value.length,
} : {
sinceId: getNewestId(),
}),
}).then(res => {
if (options.toQueue) {
aheadQueue.unshift(...res.toReversed());
if (aheadQueue.length > MAX_QUEUE_ITEMS) {
aheadQueue = aheadQueue.slice(0, MAX_QUEUE_ITEMS);
}
queuedAheadItemsCount.value = aheadQueue.length;
} else {
items.value.unshift(...res.toReversed());
if (props.useShallowRef) triggerRef(items);
}
});
}
function trim() {
if (items.value.length >= MAX_ITEMS) canFetchOlder.value = true;
items.value = items.value.slice(0, MAX_ITEMS);
}
function unshiftItems(newItems: T[]) {
items.value.unshift(...newItems);
if (props.useShallowRef) triggerRef(items);
}
function pushItems(oldItems: T[]) {
items.value.push(...oldItems);
if (props.useShallowRef) triggerRef(items);
}
function prepend(item: T) {
items.value.unshift(item);
if (props.useShallowRef) triggerRef(items);
}
function enqueue(item: T) {
aheadQueue.unshift(item);
if (aheadQueue.length > MAX_QUEUE_ITEMS) {
aheadQueue.pop();
}
queuedAheadItemsCount.value = aheadQueue.length;
}
function releaseQueue() {
unshiftItems(aheadQueue);
aheadQueue = [];
queuedAheadItemsCount.value = 0;
}
function removeItem(id: string) {
// TODO: queueからも消す
const index = items.value.findIndex(x => x.id === id);
if (index !== -1) {
items.value.splice(index, 1);
if (props.useShallowRef) triggerRef(items);
}
}
function updateItem(id: string, updator: (item: T) => T) {
// TODO: queueのも更新
const index = items.value.findIndex(x => x.id === id);
if (index !== -1) {
const item = items.value[index]!;
items.value[index] = updator(item);
if (props.useShallowRef) triggerRef(items);
}
}
onMounted(() => {
init();
});
return {
items,
queuedAheadItemsCount,
fetching,
fetchingOlder,
canFetchOlder,
init,
reload,
fetchOlder,
fetchNewer,
unshiftItems,
prepend,
trim,
removeItem,
updateItem,
enqueue,
releaseQueue,
error,
};
}

View File

@ -25,10 +25,10 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { genEmbedCode } from '@/utility/get-embed-code.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { globalEvents } from '@/events.js';
export async function getNoteClipMenu(props: {
note: Misskey.entities.Note;
isDeleted: Ref<boolean>;
currentClip?: Misskey.entities.Clip;
}) {
function getClipName(clip: Misskey.entities.Clip) {
@ -68,7 +68,6 @@ export async function getNoteClipMenu(props: {
}
}));
});
if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
}
} else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') {
os.alert({
@ -178,7 +177,6 @@ export function getNoteMenu(props: {
note: Misskey.entities.Note;
translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
translating: Ref<boolean>;
isDeleted: Ref<boolean>;
currentClip?: Misskey.entities.Clip;
}) {
const appearNote = getAppearNote(props.note);
@ -194,6 +192,8 @@ export function getNoteMenu(props: {
misskeyApi('notes/delete', {
noteId: appearNote.id,
}).then(() => {
globalEvents.emit('noteDeleted', appearNote.id);
});
if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
@ -211,6 +211,8 @@ export function getNoteMenu(props: {
misskeyApi('notes/delete', {
noteId: appearNote.id,
}).then(() => {
globalEvents.emit('noteDeleted', appearNote.id);
});
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
@ -251,7 +253,6 @@ export function getNoteMenu(props: {
async function unclip(): Promise<void> {
if (!props.currentClip) return;
os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id });
props.isDeleted.value = true;
}
async function promote(): Promise<void> {
@ -569,8 +570,9 @@ export function getRenoteMenu(props: {
misskeyApi('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
}).then(() => {
}).then((res) => {
os.toast(i18n.ts.renoted);
globalEvents.emit('notePosted', res.createdNote);
});
}
},
@ -617,8 +619,9 @@ export function getRenoteMenu(props: {
localOnly,
visibility,
renoteId: appearNote.id,
}).then(() => {
}).then((res) => {
os.toast(i18n.ts.renoted);
globalEvents.emit('notePosted', res.createdNote);
});
}
},
@ -658,8 +661,9 @@ export function getRenoteMenu(props: {
misskeyApi('notes/create', {
renoteId: appearNote.id,
channelId: channel.id,
}).then(() => {
}).then((res) => {
os.toast(i18n.tsx.renotedToX({ name: channel.name }));
globalEvents.emit('notePosted', res.createdNote);
});
}
},

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template>
<div>
<XNotifications :excludeTypes="widgetProps.excludeTypes"/>
<MkStreamingNotificationsTimeline :excludeTypes="widgetProps.excludeTypes"/>
</div>
</MkContainer>
</template>
@ -21,7 +21,7 @@ import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import XNotifications from '@/components/MkNotifications.vue';
import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';

View File

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
</div>
<div v-else>
<MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
<MkStreamingNotesTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
</div>
</MkContainer>
</template>
@ -38,7 +38,7 @@ import type { GetFormResultType } from '@/utility/form.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkContainer from '@/components/MkContainer.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import { i18n } from '@/i18n.js';
import { availableBasicTimelines, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import type { MenuItem } from '@/types/menu.js';

View File

@ -1956,6 +1956,8 @@ declare namespace entities {
NotesSearchByTagResponse,
NotesShowRequest,
NotesShowResponse,
NotesShowPartialBulkRequest,
NotesShowPartialBulkResponse,
NotesStateRequest,
NotesStateResponse,
NotesThreadMutingCreateRequest,
@ -3050,6 +3052,12 @@ type NotesSearchRequest = operations['notes___search']['requestBody']['content']
// @public (undocumented)
type NotesSearchResponse = operations['notes___search']['responses']['200']['content']['application/json'];
// @public (undocumented)
type NotesShowPartialBulkRequest = operations['notes___show-partial-bulk']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesShowPartialBulkResponse = operations['notes___show-partial-bulk']['responses']['200']['content']['application/json'];
// @public (undocumented)
type NotesShowRequest = operations['notes___show']['requestBody']['content']['application/json'];

View File

@ -3758,6 +3758,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *No*
*/
request<E extends 'notes/show-partial-bulk', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@ -512,6 +512,8 @@ import type {
NotesSearchByTagResponse,
NotesShowRequest,
NotesShowResponse,
NotesShowPartialBulkRequest,
NotesShowPartialBulkResponse,
NotesStateRequest,
NotesStateResponse,
NotesThreadMutingCreateRequest,
@ -971,6 +973,7 @@ export type Endpoints = {
'notes/search': { req: NotesSearchRequest; res: NotesSearchResponse };
'notes/search-by-tag': { req: NotesSearchByTagRequest; res: NotesSearchByTagResponse };
'notes/show': { req: NotesShowRequest; res: NotesShowResponse };
'notes/show-partial-bulk': { req: NotesShowPartialBulkRequest; res: NotesShowPartialBulkResponse };
'notes/state': { req: NotesStateRequest; res: NotesStateResponse };
'notes/thread-muting/create': { req: NotesThreadMutingCreateRequest; res: EmptyResponse };
'notes/thread-muting/delete': { req: NotesThreadMutingDeleteRequest; res: EmptyResponse };

View File

@ -515,6 +515,8 @@ export type NotesSearchByTagRequest = operations['notes___search-by-tag']['reque
export type NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json'];
export type NotesShowRequest = operations['notes___show']['requestBody']['content']['application/json'];
export type NotesShowResponse = operations['notes___show']['responses']['200']['content']['application/json'];
export type NotesShowPartialBulkRequest = operations['notes___show-partial-bulk']['requestBody']['content']['application/json'];
export type NotesShowPartialBulkResponse = operations['notes___show-partial-bulk']['responses']['200']['content']['application/json'];
export type NotesStateRequest = operations['notes___state']['requestBody']['content']['application/json'];
export type NotesStateResponse = operations['notes___state']['responses']['200']['content']['application/json'];
export type NotesThreadMutingCreateRequest = operations['notes___thread-muting___create']['requestBody']['content']['application/json'];

View File

@ -3247,6 +3247,15 @@ export type paths = {
*/
post: operations['notes___show'];
};
'/notes/show-partial-bulk': {
/**
* notes/show-partial-bulk
* @description No description provided.
*
* **Credential required**: *No*
*/
post: operations['notes___show-partial-bulk'];
};
'/notes/state': {
/**
* notes/state
@ -25736,6 +25745,59 @@ export type operations = {
};
};
};
/**
* notes/show-partial-bulk
* @description No description provided.
*
* **Credential required**: *No*
*/
'notes___show-partial-bulk': {
requestBody: {
content: {
'application/json': {
noteIds: string[];
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': Record<string, never>[];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* notes/state
* @description No description provided.