Merge 12230d7c24
into d522d1bf26
This commit is contained in:
commit
96388989cc
|
@ -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": {
|
||||
/**
|
||||
* 送信者の名前を表示
|
||||
|
|
|
@ -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: "送信者の名前を表示"
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}>(), {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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('');
|
||||
|
|
|
@ -6,11 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div
|
||||
v-if="!hardMuted && muted === false"
|
||||
v-show="!isDeleted"
|
||||
ref="rootEl"
|
||||
v-hotkey="keymap"
|
||||
:class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]"
|
||||
:tabindex="isDeleted ? '-1' : '0'"
|
||||
tabindex="0"
|
||||
>
|
||||
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
|
||||
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
|
||||
|
@ -87,7 +86,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/>
|
||||
<MkPoll
|
||||
v-if="appearNote.poll"
|
||||
:noteId="appearNote.id"
|
||||
:multiple="appearNote.poll.multiple"
|
||||
:expiresAt="appearNote.poll.expiresAt"
|
||||
:choices="$appearNote.pollChoices"
|
||||
:author="appearNote.user"
|
||||
:emojiUrls="appearNote.emojis"
|
||||
:class="$style.poll"
|
||||
/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
|
||||
</div>
|
||||
|
@ -101,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
|
||||
</div>
|
||||
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" style="margin-top: 6px;" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction">
|
||||
<MkReactionsViewer
|
||||
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
|
||||
style="margin-top: 6px;"
|
||||
:reactions="$appearNote.reactions"
|
||||
:reactionEmojis="$appearNote.reactionEmojis"
|
||||
:myReaction="$appearNote.myReaction"
|
||||
:noteId="appearNote.id"
|
||||
:maxNumber="16"
|
||||
@mockUpdateMyReaction="emitUpdReaction"
|
||||
>
|
||||
<template #more>
|
||||
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
|
||||
</template>
|
||||
|
@ -125,11 +142,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
|
||||
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
|
||||
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
|
||||
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||
<i v-else class="ti ti-plus"></i>
|
||||
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
|
||||
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number($appearNote.reactionCount) }}</p>
|
||||
</button>
|
||||
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
|
@ -176,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue';
|
||||
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide, shallowRef, reactive } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
|
@ -210,7 +227,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
|||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
|
||||
import { useNoteCapture } from '@/use/use-note-capture.js';
|
||||
import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { useTooltip } from '@/use/use-tooltip.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
|
@ -223,6 +240,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
|
|||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
@ -245,29 +263,33 @@ const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
|||
const inChannel = inject('inChannel', null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
|
||||
const note = ref(deepClone(props.note));
|
||||
let note = deepClone(props.note);
|
||||
|
||||
// plugin
|
||||
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
onMounted(async () => {
|
||||
let result: Misskey.entities.Note | null = deepClone(note.value);
|
||||
let result: Misskey.entities.Note | null = deepClone(note);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = await interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
if (result === null) {
|
||||
isDeleted.value = true;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
note.value = result as Misskey.entities.Note;
|
||||
note = result as Misskey.entities.Note;
|
||||
});
|
||||
}
|
||||
|
||||
const isRenote = Misskey.note.isPureRenote(note.value);
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note);
|
||||
const $appearNote = reactive({
|
||||
reactions: appearNote.reactions,
|
||||
reactionCount: appearNote.reactionCount,
|
||||
reactionEmojis: appearNote.reactionEmojis,
|
||||
myReaction: appearNote.myReaction,
|
||||
pollChoices: appearNote.poll?.choices,
|
||||
});
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const menuButton = useTemplateRef('menuButton');
|
||||
|
@ -275,32 +297,30 @@ const renoteButton = useTemplateRef('renoteButton');
|
|||
const renoteTime = useTemplateRef('renoteTime');
|
||||
const reactButton = useTemplateRef('reactButton');
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const appearNote = computed(() => getAppearNote(note.value));
|
||||
const galleryEl = useTemplateRef('galleryEl');
|
||||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
|
||||
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
|
||||
const collapsed = ref(appearNote.value.cw == null && isLong);
|
||||
const isDeleted = ref(false);
|
||||
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
|
||||
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
|
||||
const isLong = shouldCollapsed(appearNote, urls.value ?? []);
|
||||
const collapsed = ref(appearNote.cw == null && isLong);
|
||||
const muted = ref(checkMute(appearNote, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
|
||||
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
|
||||
const renoteCollapsed = ref(
|
||||
prefer.s.collapseRenotes && isRenote && (
|
||||
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
(appearNote.value.myReaction != null)
|
||||
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
($appearNote.myReaction != null)
|
||||
),
|
||||
);
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.value.id}`,
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
}));
|
||||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
|
@ -357,7 +377,7 @@ const keymap = {
|
|||
'v|enter': () => {
|
||||
if (renoteCollapsed.value) {
|
||||
renoteCollapsed.value = false;
|
||||
} else if (appearNote.value.cw != null) {
|
||||
} else if (appearNote.cw != null) {
|
||||
showContent.value = !showContent.value;
|
||||
} else if (isLong) {
|
||||
collapsed.value = !collapsed.value;
|
||||
|
@ -380,28 +400,28 @@ const keymap = {
|
|||
provide(DI.mfmEmojiReactCallback, (reaction) => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (props.mock) {
|
||||
watch(() => props.note, (to) => {
|
||||
note.value = deepClone(to);
|
||||
}, { deep: true });
|
||||
} else {
|
||||
if (!props.mock) {
|
||||
useNoteCapture({
|
||||
rootEl: rootEl,
|
||||
note: appearNote,
|
||||
pureNote: note,
|
||||
isDeletedRef: isDeleted,
|
||||
parentNote: note,
|
||||
$note: $appearNote,
|
||||
});
|
||||
}
|
||||
|
||||
if (!props.mock) {
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
|
@ -412,19 +432,19 @@ if (!props.mock) {
|
|||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.value.renoteCount,
|
||||
count: appearNote.renoteCount,
|
||||
targetElement: renoteButton.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: appearNote.value.reactionCount,
|
||||
_cacheKey_: $appearNote.reactionCount,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
@ -435,7 +455,7 @@ if (!props.mock) {
|
|||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: appearNote.value.reactionCount,
|
||||
count: $appearNote.reactionCount,
|
||||
targetElement: reactButton.value!,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
|
@ -448,7 +468,7 @@ function renote(viaKeyboard = false) {
|
|||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
|
||||
os.popupMenu(menu, renoteButton.value, {
|
||||
viaKeyboard,
|
||||
});
|
||||
|
@ -460,8 +480,8 @@ function reply(): void {
|
|||
return;
|
||||
}
|
||||
os.post({
|
||||
reply: appearNote.value,
|
||||
channel: appearNote.value.channel,
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
|
@ -470,7 +490,7 @@ function reply(): void {
|
|||
function react(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
if (props.mock) {
|
||||
|
@ -478,8 +498,13 @@ function react(): void {
|
|||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: '❤️',
|
||||
});
|
||||
});
|
||||
const el = reactButton.value;
|
||||
if (el && prefer.s.animation) {
|
||||
|
@ -492,7 +517,7 @@ function react(): void {
|
|||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
|
||||
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
|
@ -506,14 +531,23 @@ function react(): void {
|
|||
|
||||
if (props.mock) {
|
||||
emit('reaction', reaction);
|
||||
$appearNote.reactions[reaction] = 1;
|
||||
$appearNote.reactionCount++;
|
||||
$appearNote.myReaction = reaction;
|
||||
return;
|
||||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
|
||||
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
|
@ -522,8 +556,8 @@ function react(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function undoReact(targetNote: Misskey.entities.Note): void {
|
||||
const oldReaction = targetNote.myReaction;
|
||||
function undoReact(): void {
|
||||
const oldReaction = $appearNote.myReaction;
|
||||
if (!oldReaction) return;
|
||||
|
||||
if (props.mock) {
|
||||
|
@ -532,15 +566,15 @@ function undoReact(targetNote: Misskey.entities.Note): void {
|
|||
}
|
||||
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: targetNote.id,
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleReact() {
|
||||
if (appearNote.value.myReaction == null) {
|
||||
if ($appearNote.myReaction == null) {
|
||||
react();
|
||||
} else {
|
||||
undoReact(appearNote.value);
|
||||
undoReact();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -556,7 +590,7 @@ function onContextmenu(ev: MouseEvent): void {
|
|||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value });
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
@ -566,7 +600,7 @@ function showMenu(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value });
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
|
@ -575,7 +609,7 @@ async function clip(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
|
@ -590,9 +624,10 @@ function showRenoteMenu(): void {
|
|||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.value.id,
|
||||
noteId: note.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('noteDeleted', note.id);
|
||||
});
|
||||
isDeleted.value = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -601,23 +636,23 @@ function showRenoteMenu(): void {
|
|||
type: 'link',
|
||||
text: i18n.ts.renoteDetails,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: notePage(note.value),
|
||||
to: notePage(note),
|
||||
};
|
||||
|
||||
if (isMyRenote) {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
os.popupMenu([
|
||||
renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getUnrenote(),
|
||||
], renoteTime.value);
|
||||
} else {
|
||||
os.popupMenu([
|
||||
renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
|
||||
getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
|
||||
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
|
||||
], renoteTime.value);
|
||||
}
|
||||
|
@ -641,9 +676,8 @@ function focusAfter() {
|
|||
|
||||
function readPromo() {
|
||||
misskeyApi('promo/read', {
|
||||
noteId: appearNote.value.id,
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
isDeleted.value = true;
|
||||
}
|
||||
|
||||
function emitUpdReaction(emoji: string, delta: number) {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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; // heightのtransitionを動作させるために必要
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.transition_x_leaveTo {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.notifications {
|
||||
container-type: inline-size;
|
||||
background: var(--MI_THEME-panel);
|
||||
}
|
||||
|
||||
.item {
|
||||
border-bottom: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
</style>
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、
|
||||
* APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
|
||||
*/
|
||||
const fetchMoreApperTimeoutFn = (): void => {
|
||||
preventAppearFetchMore.value = false;
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
};
|
||||
const fetchMoreAppearTimeout = (): void => {
|
||||
preventAppearFetchMore.value = true;
|
||||
preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
|
||||
};
|
||||
|
||||
const appearFetchMore = async (): Promise<void> => {
|
||||
if (preventAppearFetchMore.value) return;
|
||||
await fetchMore();
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
const appearFetchMoreAhead = async (): Promise<void> => {
|
||||
if (preventAppearFetchMore.value) return;
|
||||
await fetchMoreAhead();
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
|
||||
|
||||
watch(visibility, () => {
|
||||
if (visibility.value === 'hidden') {
|
||||
timerForSetPause = window.setTimeout(() => {
|
||||
isPausingUpdate = true;
|
||||
timerForSetPause = null;
|
||||
},
|
||||
BACKGROUND_PAUSE_WAIT_SEC * 1000);
|
||||
} else { // 'visible'
|
||||
if (timerForSetPause) {
|
||||
window.clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
} else {
|
||||
isPausingUpdate = false;
|
||||
if (isHead()) {
|
||||
executeQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 最新のものとして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);
|
||||
|
||||
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
|
||||
// more = trueを遅らせる
|
||||
window.setTimeout(() => {
|
||||
moreFetching.value = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timerForSetPause) {
|
||||
window.clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
}
|
||||
if (preventAppearFetchMoreTimer.value) {
|
||||
window.clearTimeout(preventAppearFetchMoreTimer.value);
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
}
|
||||
scrollObserver.value?.disconnect();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
items,
|
||||
queue,
|
||||
backed: backed.value,
|
||||
more,
|
||||
reload,
|
||||
prepend,
|
||||
append: appendItem,
|
||||
removeItem,
|
||||
updateItem,
|
||||
paginator: paginator,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 }); // ほんとはscrollendにしたいけどiosが非対応
|
||||
}
|
||||
}, { 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: 先頭のノートの作成日時が1日以上前であれば流速が遅いTLと見做してインターバルを通常より延ばす
|
||||
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>
|
|
@ -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; // heightのtransitionを動作させるために必要
|
||||
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>
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
}>(), {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
/**
|
||||
* グリッドの最も上位にある状態。
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -87,7 +87,7 @@ const pagination = {
|
|||
};
|
||||
|
||||
function resolved(reportId) {
|
||||
reports.value?.removeItem(reportId);
|
||||
reports.value?.paginator.removeItem(reportId);
|
||||
}
|
||||
|
||||
function closeTutorial() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -121,7 +121,7 @@ async function addUser() {
|
|||
username: username,
|
||||
password: password,
|
||||
}).then(res => {
|
||||
paginationComponent.value?.reload();
|
||||
paginationComponent.value?.paginator.reload();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 : '');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(() => [{
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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(' ') : '');
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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(() => []);
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(() => [{
|
||||
|
|
|
@ -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[] = [
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -309,7 +309,6 @@ rt {
|
|||
max-width: 100%;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue