Compare commits

...

10 Commits

Author SHA1 Message Date
syuilo 6c45aeec32 wip 2025-05-02 20:54:03 +09:00
syuilo 668fd7b4cd wip 2025-05-02 20:47:50 +09:00
syuilo 85679b33b6 Update MkTimeline.vue 2025-05-02 20:28:13 +09:00
syuilo 894ddd4aa5 Merge branch 'develop' into no-websocket 2025-05-02 20:26:02 +09:00
syuilo c5235a7b2f perf(frontend): improve timeline page performance 2025-05-02 20:25:51 +09:00
syuilo 7b5634908b ✌️ 2025-05-02 16:13:16 +09:00
syuilo ef82d103ff Update MkTimeline.vue 2025-05-02 15:42:28 +09:00
syuilo dcaf020afb Update MkTimeline.vue 2025-05-02 15:03:37 +09:00
syuilo aa4036dcf5 Update MkTimeline.vue 2025-05-02 15:01:54 +09:00
syuilo 8e12553a85 Update style.scss 2025-05-02 15:00:17 +09:00
11 changed files with 142 additions and 24 deletions

View File

@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<slot name="label"></slot> <slot name="label"></slot>
</div> </div>
<div v-adaptive-border class="body"> <div v-adaptive-border class="body">
<slot name="prefix"></slot>
<div ref="containerEl" class="container"> <div ref="containerEl" class="container">
<div class="track"> <div class="track">
<div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div> <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
@ -25,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@touchstart="onMousedown" @touchstart="onMousedown"
></div> ></div>
</div> </div>
<slot name="suffix"></slot>
</div> </div>
<div class="caption"> <div class="caption">
<slot name="caption"></slot> <slot name="caption"></slot>
@ -224,12 +226,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
$thumbWidth: 20px; $thumbWidth: 20px;
> .body { > .body {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 12px; padding: 7px 12px;
background: var(--MI_THEME-panel); background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-panel); border: solid 1px var(--MI_THEME-panel);
border-radius: 6px; border-radius: 6px;
> .container { > .container {
flex: 1;
position: relative; position: relative;
height: $thumbHeight; height: $thumbHeight;

View File

@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</component> </component>
<button v-show="paginator.canFetchMore.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.moreFetching.value" class="_button" :class="$style.more" @click="paginator.fetchOlder"> <button v-show="paginator.canFetchMore.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.moreFetching.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.moreFetching.value">{{ i18n.ts.loadMore }}</div> <div v-if="!paginator.moreFetching.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else/> <MkLoading v-else :inline="true"/>
</button> </button>
</div> </div>
</MkPullToRefresh> </MkPullToRefresh>
@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref } from 'vue'; import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js'; import { useInterval } from '@@/js/use-interval.js';
import { isHeadVisible, scrollToTop } from '@@/js/scroll.js'; import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
import type { BasicTimelineType } from '@/timelines.js'; import type { BasicTimelineType } from '@/timelines.js';
import type { PagingCtx } from '@/use/use-pagination.js'; import type { PagingCtx } from '@/use/use-pagination.js';
import { usePagination } from '@/use/use-pagination.js'; import { usePagination } from '@/use/use-pagination.js';
@ -91,14 +91,39 @@ const props = withDefaults(defineProps<{
onlyFiles: false, onlyFiles: false,
}); });
const emit = defineEmits<{
}>();
provide('inTimeline', true); provide('inTimeline', true);
provide('tl_withSensitive', computed(() => props.withSensitive)); provide('tl_withSensitive', computed(() => props.withSensitive));
provide('inChannel', computed(() => props.src === 'channel')); provide('inChannel', computed(() => props.src === 'channel'));
function isTop() {
if (scrollContainer == null) return false;
if (rootEl.value == null) return false;
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'); const rootEl = useTemplateRef('rootEl');
watch(rootEl, (el) => {
if (el && scrollContainer == null) {
scrollContainer = getScrollContainer(el)!;
scrollContainer.addEventListener('scroll', onScrollContainerScroll, { passive: true }); // scrollendios
}
}, { immediate: true });
onUnmounted(() => {
if (scrollContainer) {
scrollContainer.removeEventListener('scroll', onScrollContainerScroll);
}
});
type TimelineQueryType = { type TimelineQueryType = {
antennaId?: string, antennaId?: string,
@ -121,10 +146,10 @@ const POLLING_INTERVAL =
MIN_POLLING_INTERVAL; MIN_POLLING_INTERVAL;
if (!store.s.realtimeMode) { if (!store.s.realtimeMode) {
// TODO: 1TL
useInterval(async () => { useInterval(async () => {
const isTop = rootEl.value == null ? false : isHeadVisible(rootEl.value, 16);
paginator.fetchNewer({ paginator.fetchNewer({
toQueue: !isTop, toQueue: !isTop(),
}); });
}, POLLING_INTERVAL, { }, POLLING_INTERVAL, {
immediate: false, immediate: false,
@ -133,9 +158,8 @@ if (!store.s.realtimeMode) {
} }
globalEvents.on('notePosted', (note: Misskey.entities.Note) => { globalEvents.on('notePosted', (note: Misskey.entities.Note) => {
const isTop = rootEl.value == null ? false : isHeadVisible(rootEl.value, 16);
paginator.fetchNewer({ paginator.fetchNewer({
toQueue: !isTop, toQueue: !isTop(),
}); });
}); });
@ -151,8 +175,7 @@ function prepend(note: Misskey.entities.Note) {
note._shouldInsertAd_ = true; note._shouldInsertAd_ = true;
} }
const isTop = isHeadVisible(rootEl.value, 16); if (isTop()) {
if (isTop) {
paginator.prepend(note); paginator.prepend(note);
} else { } else {
paginator.enqueue(note); paginator.enqueue(note);
@ -456,7 +479,7 @@ defineExpose({
margin: auto; margin: auto;
background: var(--MI_THEME-accent); background: var(--MI_THEME-accent);
color: var(--MI_THEME-fgOnAccent); color: var(--MI_THEME-fgOnAccent);
font-size: 85%; font-size: 90%;
&:hover { &:hover {
background: hsl(from var(--MI_THEME-accent) h s calc(l + 5)); background: hsl(from var(--MI_THEME-accent) h s calc(l + 5));

View File

@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<SearchMarker :keywords="['realtimemode']"> <SearchMarker :keywords="['realtimemode']">
<MkSwitch v-model="realtimeMode"> <MkSwitch v-model="realtimeMode">
<template #label><SearchLabel>{{ i18n.ts.realtimeMode }}</SearchLabel></template> <template #label><i class="ti ti-bolt"></i> <SearchLabel>{{ i18n.ts.realtimeMode }}</SearchLabel></template>
<template #caption><SearchKeyword>{{ i18n.ts._settings.realtimeMode_description }}</SearchKeyword></template> <template #caption><SearchKeyword>{{ i18n.ts._settings.realtimeMode_description }}</SearchKeyword></template>
</MkSwitch> </MkSwitch>
</SearchMarker> </SearchMarker>
@ -51,9 +51,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkDisableSection :disabled="realtimeMode"> <MkDisableSection :disabled="realtimeMode">
<SearchMarker :keywords="['polling', 'interval']"> <SearchMarker :keywords="['polling', 'interval']">
<MkPreferenceContainer k="pollingInterval"> <MkPreferenceContainer k="pollingInterval">
<MkRange v-model="pollingInterval" :min="1" :max="3" :step="1" easing :textConverter="(v) => v === 1 ? i18n.ts.low : v === 2 ? i18n.ts.middle : v === 3 ? i18n.ts.high : ''"> <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 #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 #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> </MkRange>
</MkPreferenceContainer> </MkPreferenceContainer>
</SearchMarker> </SearchMarker>

View File

@ -9,6 +9,7 @@ import type { RouteDef } from '@/lib/nirax.js';
import { $i, iAmModerator } from '@/i.js'; import { $i, iAmModerator } from '@/i.js';
import MkLoading from '@/pages/_loading_.vue'; import MkLoading from '@/pages/_loading_.vue';
import MkError from '@/pages/_error_.vue'; import MkError from '@/pages/_error_.vue';
import PageTimeline from '@/pages/timeline.vue';
export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
loader: loader, loader: loader,
@ -21,6 +22,13 @@ function chatPage(...args: Parameters<typeof page>) {
} }
export const ROUTE_DEF = [{ export const ROUTE_DEF = [{
name: 'index',
path: '/',
component: $i ? PageTimeline : page(() => import('@/pages/welcome.vue')),
}, {
path: '/timeline',
component: PageTimeline,
}, {
path: '/@:username/pages/:pageName(*)', path: '/@:username/pages/:pageName(*)',
component: page(() => import('@/pages/page.vue')), component: page(() => import('@/pages/page.vue')),
}, { }, {
@ -579,13 +587,6 @@ export const ROUTE_DEF = [{
path: '/reversi/g/:gameId', path: '/reversi/g/:gameId',
component: page(() => import('@/pages/reversi/game.vue')), component: page(() => import('@/pages/reversi/game.vue')),
loginRequired: false, loginRequired: false,
}, {
path: '/timeline',
component: page(() => import('@/pages/timeline.vue')),
}, {
name: 'index',
path: '/',
component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')),
}, { }, {
// テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする // テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする
path: '/redirect-test', path: '/redirect-test',

View File

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

View File

@ -54,7 +54,7 @@ export function usePagination<T extends MisskeyEntity>(props: {
// パラメータに何らかの変更があった際、再読込したいチャンネル等のIDが変わったなど // パラメータに何らかの変更があった際、再読込したいチャンネル等のIDが変わったなど
watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true }); watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true });
function getNewestId() { function getNewestId(): string | null | undefined {
// 様々な要因により並び順は保証されないのでソートが必要 // 様々な要因により並び順は保証されないのでソートが必要
if (aheadQueue.length > 0) { if (aheadQueue.length > 0) {
return aheadQueue.map(x => x.id).sort().at(-1); return aheadQueue.map(x => x.id).sort().at(-1);
@ -62,7 +62,7 @@ export function usePagination<T extends MisskeyEntity>(props: {
return items.value.map(x => x.id).sort().at(-1); return items.value.map(x => x.id).sort().at(-1);
} }
function getOldestId() { function getOldestId(): string | null | undefined {
// 様々な要因により並び順は保証されないのでソートが必要 // 様々な要因により並び順は保証されないのでソートが必要
return items.value.map(x => x.id).sort().at(0); return items.value.map(x => x.id).sort().at(0);
} }

View File

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

View File

@ -3758,6 +3758,17 @@ declare module '../api.js' {
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): 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. * No description provided.
* *

View File

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

View File

@ -515,6 +515,8 @@ export type NotesSearchByTagRequest = operations['notes___search-by-tag']['reque
export type NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json']; export type NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json'];
export type NotesShowRequest = operations['notes___show']['requestBody']['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 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 NotesStateRequest = operations['notes___state']['requestBody']['content']['application/json'];
export type NotesStateResponse = operations['notes___state']['responses']['200']['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']; export type NotesThreadMutingCreateRequest = operations['notes___thread-muting___create']['requestBody']['content']['application/json'];

View File

@ -3247,6 +3247,15 @@ export type paths = {
*/ */
post: operations['notes___show']; 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': {
/** /**
* 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 * notes/state
* @description No description provided. * @description No description provided.