Compare commits

..

27 Commits

Author SHA1 Message Date
tamaina
a926e84b14 Merge branch 'develop' into fetch-outbox 2023-09-16 08:33:23 +00:00
tamaina
df636659e2 Merge branch 'develop' into fetch-outbox 2023-09-11 06:32:04 +00:00
tamaina
888cd2eb9f Merge branch 'develop' into fetch-outbox 2023-08-24 06:23:05 +00:00
Kagami Sascha Rosylight
ff4b3d2d9e Merge branch 'develop' into fetch-outbox 2023-08-06 00:04:26 +02:00
tamaina
60fd848182 configable 2023-07-15 14:24:05 +00:00
tamaina
76def0032e ? 2023-07-15 14:06:36 +00:00
tamaina
da0804eb17 fix type error 2023-07-15 14:05:05 +00:00
tamaina
6b26ce3768 Merge branch 'develop' into fetch-outbox 2023-07-15 14:04:54 +00:00
tamaina
5a0d7d41e6 Merge branch 'develop' into fetch-outbox 2023-07-14 13:36:04 +09:00
Kagami Sascha Rosylight
08e2b6ee32 perform only create activities 2023-07-09 23:34:07 +02:00
Kagami Sascha Rosylight
ca0c673b44 Update activitypub.ts 2023-07-09 22:39:02 +02:00
Kagami Sascha Rosylight
70bb9a4d1f Update activitypub.ts 2023-07-09 22:36:41 +02:00
Kagami Sascha Rosylight
b93046c071 Update activitypub.ts 2023-07-09 22:36:27 +02:00
Kagami Sascha Rosylight
f34f0dfcb6 Merge remote-tracking branch 'origin/develop' into fetch-outbox 2023-07-09 22:36:14 +02:00
Kagami Sascha Rosylight
26040c2bb0 Update activitypub.ts 2023-07-09 22:36:07 +02:00
Kagami Sascha Rosylight
bdbad4605b Merge branch 'develop' into fetch-outbox 2023-07-09 15:04:12 +02:00
Kagami Sascha Rosylight
ec62fe02b1 Merge branch 'develop' into fetch-outbox 2023-07-09 00:08:54 +02:00
Kagami Sascha Rosylight
a74af07992 reformat 2023-07-08 15:48:10 +02:00
Kagami Sascha Rosylight
aa78c29e8c Merge remote-tracking branch 'origin/develop' into fetch-outbox 2023-07-08 15:47:00 +02:00
Kagami Sascha Rosylight
45d0b46e7a typo 2023-07-08 15:44:27 +02:00
Kagami Sascha Rosylight
6087d02047 adjust tests 2023-07-08 15:40:48 +02:00
Kagami Sascha Rosylight
7bf318ae98 remove extra resolving in tests 2023-07-08 15:13:23 +02:00
tamaina
71d74676f0 Merge branch 'develop' into fetch-outbox 2023-07-07 14:54:28 +09:00
tamaina
5077df2973 100 -> 15 2023-07-07 05:02:28 +00:00
Kagami Sascha Rosylight
a1388a8444 Update CHANGELOG.md 2023-07-06 03:39:16 +02:00
Kagami Sascha Rosylight
630e97bd06 Merge remote-tracking branch 'origin/develop' into fetch-outbox 2023-07-06 03:38:13 +02:00
Kagami Sascha Rosylight
2e1de4fca9 feat(backend): fetch the first page of outbox when resolving Person 2023-07-06 02:56:17 +02:00
35 changed files with 650 additions and 546 deletions

View File

@@ -8,7 +8,7 @@
-
### Server
-
- 最初照会したユーザーの最新ノートを受け取るように
-->

View File

@@ -187,6 +187,10 @@ id: "aidx"
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true
# Limit of notes to fetch from outbox with remote user first fetched (default: 5)
# https://github.com/misskey-dev/misskey/pull/11130
outboxNotesFetchLimit: 5
#allowedPrivateNetworks: [
# '127.0.0.1/32'
#]

7
locales/index.d.ts vendored
View File

@@ -533,7 +533,7 @@ export interface Locale {
"deleteAll": string;
"showFixedPostForm": string;
"showFixedPostFormInChannel": string;
"goToTheHeadOfTimeline": string;
"newNoteRecived": string;
"sounds": string;
"sound": string;
"listen": string;
@@ -1103,7 +1103,6 @@ export interface Locale {
"doYouAgree": string;
"beSureToReadThisAsItIsImportant": string;
"iHaveReadXCarefullyAndAgree": string;
"timelineBackTopBehavior": string;
"dialog": string;
"icon": string;
"forYou": string;
@@ -1673,10 +1672,6 @@ export interface Locale {
"dialog": string;
"quiet": string;
};
"_timelineBackTopBehavior": {
"newest": string;
"next": string;
};
"_channel": {
"create": string;
"edit": string;

View File

@@ -530,7 +530,7 @@ serverLogs: "サーバーログ"
deleteAll: "全て削除"
showFixedPostForm: "タイムライン上部に投稿フォームを表示する"
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
goToTheHeadOfTimeline: "最新のノートに移動"
newNoteRecived: "新しいノートがあります"
sounds: "サウンド"
sound: "サウンド"
listen: "聴く"
@@ -1100,7 +1100,6 @@ expired: "期限切れ"
doYouAgree: "同意しますか?"
beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。"
iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。"
timelineBackTopBehavior: "タイムラインのスクロールが先頭に戻った時の挙動"
dialog: "ダイアログ"
icon: "アイコン"
forYou: "あなたへ"
@@ -1590,10 +1589,6 @@ _serverDisconnectedBehavior:
dialog: "ダイアログで警告"
quiet: "控えめに警告"
_timelineBackTopBehavior:
newest: "最新の投稿を表示"
next: "次の投稿を遡る"
_channel:
create: "チャンネルを作成"
edit: "チャンネルを編集"

View File

@@ -85,6 +85,7 @@ type Source = {
videoThumbnailGenerator?: string;
signToActivityPubGet?: boolean;
outboxNotesFetchLimit?: number;
perChannelMaxNoteCacheCount?: number;
perUserNotificationsMaxCount?: number;

View File

@@ -27,7 +27,7 @@ import { QueueService } from '@/core/QueueService.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import type { MiRemoteUser } from '@/models/entities/User.js';
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isOrderedCollectionPage, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
import { ApDbResolverService } from './ApDbResolverService.js';
@@ -87,11 +87,19 @@ export class ApInboxService {
}
@bindThis
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<void> {
if (isCollectionOrOrderedCollection(activity)) {
public async performActivity(actor: MiRemoteUser, activity: IObject, {
limit = Infinity,
allow = null as (string[] | null) } = {},
): Promise<void> {
if (isCollectionOrOrderedCollection(activity) || isOrderedCollectionPage(activity)) {
const resolver = this.apResolverService.createResolver();
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems).slice(0, limit)) {
const act = await resolver.resolve(item);
const type = getApType(act);
if (allow && !allow.includes(type)) {
this.logger.info(`skipping activity type: ${type}`);
continue;
}
try {
await this.performOneActivity(actor, act);
} catch (err) {
@@ -367,7 +375,7 @@ export class ApInboxService {
});
if (isPost(object)) {
this.createNote(resolver, actor, object, false, activity);
await this.createNote(resolver, actor, object, false, activity);
} else {
this.logger.warn(`Unknown type: ${getApType(object)}`);
}

View File

@@ -15,11 +15,11 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { isCollectionOrOrderedCollection } from './type.js';
import { isCollectionOrOrderedCollection, isOrderedCollectionPage } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
import type { IObject, ICollection, IOrderedCollection, IOrderedCollectionPage } from './type.js';
export class Resolver {
private history: Set<string>;
@@ -64,6 +64,18 @@ export class Resolver {
}
}
public async resolveOrderedCollectionPage(value: string | IObject): Promise<IOrderedCollectionPage> {
const collection = typeof value === 'string'
? await this.resolve(value)
: value;
if (isOrderedCollectionPage(collection)) {
return collection;
} else {
throw new Error(`unrecognized collection type: ${collection.type}`);
}
}
@bindThis
public async resolve(value: string | IObject): Promise<IObject> {
if (typeof value !== 'string') {

View File

@@ -38,7 +38,8 @@ import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage, isPropertyValue } from '../type.js';
import { ApInboxService } from '../ApInboxService.js';
import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common';
import type { ApNoteService } from './ApNoteService.js';
@@ -68,6 +69,7 @@ export class ApPersonService implements OnModuleInit {
private apResolverService: ApResolverService;
private apNoteService: ApNoteService;
private apImageService: ApImageService;
private apInboxService: ApInboxService;
private apMfmService: ApMfmService;
private mfmService: MfmService;
private hashtagService: HashtagService;
@@ -116,6 +118,7 @@ export class ApPersonService implements OnModuleInit {
this.apResolverService = this.moduleRef.get('ApResolverService');
this.apNoteService = this.moduleRef.get('ApNoteService');
this.apImageService = this.moduleRef.get('ApImageService');
this.apInboxService = this.moduleRef.get('ApInboxService');
this.apMfmService = this.moduleRef.get('ApMfmService');
this.mfmService = this.moduleRef.get('MfmService');
this.hashtagService = this.moduleRef.get('HashtagService');
@@ -384,7 +387,10 @@ export class ApPersonService implements OnModuleInit {
}
//#endregion
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
await Promise.allSettled([
this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)),
this.updateOutboxFirstPage(user, person.outbox, resolver).catch(err => this.logger.error(err)),
]);
return user;
}
@@ -589,7 +595,7 @@ export class ApPersonService implements OnModuleInit {
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x)));
// Resolve and regist Notes
// Resolve and register Notes
const limit = promiseLimit<MiNote | null>(2);
const featuredNotes = await Promise.all(items
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
@@ -616,6 +622,35 @@ export class ApPersonService implements OnModuleInit {
});
}
/**
* Retrieve outbox from an actor object.
*
* This only retrieves the first page for now.
*/
public async updateOutboxFirstPage(user: RemoteUser, outbox: IActor['outbox'], resolver: Resolver): Promise<void> {
if (!this.config.outboxNotesFetchLimit) return;
// https://www.w3.org/TR/activitypub/#actor-objects
// Outbox is a required property for all actors
if (!outbox) {
throw new Error('No outbox property');
}
this.logger.info(`Fetching the outbox for ${user.uri}: ${outbox}`);
const collection = await resolver.resolveCollection(outbox);
if (!isOrderedCollection(collection)) {
throw new Error('Outbox must be an ordered collection');
}
const firstPage = collection.first ?
await resolver.resolveOrderedCollectionPage(collection.first) :
collection;
// Perform activity but only the first outboxNotesFetchLimit ones with `type: Create`
await this.apInboxService.performActivity(user, firstPage, { limit: this.config.outboxNotesFetchLimit, allow: ['Create'] });
}
/**
* リモート由来のアカウント移行処理を行います
* @param src 移行元アカウントリモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提

View File

@@ -92,16 +92,37 @@ export interface IActivity extends IObject {
};
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection
export interface ICollection extends IObject {
type: 'Collection';
totalItems: number;
current?: ICollectionPage | string;
first?: ICollectionPage | string;
last?: ICollectionPage | string;
items: ApObject;
}
export interface IOrderedCollection extends IObject {
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection
export interface IOrderedCollection extends Omit<ICollection, 'type' | 'items'> {
type: 'OrderedCollection';
totalItems: number;
orderedItems: ApObject;
// orderedItems is not defined well
// https://github.com/w3c/activitystreams/issues/494
orderedItems?: ApObject;
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
export interface ICollectionPage extends Omit<ICollection, 'type'> {
type: 'CollectionPage';
partOf?: ICollection | string;
next?: ICollectionPage | string;
prev?: ICollectionPage | string;
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollectionpage
export interface IOrderedCollectionPage extends Omit<IOrderedCollection, 'type'>, Omit<ICollectionPage, 'type' | 'items'> {
type: 'OrderedCollectionPage';
startIndex?: number,
}
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
@@ -188,6 +209,9 @@ export const isCollection = (object: IObject): object is ICollection =>
export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
getApType(object) === 'OrderedCollection';
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
getApType(object) === 'OrderedCollectionPage';
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
isCollection(object) || isOrderedCollection(object);

View File

@@ -68,7 +68,7 @@ export class MockResolver extends Resolver {
const r = this.#responseMap.get(value);
if (!r) {
throw new Error('Not registed for mock');
throw new Error('Not registered for mock');
}
const object = JSON.parse(r.content);

View File

@@ -17,7 +17,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { IActor, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js';
import type { IActivity, IApDocument, IActor, ICollection, IObject, IOrderedCollection, IOrderedCollectionPage, IPost } from '@/core/activitypub/type.js';
import { MiMeta, MiNote } from '@/models/_.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js';
@@ -29,6 +29,16 @@ const host = 'https://host1.test';
type NonTransientIActor = IActor & { id: string };
type NonTransientIPost = IPost & { id: string };
type NonTransientICollection = ICollection & { id: string };
type NonTransientIOrderedCollection = IOrderedCollection & { id: string };
type NonTransientIOrderedCollectionPage = IOrderedCollectionPage & { id: string };
/**
* Use when the order of the array is not definitive
*/
function deepSortedEqual<T extends unknown[]>(array1: unknown[], array2: T): asserts array1 is T {
return assert.deepStrictEqual(array1.sort(), array2.sort());
}
function createRandomActor({ actorHost = host } = {}): NonTransientIActor {
const preferredUsername = secureRndstr(8);
@@ -60,7 +70,7 @@ function createRandomNotes(actor: NonTransientIActor, length: number): NonTransi
return new Array(length).fill(null).map(() => createRandomNote(actor));
}
function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): ICollection {
function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): NonTransientICollection {
const items = createRandomNotes(actor, length);
return {
@@ -72,6 +82,53 @@ function createRandomFeaturedCollection(actor: NonTransientIActor, length: numbe
};
}
function createRandomActivities(actor: NonTransientIActor, type: string, length: number): IActivity[] {
return new Array(length).fill(null).map((): IActivity => {
const note = createRandomNote(actor);
return {
type,
id: `${note.id}/activity`,
actor,
object: note,
};
});
}
function createRandomNonPagedOutbox(actor: NonTransientIActor, length: number): NonTransientIOrderedCollection {
const orderedItems = createRandomActivities(actor, 'Create', length);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: actor.outbox as string,
totalItems: orderedItems.length,
orderedItems,
};
}
function createRandomOutboxPage(actor: NonTransientIActor, id: string, length: number): NonTransientIOrderedCollectionPage {
const orderedItems = createRandomActivities(actor, 'Create', length);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollectionPage',
id,
totalItems: orderedItems.length,
orderedItems,
};
}
function createRandomPagedOutbox(actor: NonTransientIActor): NonTransientIOrderedCollection {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: actor.outbox as string,
totalItems: 10,
first: `${actor.outbox}?first`,
};
}
async function createRandomRemoteUser(
resolver: MockResolver,
personService: ApPersonService,
@@ -196,7 +253,7 @@ describe('ActivityPub', () => {
describe('Renderer', () => {
test('Render an announce with visibility: followers', () => {
rendererService.renderAnnounce(null, {
rendererService.renderAnnounce('hoge', {
createdAt: new Date(0),
visibility: 'followers',
} as MiNote);
@@ -216,7 +273,7 @@ describe('ActivityPub', () => {
await personService.createPerson(actor.id, resolver);
// All notes in `featured` are same-origin, no need to fetch notes again
assert.deepStrictEqual(resolver.remoteGetTrials(), [actor.id, actor.featured]);
deepSortedEqual(resolver.remoteGetTrials(), [actor.id, actor.featured, actor.outbox]);
// Created notes without resolving anything
for (const item of featured.items as IPost[]) {
@@ -247,9 +304,9 @@ describe('ActivityPub', () => {
await personService.createPerson(actor1.id, resolver);
// actor2Note is from a different server and needs to be fetched again
assert.deepStrictEqual(
deepSortedEqual(
resolver.remoteGetTrials(),
[actor1.id, actor1.featured, actor2Note.id, actor2.id],
[actor1.id, actor1.featured, actor1.outbox, actor2Note.id, actor2.id, actor2.outbox],
);
const note = await noteService.fetchNote(actor2Note.id);
@@ -276,6 +333,95 @@ describe('ActivityPub', () => {
});
});
describe('Outbox', () => {
test('Fetch non-paged outbox from IActor', async () => {
const actor = createRandomActor();
const outbox = createRandomNonPagedOutbox(actor, 10);
resolver.register(actor.id, actor);
resolver.register(actor.outbox as string, outbox);
await personService.createPerson(actor.id, resolver);
deepSortedEqual(
resolver.remoteGetTrials(),
[actor.id, actor.outbox],
);
for (const item of outbox.orderedItems as IActivity[]) {
const note = await noteService.fetchNote(item.object);
assert.ok(note);
assert.strictEqual(note.text, 'test test foo');
assert.strictEqual(note.uri, (item.object as IObject).id);
}
});
test('Fetch paged outbox from IActor', async () => {
const actor = createRandomActor();
const outbox = createRandomPagedOutbox(actor);
const page = createRandomOutboxPage(actor, outbox.id, 10);
resolver.register(actor.id, actor);
resolver.register(actor.outbox as string, outbox);
resolver.register(outbox.first as string, page);
await personService.createPerson(actor.id, resolver);
deepSortedEqual(
resolver.remoteGetTrials(),
[actor.id, actor.outbox, outbox.first],
);
for (const item of page.orderedItems as IActivity[]) {
const note = await noteService.fetchNote(item.object);
assert.ok(note);
assert.strictEqual(note.text, 'test test foo');
assert.strictEqual(note.uri, (item.object as IObject).id);
}
});
test('Fetch only the first 20 items', async () => {
const actor = createRandomActor();
const outbox = createRandomNonPagedOutbox(actor, 200);
resolver.register(actor.id, actor);
resolver.register(actor.outbox as string, outbox);
await personService.createPerson(actor.id, resolver);
const items = outbox.orderedItems as IActivity[];
deepSortedEqual(
resolver.remoteGetTrials(),
[actor.id, actor.outbox],
);
assert.ok(await noteService.fetchNote(items[19].object));
assert.ok(!await noteService.fetchNote(items[20].object));
});
test('Perform only Create activities', async () => {
const actor = createRandomActor();
const outbox = createRandomNonPagedOutbox(actor, 0);
outbox.orderedItems = createRandomActivities(actor, 'Announce', 10);
resolver.register(actor.id, actor);
resolver.register(actor.outbox as string, outbox);
await personService.createPerson(actor.id, resolver);
deepSortedEqual(
resolver.remoteGetTrials(),
[actor.id, actor.outbox],
);
for (const item of outbox.orderedItems as IActivity[]) {
const note = await noteService.fetchNote(item.object);
assert.ok(!note);
}
});
});
describe('Images', () => {
test('Create images', async () => {
const imageObject: IApDocument = {

View File

@@ -68,7 +68,6 @@
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typescript": "5.2.2",
"ua-parser-js": "2.0.0-alpha.2",
"uuid": "9.0.1",
"vanilla-tilt": "1.8.1",
"vite": "4.4.9",

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<script lang="ts">
import { defineComponent, h, PropType, TransitionGroup, useCssModule, watch } from 'vue';
import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue';
import MkAd from '@/components/global/MkAd.vue';
import { isDebuggerEnabled, stackTraceInstances } from '@/debug';
import { i18n } from '@/i18n';
@@ -38,11 +38,6 @@ export default defineComponent({
required: false,
default: false,
},
denyMoveTransition: {
type: Boolean,
required: false,
default: false,
},
},
setup(props, { slots, expose }) {
@@ -140,7 +135,6 @@ export default defineComponent({
[$style['reversed']]: props.reversed,
[$style['direction-down']]: props.direction === 'down',
[$style['direction-up']]: props.direction === 'up',
'deny-move-transition': props.denyMoveTransition,
},
...(defaultStore.state.animation ? {
name: 'list',
@@ -159,11 +153,15 @@ export default defineComponent({
container-type: inline-size;
&:global {
&:not(.deny-move-transition) > .list-move {
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
&:not(.deny-move-transition) > .list-enter-active {
&.deny-move-transition > .list-move {
transition: none !important;
}
> .list-enter-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}

View File

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<template #default="{ items: notes, denyMoveTransition }">
<template #default="{ items: notes }">
<div :class="[$style.root, { [$style.noGap]: noGap }]">
<MkDateSeparatedList
ref="notes"
@@ -23,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:noGap="noGap"
:ad="true"
:class="$style.notes"
:denyMoveTransition="denyMoveTransition"
>
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
</MkDateSeparatedList>

View File

@@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<template #default="{ items: notifications, denyMoveTransition }">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true" :denyMoveTransition="denyMoveTransition">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="`showNotificationAsNote:${notification.id}`" :note="notification.note"/>
<template #default="{ items: notifications }">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/>
</MkDateSeparatedList>
</template>
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onUnmounted, onMounted, computed, shallowRef, watch } from 'vue';
import { onUnmounted, onMounted, computed, shallowRef } from 'vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import XNotification from '@/components/MkNotification.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
@@ -55,16 +55,10 @@ const onNotification = (notification) => {
}
if (!isMuted) {
pagingComponent.value?.prepend(notification);
pagingComponent.value.prepend(notification);
}
};
watch(() => pagingComponent.value?.backed, (backed) => {
if (backed === false) {
useStream().send('readNotification');
}
});
let connection;
onMounted(() => {

View File

@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div ref="contents" :class="$style.root" style="container-type: inline-size;">
<RouterView :key="reloadCount" :router="router" :scrollContainer="contents"/>
<RouterView :key="reloadCount" :router="router"/>
</div>
</MkWindow>
</template>
@@ -37,11 +37,12 @@ import copyToClipboard from '@/scripts/copy-to-clipboard';
import { url } from '@/config';
import { mainRouter, routes, page } from '@/router';
import { $i } from '@/account';
import { Router } from '@/nirax';
import { Router, useScrollPositionManager } from '@/nirax';
import { i18n } from '@/i18n';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
import { openingWindowsCount } from '@/os';
import { claimAchievement } from '@/scripts/achievements';
import { getScrollContainer } from '@/scripts/scroll';
const props = defineProps<{
initialPath: string;
@@ -145,6 +146,8 @@ function popout() {
windowEl.close();
}
useScrollPositionManager(() => getScrollContainer(contents.value), router);
onMounted(() => {
openingWindowsCount.value++;
if (openingWindowsCount.value >= 3) {

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<MkLoading v-if="fetching"/>
<MkError v-else-if="empty && error" @retry="reload()"/>
<MkError v-else-if="error" @retry="init()"/>
<div v-else-if="empty" key="_empty_" class="empty">
<slot name="empty">
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="providingItems" :fetching="fetching || moreFetching" :denyMoveTransition="denyMoveTransition"></slot>
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
@@ -46,31 +46,20 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os';
import { isBottomVisible, isTopVisible, getScrollContainer, scrollToBottom, scrollToTop, scrollBy, scroll, getBodyScrollHeight } from '@/scripts/scroll';
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll';
import { useDocumentVisibility } from '@/scripts/use-document-visibility';
import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store';
import { MisskeyEntity } from '@/types/date-separated-list';
import { i18n } from '@/i18n';
import { isWebKit } from '@/scripts/useragent';
const SECOND_FETCH_LIMIT = 30;
const TOLERANCE = 6;
const TOLERANCE = 16;
const APPEAR_MINIMUM_INTERVAL = 600;
export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
endpoint: E;
/**
* 一度にAPIへ取得する件数
*/
limit: number;
/**
* タイムラインに表示する最大件数
*/
displayLimit?: number;
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
/**
@@ -98,8 +87,6 @@ function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
return new Map([...map, ...arrayToEntries(entities)]);
}
const timelineBackTopBehavior = computed(() => isWebKit() ? 'newest' : defaultStore.reactiveState.timelineBackTopBehavior.value);
</script>
<script lang="ts" setup>
import { infoImageUrl } from '@/instance';
@@ -107,19 +94,19 @@ import { infoImageUrl } from '@/instance';
const props = withDefaults(defineProps<{
pagination: Paging;
disableAutoLoad?: boolean;
displayLimit?: number;
}>(), {
displayLimit: 20,
});
const emit = defineEmits<{
(ev: 'queue', count: number): void;
}>();
let rootEl = $shallowRef<HTMLElement>();
/**
* スクロールが先頭にある場合はfalse
* スクロールが先頭にない場合にtrue
*/
// 先頭にいるかprependでキューに追加するかどうかの判定に使う
// 遡り中かどうか
let backed = $ref(false);
// true→falseの変更でexecuteQueueする
let weakBacked = $ref(false);
let scrollRemove = $ref<(() => void) | null>(null);
@@ -128,14 +115,12 @@ let scrollRemove = $ref<(() => void) | null>(null);
* 最新が0番目
*/
const items = ref<MisskeyEntityMap>(new Map());
const providingItems = computed(() => Array.from(items.value.values()));
/**
* タブが非アクティブなどの場合に更新を貯めておく
* 最新が最後パフォーマンス上の理由でitemsと逆にした
* 最新が0番目
*/
const queue = ref<MisskeyEntityMap>(new Map());
const queueSize = computed(() => queue.value.size);
const offset = ref(0);
@@ -144,153 +129,69 @@ const offset = ref(0);
*/
const fetching = ref(true);
/**
* onActivatedでtrue, onDeactivatedでfalseになる
*/
const active = 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,
} = defaultStore.reactiveState;
const displayLimit = computed(() => props.pagination.displayLimit ?? props.pagination.limit * 2);
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) ?? null : null);
const scrollableElementOrHtml = $computed(() => scrollableElement ?? document.getElementsByName('html')[0]);
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body);
const visibility = useDocumentVisibility();
const isPausingUpdateByExecutingQueue = ref(false);
const denyMoveTransition = ref(false);
let isPausingUpdate = false;
let timerForSetPause: number | null = null;
const BACKGROUND_PAUSE_WAIT_SEC = 10;
//#region scrolling
const checkFn = props.pagination.reversed ? isBottomVisible : isTopVisible;
const checkTop = (tolerance?: number) => {
if (!contentEl) return true;
if (!document.body.contains(contentEl)) return true;
return checkFn(contentEl, tolerance, scrollableElement);
};
/**
* IntersectionObserverで大まかに検出
* https://qiita.com/mkataigi/items/0154aefd2223ce23398e
*/
// 先頭が表示されているかどうかを検出
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
let scrollObserver = $ref<IntersectionObserver>();
watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
if (scrollObserver) scrollObserver.disconnect();
scrollObserver = new IntersectionObserver(entries => {
if (!active.value) return; // activeでない時は触らない
weakBacked = entries[0].intersectionRatio >= 0.1;
backed = entries[0].isIntersecting;
}, {
root: scrollableElement,
rootMargin: props.pagination.reversed ? '-100% 0px 1000% 0px' : '1000% 0px -100% 0px',
threshold: [0.01, 0.05, 0.1, 0.12, 0.15],
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
threshold: 0.01,
});
}, { immediate: true });
watch([$$(rootEl), $$(scrollObserver)], () => {
watch($$(rootEl), () => {
scrollObserver?.disconnect();
if (rootEl) scrollObserver?.observe(rootEl);
});
/**
* weakBackedがtrue→falseになったらexecuteQueue
*/
watch($$(weakBacked), () => {
if (timelineBackTopBehavior.value === 'next' && !weakBacked) {
executeQueue();
}
});
/**
* backedがtrue→falseになってもexecuteQueue
*/
watch($$(backed), () => {
if (!backed) {
executeQueue();
}
});
/**
* onScrollTop/onScrollBottomでbackedを厳密に検出する
*/
watch([$$(weakBacked), $$(contentEl)], () => {
if (scrollRemove) scrollRemove();
scrollRemove = null;
if (weakBacked || !contentEl) {
if (weakBacked) backed = true;
return;
}
scrollRemove = (() => {
const checkBacked = () => {
if (!active.value) return; // activeでない時は触らない
backed = !checkTop(TOLERANCE);
};
// とりあえず評価してみる
checkBacked();
const container = scrollableElementOrHtml;
function removeListener() { container.removeEventListener('scroll', checkBacked); }
container.addEventListener('scroll', checkBacked, { passive: true });
return removeListener;
})();
});
function preventDefault(ev: Event) {
ev.preventDefault();
}
/**
* アイテムを上に追加した場合に追加分だけスクロールを下にずらす
* Safariでは使わない方がいいかも
* @param fn DOM操作(unshiftItemsなど)
*/
async function adjustScroll(fn: () => void): Promise<void> {
await nextTick();
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
// スクロールをやめさせる
try {
// なぜかscrollableElementOrHtmlがundefinedであるというエラーが出る
scrollableElementOrHtml.addEventListener('wheel', preventDefault, { passive: false });
scrollableElementOrHtml.addEventListener('touchmove', preventDefault, { passive: false });
// スクロールを強制的に停止
scroll(scrollableElement, { top: oldScroll, behavior: 'instant' });
} catch (err) {
console.error(err, { scrollableElementOrHtml });
}
denyMoveTransition.value = true;
fn();
return await nextTick().then(() => {
const top = oldScroll + ((scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight()) - oldHeight);
scroll(scrollableElement, { top, behavior: 'instant' });
// なぜかscrollableElementOrHtmlがundefinedであるというエラーが出る
scrollableElementOrHtml.removeEventListener('wheel', preventDefault);
scrollableElementOrHtml.removeEventListener('touchmove', preventDefault);
}).then(() => nextTick()).finally(() => {
denyMoveTransition.value = false;
nextTick(() => {
if (rootEl) scrollObserver?.observe(rootEl);
});
}
//#endregion
});
watch([$$(backed), $$(contentEl)], () => {
if (!backed) {
if (!contentEl) return;
scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, TOLERANCE);
} else {
if (scrollRemove) scrollRemove();
scrollRemove = null;
}
});
if (props.pagination.params && isRef(props.pagination.params)) {
watch(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 });
/**
* 初期化
* scrollAfterInitなどの後処理もあるので、reload関数を使うべき
*
* 注意: moreFetchingをtrueにするのでfalseにする必要がある
*/
async function init(): Promise<void> {
items.value = new Map();
queue.value = new Map();
@@ -309,7 +210,7 @@ async function init(): Promise<void> {
concatItems(res);
more.value = false;
} else {
moreFetching.value = true;
if (props.pagination.reversed) moreFetching.value = true;
concatItems(res);
more.value = true;
}
@@ -323,50 +224,10 @@ async function init(): Promise<void> {
});
}
/**
* initの後に呼ぶ
* コンポーネント作成直後でinitが呼ばれた時はonMountedで呼ばれる
* reloadでinitが呼ばれた時はreload内でinitの後に呼ばれる
*/
function scrollAfterInit() {
if (props.pagination.reversed) {
nextTick(() => {
setTimeout(async () => {
if (contentEl) {
scrollToBottom(contentEl);
// scrollToしてもbacked周りがうまく動かないので手動で戻す必要がある
weakBacked = false;
}
}, 200);
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
// more = trueを遅らせる
setTimeout(() => {
moreFetching.value = false;
}, 2000);
});
} else {
nextTick(() => {
setTimeout(() => {
scrollToTop(scrollableElement);
// scrollToしてもbacked周りがうまく動かないので手動で戻す必要がある
weakBacked = false;
moreFetching.value = false;
}, 200);
});
}
}
const reload = async (): Promise<void> => {
await init();
scrollAfterInit();
const reload = (): Promise<void> => {
return init();
};
if (props.pagination.params && isRef(props.pagination.params)) {
watch(props.pagination.params, reload, { deep: true });
}
const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
@@ -385,13 +246,29 @@ const fetchMore = async (): Promise<void> => {
if (i === 10) item._shouldInsertAd_ = true;
}
const reverseConcat = (_res) => adjustScroll(() => concatMapWithArray(items.value, _res));
const reverseConcat = _res => {
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
items.value = concatMapWithArray(items.value, _res);
return nextTick(() => {
if (scrollableElement) {
scroll(scrollableElement, { top: oldScroll + (scrollableElement.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);
more.value = false;
moreFetching.value = false;
reverseConcat(res).then(() => {
more.value = false;
moreFetching.value = false;
});
} else {
items.value = concatMapWithArray(items.value, res);
more.value = false;
@@ -399,9 +276,10 @@ const fetchMore = async (): Promise<void> => {
}
} else {
if (props.pagination.reversed) {
reverseConcat(res);
more.value = true;
moreFetching.value = false;
reverseConcat(res).then(() => {
more.value = true;
moreFetching.value = false;
});
} else {
items.value = concatMapWithArray(items.value, res);
more.value = true;
@@ -466,19 +344,25 @@ const appearFetchMoreAhead = async (): Promise<void> => {
fetchMoreAppearTimeout();
};
onActivated(() => {
nextTick(() => {
active.value = true;
});
});
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE);
onDeactivated(() => {
active.value = false;
});
watch([active, visibility], () => {
if (!backed && active.value && visibility.value === 'visible') {
executeQueue();
watch(visibility, () => {
if (visibility.value === 'hidden') {
timerForSetPause = window.setTimeout(() => {
isPausingUpdate = true;
timerForSetPause = null;
},
BACKGROUND_PAUSE_WAIT_SEC * 1000);
} else { // 'visible'
if (timerForSetPause) {
clearTimeout(timerForSetPause);
timerForSetPause = null;
} else {
isPausingUpdate = false;
if (isTop()) {
executeQueue();
}
}
}
});
@@ -494,39 +378,19 @@ const prepend = (item: MisskeyEntity): void => {
return;
}
if (
!isPausingUpdateByExecutingQueue.value && // スクロール調整中はキューに追加する
visibility.value !== 'hidden' && // バックグラウンドの場合はキューに追加する
queueSize.value === 0 && // キューに残っている場合はキューに追加する
active.value // keepAliveで隠されている間はキューに追加する
) {
if (!backed) {
// かなりスクロールの先頭にいる場合
if (items.value.has(item.id)) return; // 既にタイムラインにある場合は何もしない
unshiftItems([item]);
} else if (timelineBackTopBehavior.value === 'next' && !weakBacked) {
// ちょっと先頭にいる場合はスクロールを調整する
prependQueue(item);
executeQueue();
} else {
// 先頭にいない場合はキューに追加する
prependQueue(item);
}
} else {
prependQueue(item);
}
if (isTop() && !isPausingUpdate) unshiftItems([item]);
else prependQueue(item);
};
/**
* 新着アイテムをitemsの先頭に追加し、limitを適用する
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
* @param newItems 新しいアイテムの配列
* @param limit デフォルトはdisplayLimit
*/
function unshiftItems(newItems: MisskeyEntity[], limit = displayLimit.value) {
function unshiftItems(newItems: MisskeyEntity[]) {
const length = newItems.length + items.value.size;
items.value = new Map([...arrayToEntries(newItems), ...(newItems.length >= limit ? [] : items.value)].slice(0, limit));
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
if (length >= limit) more.value = true;
if (length >= props.displayLimit) more.value = true;
}
/**
@@ -535,43 +399,18 @@ function unshiftItems(newItems: MisskeyEntity[], limit = displayLimit.value) {
*/
function concatItems(oldItems: MisskeyEntity[]) {
const length = oldItems.length + items.value.size;
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, displayLimit.value));
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
if (length >= displayLimit.value) more.value = true;
if (length >= props.displayLimit) more.value = true;
}
async function executeQueue() {
// キューが空の場合でもタイムライン表示数を制限する役割がある
// ため続行する!
// if (queue.value.size === 0) return;
if (isPausingUpdateByExecutingQueue.value) return;
if (timelineBackTopBehavior.value === 'newest') {
// Safariは最新のアイテムにするだけ
const newItems = Array.from(queue.value.values()).slice(-1 * props.pagination.limit);
unshiftItems(newItems);
queue.value = new Map();
} else {
if (queue.value.size > 0) {
const queueArr = Array.from(queue.value.entries());
queue.value = new Map(queueArr.slice(props.pagination.limit));
const newItems = Array.from({ length: Math.min(queueArr.length, props.pagination.limit) }, (_, i) => queueArr[i][1]).reverse();
isPausingUpdateByExecutingQueue.value = true;
await adjustScroll(() => unshiftItems(newItems, Infinity));
backed = true;
}
denyMoveTransition.value = true;
items.value = new Map([...items.value].slice(0, displayLimit.value));
await nextTick();
isPausingUpdateByExecutingQueue.value = false;
denyMoveTransition.value = false;
}
function executeQueue() {
unshiftItems(Array.from(queue.value.values()));
queue.value = new Map();
}
function prependQueue(newItem: MisskeyEntity) {
queue.value.set(newItem.id, newItem);
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
}
/*
@@ -596,27 +435,52 @@ const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => M
const inited = init();
onActivated(() => {
isBackTop.value = false;
});
onDeactivated(() => {
isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl ? rootEl.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
});
function toBottom() {
scrollToBottom(contentEl!);
}
onMounted(() => {
active.value = true;
inited.then(scrollAfterInit);
inited.then(() => {
if (props.pagination.reversed) {
nextTick(() => {
setTimeout(toBottom, 800);
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
// more = trueを遅らせる
setTimeout(() => {
moreFetching.value = false;
}, 2000);
});
}
});
});
onBeforeUnmount(() => {
if (timerForSetPause) {
clearTimeout(timerForSetPause);
timerForSetPause = null;
}
if (preventAppearFetchMoreTimer.value) {
clearTimeout(preventAppearFetchMoreTimer.value);
preventAppearFetchMoreTimer.value = null;
}
scrollObserver?.disconnect();
if (scrollRemove) scrollRemove();
});
defineExpose({
items,
queue,
backed,
more,
inited,
queueSize,
backed: $$(backed),
reload,
prepend,
append: appendItem,

View File

@@ -4,17 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<div v-if="queueSize > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="reload()">{{ i18n.ts.goToTheHeadOfTimeline }}</button></div>
<div v-if="(((src === 'local' || src === 'social') && !isLocalTimelineAvailable) || (src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
<p :class="$style.disabledTitle">
<i class="ti ti-circle-minus"></i>
{{ i18n.ts._disabledTimeline.title }}
</p>
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
</div>
<MkNotes v-else ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination"/>
</div>
<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
</template>
<script lang="ts" setup>
@@ -24,8 +14,6 @@ import { useStream } from '@/stream';
import * as sound from '@/scripts/sound';
import { $i } from '@/account';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
const props = defineProps<{
src: string;
@@ -38,22 +26,15 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'note'): void;
(ev: 'reload'): void;
(ev: 'queue', count: number): void;
}>();
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
provide('inChannel', computed(() => props.src === 'channel'));
let tlComponent: InstanceType<typeof MkNotes> | undefined = $ref();
const queueSize = computed(() => {
return tlComponent?.pagingComponent?.queueSize ?? 0;
});
const tlComponent: InstanceType<typeof MkNotes> = $ref();
const prepend = note => {
tlComponent?.pagingComponent?.prepend(note);
tlComponent.pagingComponent?.prepend(note);
emit('note');
@@ -178,48 +159,4 @@ const timetravel = (date?: Date) => {
this.$refs.tl.reload();
};
*/
const reload = () => {
tlComponent?.pagingComponent?.reload();
emit('reload');
};
defineExpose({
reload,
queueSize,
});
</script>
<style lang="scss" module>
.new {
position: sticky;
top: calc(var(--stickyTop, 0px) + 12px);
z-index: 1000;
width: 100%;
margin: calc(-0.675em - 8px) 0;
&:first-child {
margin-top: calc(-0.675em - 8px - var(--margin));
}
}
.newButton {
display: block;
margin: var(--margin) auto 0 auto;
padding: 8px 16px;
border-radius: 32px;
}
.disabled {
text-align: center;
}
.disabledTitle {
margin: 16px;
}
.disabledDescription {
font-size: 90%;
margin: 16px;
}
</style>

View File

@@ -40,7 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]">
<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div>
<MkTimeline src="local" :class="$style.tlBody"/>
<div :class="$style.tlBody">
<MkTimeline src="local"/>
</div>
</div>
<div :class="$style.panel">
<XActiveUsersChart/>

View File

@@ -16,18 +16,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, inject, onBeforeUnmount, provide, nextTick } from 'vue';
import { NiraxChangeEvent, Resolved, Router } from '@/nirax';
import { inject, onBeforeUnmount, provide } from 'vue';
import { Resolved, Router } from '@/nirax';
import { defaultStore } from '@/store';
import { getScrollContainer } from '@/scripts/scroll';
const props = defineProps<{
router?: Router;
/**
* Set any element if scroll position management needed
*/
scrollContainer?: HTMLElement | null;
}>();
const router = props.router ?? inject('router');
@@ -56,49 +50,17 @@ let currentPageComponent = $shallowRef(current.route.component);
let currentPageProps = $ref(current.props);
let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props)));
const scrollContainer = computed(() => props.scrollContainer ? (getScrollContainer(props.scrollContainer) ?? document.getElementsByTagName('html')[0]) : undefined);
const scrollPosStore = new Map<string, number>();
function onChange(ctx: NiraxChangeEvent) {
// save scroll position
if (scrollContainer.value) scrollPosStore.set(key, scrollContainer.value.scrollTop);
//#region change page
const current = resolveNested(ctx.resolved);
function onChange({ resolved, key: newKey }) {
const current = resolveNested(resolved);
if (current == null) return;
currentPageComponent = current.route.component;
currentPageProps = current.props;
key = current.route.path + JSON.stringify(Object.fromEntries(current.props));
//#endregion
//#region scroll
nextTick(() => {
if (!scrollContainer.value) return;
const scrollPos = scrollPosStore.get(key) ?? 0;
scrollContainer.value.scroll({ top: scrollPos, behavior: 'instant' });
if (scrollPos !== 0) {
window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
if (!scrollContainer.value) return;
scrollContainer.value.scroll({ top: scrollPos, behavior: 'instant' });
}, 100);
}
});
//#endregion
}
router.addListener('change', onChange);
function onSame() {
if (!scrollContainer.value) return;
scrollContainer.value.scroll({ top: 0, behavior: 'smooth' });
}
router.addListener('same', onSame);
onBeforeUnmount(() => {
router.removeListener('change', onChange);
router.removeListener('same', onSame);
});
</script>

View File

@@ -54,30 +54,24 @@ function parsePath(path: string): ParsedPath {
return res;
}
export type NiraxChangeEvent = {
beforePath: string;
path: string;
resolved: Resolved;
key: string;
};
export type NiraxExportEvent = {
path: string;
key: string;
};
export type NiraxPushEvent = {
beforePath: string;
path: string;
route: RouteDef | null;
props: Map<string, string> | null;
key: string;
};
export class Router extends EventEmitter<{
change: (ctx: NiraxChangeEvent) => void;
replace: (ctx: NiraxExportEvent) => void;
push: (ctx: NiraxExportEvent) => void;
change: (ctx: {
beforePath: string;
path: string;
resolved: Resolved;
key: string;
}) => void;
replace: (ctx: {
path: string;
key: string;
}) => void;
push: (ctx: {
beforePath: string;
path: string;
route: RouteDef | null;
props: Map<string, string> | null;
key: string;
}) => void;
same: () => void;
}> {
private routes: RouteDef[];
@@ -282,3 +276,29 @@ export class Router extends EventEmitter<{
this.navigate(path, key);
}
}
export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: Router) {
const scrollPosStore = new Map<string, number>();
onMounted(() => {
const scrollContainer = getScrollContainer();
scrollContainer.addEventListener('scroll', () => {
scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop);
}, { passive: true });
router.addListener('change', ctx => {
const scrollPos = scrollPosStore.get(ctx.key) ?? 0;
scrollContainer.scroll({ top: scrollPos, behavior: 'instant' });
if (scrollPos !== 0) {
window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
scrollContainer.scroll({ top: scrollPos, behavior: 'instant' });
}, 100);
}
});
router.addListener('same', () => {
scrollContainer.scroll({ top: 0, behavior: 'smooth' });
});
});
}

View File

@@ -8,13 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div ref="rootEl" v-hotkey.global="keymap">
<MkTimeline
ref="tlEl" :key="antennaId"
src="antenna"
:antenna="antennaId"
:sound="true"
:class="$style.tl"
/>
<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>
</MkSpacer>
</MkStickyContainer>
@@ -23,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch } from 'vue';
import MkTimeline from '@/components/MkTimeline.vue';
import { scroll } from '@/scripts/scroll';
import * as os from '@/os';
import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata';
@@ -35,14 +39,19 @@ const props = defineProps<{
}>();
let antenna = $ref(null);
let queue = $ref(0);
let rootEl = $shallowRef<HTMLElement>();
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
const keymap = $computed(() => ({
't': focus,
}));
function queueUpdated(q) {
queue = q;
}
function top() {
tlEl?.reload();
scroll(rootEl, { top: 0 });
}
async function timetravel() {
@@ -87,6 +96,25 @@ definePageMetadata(computed(() => antenna ? {
</script>
<style lang="scss" module>
.new {
position: sticky;
top: calc(var(--stickyTop, 0px) + 16px);
z-index: 1000;
width: 100%;
margin: calc(-0.675em - 8px) 0;
&:first-child {
margin-top: calc(-0.675em - 8px - var(--margin));
}
}
.newButton {
display: block;
margin: var(--margin) auto 0 auto;
padding: 8px 16px;
border-radius: 32px;
}
.tl {
background: var(--bg);
border-radius: var(--radius);

View File

@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" />
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/>
</div>
<div v-else-if="tab === 'featured'">
<MkNotes :pagination="featuredPagination"/>

View File

@@ -26,18 +26,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRadios>
<FormSection>
<div class="_gaps_m">
<div class="_gaps_s">
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch>
</div>
<MkSelect v-model="timelineBackTopBehavior" :disabled="isWebKit()" :readonly="isWebKit()">
<template #label>{{ i18n.ts.timelineBackTopBehavior }}</template>
<option value="newest">{{ i18n.ts._timelineBackTopBehavior.newest }}</option>
<option value="next">{{ i18n.ts._timelineBackTopBehavior.next }}</option>
</MkSelect>
<div class="_gaps_s">
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch>
</div>
</FormSection>
@@ -201,8 +193,6 @@ import { unisonReload } from '@/scripts/unison-reload';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { miLocalStorage } from '@/local-storage';
import { isWebKit } from '@/scripts/useragent';
import { testNotification } from '@/scripts/test-notification';
import { globalEvents } from '@/events';
import { claimAchievement } from '@/scripts/achievements';
@@ -251,7 +241,6 @@ const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies'));
const timelineBackTopBehavior = computed(defaultStore.makeGetterSetter('timelineBackTopBehavior'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);

View File

@@ -92,7 +92,6 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'numberOfPageCache',
'aiChanMode',
'mediaListWithOneImageAppearance',
'timelineBackTopBehavior',
];
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
'lightTheme',

View File

@@ -11,13 +11,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/>
<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
<MkTimeline
ref="tlComponent"
:key="src"
:src="src"
:sound="true"
:class="$style.tl"
/>
<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="tlComponent"
:key="src"
:src="src"
:sound="true"
@queue="queueUpdated"
/>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
@@ -28,6 +31,7 @@ import { defineAsyncComponent, computed, watch, provide } from 'vue';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import MkPostForm from '@/components/MkPostForm.vue';
import { scroll } from '@/scripts/scroll';
import * as os from '@/os';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
@@ -50,11 +54,18 @@ const keymap = {
const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>();
const rootEl = $shallowRef<HTMLElement>();
let queue = $ref(0);
let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global');
const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) });
watch ($$(src), () => queue = 0);
function queueUpdated(q: number): void {
queue = q;
}
function top(): void {
tlComponent?.reload();
if (rootEl) scroll(rootEl, { top: 0 });
}
async function chooseList(ev: MouseEvent): Promise<void> {
@@ -173,6 +184,25 @@ definePageMetadata(computed(() => ({
</script>
<style lang="scss" module>
.new {
position: sticky;
top: calc(var(--stickyTop, 0px) + 16px);
z-index: 1000;
width: 100%;
margin: calc(-0.675em - 8px) 0;
&:first-child {
margin-top: calc(-0.675em - 8px - var(--margin));
}
}
.newButton {
display: block;
margin: var(--margin) auto 0 auto;
padding: 8px 16px;
border-radius: 32px;
}
.postForm {
border-radius: var(--radius);
}

View File

@@ -8,13 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div ref="rootEl">
<MkTimeline
ref="tlEl" :key="listId"
src="list"
:list="listId"
:sound="true"
:class="$style.tl"
/>
<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>
</MkSpacer>
</MkStickyContainer>
@@ -23,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch } from 'vue';
import MkTimeline from '@/components/MkTimeline.vue';
import { scroll } from '@/scripts/scroll';
import * as os from '@/os';
import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata';
@@ -35,6 +39,7 @@ const props = defineProps<{
}>();
let list = $ref(null);
let queue = $ref(0);
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
let rootEl = $shallowRef<HTMLElement>();
@@ -44,8 +49,12 @@ watch(() => props.listId, async () => {
});
}, { immediate: true });
function queueUpdated(q) {
queue = q;
}
function top() {
tlEl?.reload();
scroll(rootEl, { top: 0 });
}
function settings() {
@@ -80,6 +89,24 @@ definePageMetadata(computed(() => list ? {
</script>
<style lang="scss" module>
.new {
position: sticky;
top: calc(var(--stickyTop, 0px) + 16px);
z-index: 1000;
width: 100%;
margin: calc(-0.675em - 8px) 0;
&:first-child {
margin-top: calc(-0.675em - 8px - var(--margin));
}
}
.newButton {
display: block;
margin: var(--margin) auto 0 auto;
padding: 8px 16px;
border-radius: 32px;
}
.tl {
background: var(--bg);

View File

@@ -30,7 +30,7 @@ export function getScrollPosition(el: HTMLElement | null): number {
export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) {
// とりあえず評価してみる
if (el.isConnected && isTopVisible(el, tolerance)) {
if (el.isConnected && isTopVisible(el)) {
cb();
if (once) return null;
}
@@ -75,29 +75,12 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
return removeListener;
}
/**
* コンテナを指定してスクロールする
* @param el Container element
* @param options ScrollToOptions
*/
export function scroll(el: HTMLElement | null, options: ScrollToOptions | undefined) {
if (el == null) {
export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) {
const container = getScrollContainer(el);
if (container == null) {
window.scroll(options);
} else {
el.scroll(options);
}
}
/**
* コンテナを指定してscrollByする
* @param el Container element
* @param options ScrollToOptions
*/
export function scrollBy(el: HTMLElement | null, options: ScrollToOptions | undefined) {
if (el == null) {
window.scrollBy(options);
} else {
el.scrollBy(options);
container.scroll(options);
}
}
@@ -106,8 +89,8 @@ export function scrollBy(el: HTMLElement | null, options: ScrollToOptions | unde
* @param el Scroll container element
* @param options Scroll options
*/
export function scrollToTop(el: HTMLElement | null, options: { behavior?: ScrollBehavior; } = {}) {
scroll(getScrollContainer(el), { top: 0, ...options });
export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) {
scroll(el, { top: 0, ...options });
}
/**

View File

@@ -1,3 +0,0 @@
import { UAParser } from 'ua-parser-js';
const ua = new UAParser(navigator.userAgent);
export const isWebKit = () => ua.getEngine().name === 'WebKit';

View File

@@ -6,7 +6,6 @@
import { markRaw, ref } from 'vue';
import misskey from 'misskey-js';
import { Storage } from './pizzax';
import { isWebKit } from './scripts/useragent';
interface PostFormAction {
title: string,
@@ -353,10 +352,6 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: {} as Record<string, Record<string, string[]>>,
},
timelineBackTopBehavior: {
where: 'device',
default: (isWebKit() ? 'newest' : 'next') as 'newest' | 'next',
},
}));
// TODO: 他のタブと永続化されたstateを同期

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div ref="contents">
<RouterView :scrollContainer="contents" @contextmenu.stop="onContextmenu"/>
<RouterView @contextmenu.stop="onContextmenu"/>
</div>
</XColumn>
</template>
@@ -26,6 +26,8 @@ import * as os from '@/os';
import { i18n } from '@/i18n';
import { mainRouter } from '@/router';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
import { useScrollPositionManager } from '@/nirax';
import { getScrollContainer } from '@/scripts/scroll';
defineProps<{
column: Column;
@@ -69,4 +71,6 @@ function onContextmenu(ev: MouseEvent) {
},
}], ev);
}
useScrollPositionManager(() => getScrollContainer(contents.value), mainRouter);
</script>

View File

@@ -13,7 +13,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<span style="margin-left: 8px;">{{ column.name }}</span>
</template>
<MkTimeline v-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/>
<div v-if="(((column.tl === 'local' || column.tl === 'social') && !isLocalTimelineAvailable) || (column.tl === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
<p :class="$style.disabledTitle">
<i class="ti ti-circle-minus"></i>
{{ i18n.ts._disabledTimeline.title }}
</p>
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
</div>
<MkTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/>
</XColumn>
</template>
@@ -23,16 +30,27 @@ import XColumn from './column.vue';
import { removeColumn, updateColumn, Column } from './deck-store';
import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
const props = defineProps<{
column: Column;
isStacked: boolean;
}>();
let disabled = $ref(false);
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
onMounted(() => {
if (props.column.tl == null) {
setType();
} else if ($i) {
disabled = (
(!((instance.policies.ltlAvailable) || ($i.policies.ltlAvailable)) && ['local', 'social'].includes(props.column.tl)) ||
(!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl)));
}
});
@@ -66,3 +84,17 @@ const menu = [{
action: setType,
}];
</script>
<style lang="scss" module>
.disabled {
text-align: center;
}
.disabledTitle {
margin: 16px;
}
.disabledDescription {
font-size: 90%;
}
</style>

View File

@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XStatusBars :class="$style.statusbars"/>
</div>
</template>
<RouterView :scrollContainer="contents?.rootEl"/>
<RouterView/>
<div :class="$style.spacer"></div>
</MkStickyContainer>
@@ -105,6 +105,7 @@ import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind';
import { miLocalStorage } from '@/local-storage';
import { CURRENT_STICKY_BOTTOM } from '@/const';
import { useScrollPositionManager } from '@/nirax';
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
@@ -226,6 +227,8 @@ watch($$(navFooter), () => {
}, {
immediate: true,
});
useScrollPositionManager(() => contents.value.rootEl, mainRouter);
</script>
<style>

View File

@@ -20,20 +20,33 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
</template>
<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"/>
<div v-if="(((widgetProps.src === 'local' || widgetProps.src === 'social') && !isLocalTimelineAvailable) || (widgetProps.src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
<p :class="$style.disabledTitle">
<i class="ti ti-minus"></i>
{{ i18n.ts._disabledTimeline.title }}
</p>
<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"/>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
import MkContainer from '@/components/MkContainer.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import { instance } from '@/instance';
const name = 'timeline';
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
const widgetPropsDef = {
showHeader: {
@@ -128,3 +141,17 @@ defineExpose<WidgetComponentExpose>({
id: props.widget ? props.widget.id : null,
});
</script>
<style lang="scss" module>
.disabled {
text-align: center;
}
.disabledTitle {
margin: 16px;
}
.disabledDescription {
font-size: 90%;
}
</style>

8
pnpm-lock.yaml generated
View File

@@ -799,9 +799,6 @@ importers:
typescript:
specifier: 5.2.2
version: 5.2.2
ua-parser-js:
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2
uuid:
specifier: 9.0.1
version: 9.0.1
@@ -11849,7 +11846,6 @@ packages:
/form-data@3.0.1:
resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
engines: {node: '>= 6'}
requiresBuild: true
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
@@ -18822,10 +18818,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
/ua-parser-js@2.0.0-alpha.2:
resolution: {integrity: sha512-Vz+BJN/EFC1OaUv0eu5kPyX7HEZIO7Dv29jIK7rMuKjUB1qqq+Is/XIpu5iV5XDvoNl62dM7ay8DtzYjBDI0WA==}
dev: false
/ufo@1.1.2:
resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==}
dev: true