Compare commits
27 Commits
pag-back
...
fetch-outb
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a926e84b14 | ||
![]() |
df636659e2 | ||
![]() |
888cd2eb9f | ||
![]() |
ff4b3d2d9e | ||
![]() |
60fd848182 | ||
![]() |
76def0032e | ||
![]() |
da0804eb17 | ||
![]() |
6b26ce3768 | ||
![]() |
5a0d7d41e6 | ||
![]() |
08e2b6ee32 | ||
![]() |
ca0c673b44 | ||
![]() |
70bb9a4d1f | ||
![]() |
b93046c071 | ||
![]() |
f34f0dfcb6 | ||
![]() |
26040c2bb0 | ||
![]() |
bdbad4605b | ||
![]() |
ec62fe02b1 | ||
![]() |
a74af07992 | ||
![]() |
aa78c29e8c | ||
![]() |
45d0b46e7a | ||
![]() |
6087d02047 | ||
![]() |
7bf318ae98 | ||
![]() |
71d74676f0 | ||
![]() |
5077df2973 | ||
![]() |
a1388a8444 | ||
![]() |
630e97bd06 | ||
![]() |
2e1de4fca9 |
@@ -8,7 +8,7 @@
|
|||||||
-
|
-
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
-
|
- 最初照会したユーザーの最新ノートを受け取るように
|
||||||
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
@@ -187,6 +187,10 @@ id: "aidx"
|
|||||||
# Sign to ActivityPub GET request (default: true)
|
# Sign to ActivityPub GET request (default: true)
|
||||||
signToActivityPubGet: 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: [
|
#allowedPrivateNetworks: [
|
||||||
# '127.0.0.1/32'
|
# '127.0.0.1/32'
|
||||||
#]
|
#]
|
||||||
|
7
locales/index.d.ts
vendored
7
locales/index.d.ts
vendored
@@ -533,7 +533,7 @@ export interface Locale {
|
|||||||
"deleteAll": string;
|
"deleteAll": string;
|
||||||
"showFixedPostForm": string;
|
"showFixedPostForm": string;
|
||||||
"showFixedPostFormInChannel": string;
|
"showFixedPostFormInChannel": string;
|
||||||
"goToTheHeadOfTimeline": string;
|
"newNoteRecived": string;
|
||||||
"sounds": string;
|
"sounds": string;
|
||||||
"sound": string;
|
"sound": string;
|
||||||
"listen": string;
|
"listen": string;
|
||||||
@@ -1103,7 +1103,6 @@ export interface Locale {
|
|||||||
"doYouAgree": string;
|
"doYouAgree": string;
|
||||||
"beSureToReadThisAsItIsImportant": string;
|
"beSureToReadThisAsItIsImportant": string;
|
||||||
"iHaveReadXCarefullyAndAgree": string;
|
"iHaveReadXCarefullyAndAgree": string;
|
||||||
"timelineBackTopBehavior": string;
|
|
||||||
"dialog": string;
|
"dialog": string;
|
||||||
"icon": string;
|
"icon": string;
|
||||||
"forYou": string;
|
"forYou": string;
|
||||||
@@ -1673,10 +1672,6 @@ export interface Locale {
|
|||||||
"dialog": string;
|
"dialog": string;
|
||||||
"quiet": string;
|
"quiet": string;
|
||||||
};
|
};
|
||||||
"_timelineBackTopBehavior": {
|
|
||||||
"newest": string;
|
|
||||||
"next": string;
|
|
||||||
};
|
|
||||||
"_channel": {
|
"_channel": {
|
||||||
"create": string;
|
"create": string;
|
||||||
"edit": string;
|
"edit": string;
|
||||||
|
@@ -530,7 +530,7 @@ serverLogs: "サーバーログ"
|
|||||||
deleteAll: "全て削除"
|
deleteAll: "全て削除"
|
||||||
showFixedPostForm: "タイムライン上部に投稿フォームを表示する"
|
showFixedPostForm: "タイムライン上部に投稿フォームを表示する"
|
||||||
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
|
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
|
||||||
goToTheHeadOfTimeline: "最新のノートに移動"
|
newNoteRecived: "新しいノートがあります"
|
||||||
sounds: "サウンド"
|
sounds: "サウンド"
|
||||||
sound: "サウンド"
|
sound: "サウンド"
|
||||||
listen: "聴く"
|
listen: "聴く"
|
||||||
@@ -1100,7 +1100,6 @@ expired: "期限切れ"
|
|||||||
doYouAgree: "同意しますか?"
|
doYouAgree: "同意しますか?"
|
||||||
beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。"
|
beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。"
|
||||||
iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。"
|
iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。"
|
||||||
timelineBackTopBehavior: "タイムラインのスクロールが先頭に戻った時の挙動"
|
|
||||||
dialog: "ダイアログ"
|
dialog: "ダイアログ"
|
||||||
icon: "アイコン"
|
icon: "アイコン"
|
||||||
forYou: "あなたへ"
|
forYou: "あなたへ"
|
||||||
@@ -1590,10 +1589,6 @@ _serverDisconnectedBehavior:
|
|||||||
dialog: "ダイアログで警告"
|
dialog: "ダイアログで警告"
|
||||||
quiet: "控えめに警告"
|
quiet: "控えめに警告"
|
||||||
|
|
||||||
_timelineBackTopBehavior:
|
|
||||||
newest: "最新の投稿を表示"
|
|
||||||
next: "次の投稿を遡る"
|
|
||||||
|
|
||||||
_channel:
|
_channel:
|
||||||
create: "チャンネルを作成"
|
create: "チャンネルを作成"
|
||||||
edit: "チャンネルを編集"
|
edit: "チャンネルを編集"
|
||||||
|
@@ -85,6 +85,7 @@ type Source = {
|
|||||||
videoThumbnailGenerator?: string;
|
videoThumbnailGenerator?: string;
|
||||||
|
|
||||||
signToActivityPubGet?: boolean;
|
signToActivityPubGet?: boolean;
|
||||||
|
outboxNotesFetchLimit?: number;
|
||||||
|
|
||||||
perChannelMaxNoteCacheCount?: number;
|
perChannelMaxNoteCacheCount?: number;
|
||||||
perUserNotificationsMaxCount?: number;
|
perUserNotificationsMaxCount?: number;
|
||||||
|
@@ -27,7 +27,7 @@ import { QueueService } from '@/core/QueueService.js';
|
|||||||
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
|
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { MiRemoteUser } from '@/models/entities/User.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 { ApNoteService } from './models/ApNoteService.js';
|
||||||
import { ApLoggerService } from './ApLoggerService.js';
|
import { ApLoggerService } from './ApLoggerService.js';
|
||||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||||
@@ -87,11 +87,19 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<void> {
|
public async performActivity(actor: MiRemoteUser, activity: IObject, {
|
||||||
if (isCollectionOrOrderedCollection(activity)) {
|
limit = Infinity,
|
||||||
|
allow = null as (string[] | null) } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
if (isCollectionOrOrderedCollection(activity) || isOrderedCollectionPage(activity)) {
|
||||||
const resolver = this.apResolverService.createResolver();
|
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 act = await resolver.resolve(item);
|
||||||
|
const type = getApType(act);
|
||||||
|
if (allow && !allow.includes(type)) {
|
||||||
|
this.logger.info(`skipping activity type: ${type}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.performOneActivity(actor, act);
|
await this.performOneActivity(actor, act);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -367,7 +375,7 @@ export class ApInboxService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isPost(object)) {
|
if (isPost(object)) {
|
||||||
this.createNote(resolver, actor, object, false, activity);
|
await this.createNote(resolver, actor, object, false, activity);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Unknown type: ${getApType(object)}`);
|
this.logger.warn(`Unknown type: ${getApType(object)}`);
|
||||||
}
|
}
|
||||||
|
@@ -15,11 +15,11 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { isCollectionOrOrderedCollection } from './type.js';
|
import { isCollectionOrOrderedCollection, isOrderedCollectionPage } from './type.js';
|
||||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||||
import { ApRendererService } from './ApRendererService.js';
|
import { ApRendererService } from './ApRendererService.js';
|
||||||
import { ApRequestService } from './ApRequestService.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 {
|
export class Resolver {
|
||||||
private history: Set<string>;
|
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
|
@bindThis
|
||||||
public async resolve(value: string | IObject): Promise<IObject> {
|
public async resolve(value: string | IObject): Promise<IObject> {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
|
@@ -38,7 +38,8 @@ import { MetaService } from '@/core/MetaService.js';
|
|||||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||||
import { checkHttps } from '@/misc/check-https.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 { extractApHashtags } from './tag.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
import type { ApNoteService } from './ApNoteService.js';
|
import type { ApNoteService } from './ApNoteService.js';
|
||||||
@@ -68,6 +69,7 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
private apResolverService: ApResolverService;
|
private apResolverService: ApResolverService;
|
||||||
private apNoteService: ApNoteService;
|
private apNoteService: ApNoteService;
|
||||||
private apImageService: ApImageService;
|
private apImageService: ApImageService;
|
||||||
|
private apInboxService: ApInboxService;
|
||||||
private apMfmService: ApMfmService;
|
private apMfmService: ApMfmService;
|
||||||
private mfmService: MfmService;
|
private mfmService: MfmService;
|
||||||
private hashtagService: HashtagService;
|
private hashtagService: HashtagService;
|
||||||
@@ -116,6 +118,7 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
this.apResolverService = this.moduleRef.get('ApResolverService');
|
this.apResolverService = this.moduleRef.get('ApResolverService');
|
||||||
this.apNoteService = this.moduleRef.get('ApNoteService');
|
this.apNoteService = this.moduleRef.get('ApNoteService');
|
||||||
this.apImageService = this.moduleRef.get('ApImageService');
|
this.apImageService = this.moduleRef.get('ApImageService');
|
||||||
|
this.apInboxService = this.moduleRef.get('ApInboxService');
|
||||||
this.apMfmService = this.moduleRef.get('ApMfmService');
|
this.apMfmService = this.moduleRef.get('ApMfmService');
|
||||||
this.mfmService = this.moduleRef.get('MfmService');
|
this.mfmService = this.moduleRef.get('MfmService');
|
||||||
this.hashtagService = this.moduleRef.get('HashtagService');
|
this.hashtagService = this.moduleRef.get('HashtagService');
|
||||||
@@ -384,7 +387,10 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
//#endregion
|
//#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;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -589,7 +595,7 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
||||||
const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x)));
|
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 limit = promiseLimit<MiNote | null>(2);
|
||||||
const featuredNotes = await Promise.all(items
|
const featuredNotes = await Promise.all(items
|
||||||
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
|
.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で呼ばれる前提)
|
* @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提)
|
||||||
|
@@ -92,16 +92,37 @@ export interface IActivity extends IObject {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection
|
||||||
export interface ICollection extends IObject {
|
export interface ICollection extends IObject {
|
||||||
type: 'Collection';
|
type: 'Collection';
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
|
current?: ICollectionPage | string;
|
||||||
|
first?: ICollectionPage | string;
|
||||||
|
last?: ICollectionPage | string;
|
||||||
items: ApObject;
|
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';
|
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'];
|
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 =>
|
export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
|
||||||
getApType(object) === 'OrderedCollection';
|
getApType(object) === 'OrderedCollection';
|
||||||
|
|
||||||
|
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
|
||||||
|
getApType(object) === 'OrderedCollectionPage';
|
||||||
|
|
||||||
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
|
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
|
||||||
isCollection(object) || isOrderedCollection(object);
|
isCollection(object) || isOrderedCollection(object);
|
||||||
|
|
||||||
|
@@ -68,7 +68,7 @@ export class MockResolver extends Resolver {
|
|||||||
const r = this.#responseMap.get(value);
|
const r = this.#responseMap.get(value);
|
||||||
|
|
||||||
if (!r) {
|
if (!r) {
|
||||||
throw new Error('Not registed for mock');
|
throw new Error('Not registered for mock');
|
||||||
}
|
}
|
||||||
|
|
||||||
const object = JSON.parse(r.content);
|
const object = JSON.parse(r.content);
|
||||||
|
@@ -17,7 +17,7 @@ import { GlobalModule } from '@/GlobalModule.js';
|
|||||||
import { CoreModule } from '@/core/CoreModule.js';
|
import { CoreModule } from '@/core/CoreModule.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.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 { MiMeta, MiNote } from '@/models/_.js';
|
||||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
import { DownloadService } from '@/core/DownloadService.js';
|
import { DownloadService } from '@/core/DownloadService.js';
|
||||||
@@ -29,6 +29,16 @@ const host = 'https://host1.test';
|
|||||||
|
|
||||||
type NonTransientIActor = IActor & { id: string };
|
type NonTransientIActor = IActor & { id: string };
|
||||||
type NonTransientIPost = IPost & { 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 {
|
function createRandomActor({ actorHost = host } = {}): NonTransientIActor {
|
||||||
const preferredUsername = secureRndstr(8);
|
const preferredUsername = secureRndstr(8);
|
||||||
@@ -60,7 +70,7 @@ function createRandomNotes(actor: NonTransientIActor, length: number): NonTransi
|
|||||||
return new Array(length).fill(null).map(() => createRandomNote(actor));
|
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);
|
const items = createRandomNotes(actor, length);
|
||||||
|
|
||||||
return {
|
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(
|
async function createRandomRemoteUser(
|
||||||
resolver: MockResolver,
|
resolver: MockResolver,
|
||||||
personService: ApPersonService,
|
personService: ApPersonService,
|
||||||
@@ -196,7 +253,7 @@ describe('ActivityPub', () => {
|
|||||||
|
|
||||||
describe('Renderer', () => {
|
describe('Renderer', () => {
|
||||||
test('Render an announce with visibility: followers', () => {
|
test('Render an announce with visibility: followers', () => {
|
||||||
rendererService.renderAnnounce(null, {
|
rendererService.renderAnnounce('hoge', {
|
||||||
createdAt: new Date(0),
|
createdAt: new Date(0),
|
||||||
visibility: 'followers',
|
visibility: 'followers',
|
||||||
} as MiNote);
|
} as MiNote);
|
||||||
@@ -216,7 +273,7 @@ describe('ActivityPub', () => {
|
|||||||
await personService.createPerson(actor.id, resolver);
|
await personService.createPerson(actor.id, resolver);
|
||||||
|
|
||||||
// All notes in `featured` are same-origin, no need to fetch notes again
|
// 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
|
// Created notes without resolving anything
|
||||||
for (const item of featured.items as IPost[]) {
|
for (const item of featured.items as IPost[]) {
|
||||||
@@ -247,9 +304,9 @@ describe('ActivityPub', () => {
|
|||||||
await personService.createPerson(actor1.id, resolver);
|
await personService.createPerson(actor1.id, resolver);
|
||||||
|
|
||||||
// actor2Note is from a different server and needs to be fetched again
|
// actor2Note is from a different server and needs to be fetched again
|
||||||
assert.deepStrictEqual(
|
deepSortedEqual(
|
||||||
resolver.remoteGetTrials(),
|
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);
|
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', () => {
|
describe('Images', () => {
|
||||||
test('Create images', async () => {
|
test('Create images', async () => {
|
||||||
const imageObject: IApDocument = {
|
const imageObject: IApDocument = {
|
||||||
|
@@ -68,7 +68,6 @@
|
|||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"twemoji-parser": "14.0.0",
|
"twemoji-parser": "14.0.0",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"ua-parser-js": "2.0.0-alpha.2",
|
|
||||||
"uuid": "9.0.1",
|
"uuid": "9.0.1",
|
||||||
"vanilla-tilt": "1.8.1",
|
"vanilla-tilt": "1.8.1",
|
||||||
"vite": "4.4.9",
|
"vite": "4.4.9",
|
||||||
|
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<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 MkAd from '@/components/global/MkAd.vue';
|
||||||
import { isDebuggerEnabled, stackTraceInstances } from '@/debug';
|
import { isDebuggerEnabled, stackTraceInstances } from '@/debug';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
@@ -38,11 +38,6 @@ export default defineComponent({
|
|||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
denyMoveTransition: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setup(props, { slots, expose }) {
|
setup(props, { slots, expose }) {
|
||||||
@@ -140,7 +135,6 @@ export default defineComponent({
|
|||||||
[$style['reversed']]: props.reversed,
|
[$style['reversed']]: props.reversed,
|
||||||
[$style['direction-down']]: props.direction === 'down',
|
[$style['direction-down']]: props.direction === 'down',
|
||||||
[$style['direction-up']]: props.direction === 'up',
|
[$style['direction-up']]: props.direction === 'up',
|
||||||
'deny-move-transition': props.denyMoveTransition,
|
|
||||||
},
|
},
|
||||||
...(defaultStore.state.animation ? {
|
...(defaultStore.state.animation ? {
|
||||||
name: 'list',
|
name: 'list',
|
||||||
@@ -159,11 +153,15 @@ export default defineComponent({
|
|||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
|
|
||||||
&:global {
|
&:global {
|
||||||
&:not(.deny-move-transition) > .list-move {
|
> .list-move {
|
||||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
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);
|
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ items: notes, denyMoveTransition }">
|
<template #default="{ items: notes }">
|
||||||
<div :class="[$style.root, { [$style.noGap]: noGap }]">
|
<div :class="[$style.root, { [$style.noGap]: noGap }]">
|
||||||
<MkDateSeparatedList
|
<MkDateSeparatedList
|
||||||
ref="notes"
|
ref="notes"
|
||||||
@@ -23,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
:noGap="noGap"
|
:noGap="noGap"
|
||||||
:ad="true"
|
:ad="true"
|
||||||
:class="$style.notes"
|
:class="$style.notes"
|
||||||
:denyMoveTransition="denyMoveTransition"
|
|
||||||
>
|
>
|
||||||
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
|
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
|
||||||
</MkDateSeparatedList>
|
</MkDateSeparatedList>
|
||||||
|
@@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ items: notifications, denyMoveTransition }">
|
<template #default="{ items: notifications }">
|
||||||
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true" :denyMoveTransition="denyMoveTransition">
|
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
|
||||||
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="`showNotificationAsNote:${notification.id}`" :note="notification.note"/>
|
<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"/>
|
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/>
|
||||||
</MkDateSeparatedList>
|
</MkDateSeparatedList>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||||
import XNotification from '@/components/MkNotification.vue';
|
import XNotification from '@/components/MkNotification.vue';
|
||||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||||
@@ -55,16 +55,10 @@ const onNotification = (notification) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isMuted) {
|
if (!isMuted) {
|
||||||
pagingComponent.value?.prepend(notification);
|
pagingComponent.value.prepend(notification);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => pagingComponent.value?.backed, (backed) => {
|
|
||||||
if (backed === false) {
|
|
||||||
useStream().send('readNotification');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let connection;
|
let connection;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div ref="contents" :class="$style.root" style="container-type: inline-size;">
|
<div ref="contents" :class="$style.root" style="container-type: inline-size;">
|
||||||
<RouterView :key="reloadCount" :router="router" :scrollContainer="contents"/>
|
<RouterView :key="reloadCount" :router="router"/>
|
||||||
</div>
|
</div>
|
||||||
</MkWindow>
|
</MkWindow>
|
||||||
</template>
|
</template>
|
||||||
@@ -37,11 +37,12 @@ import copyToClipboard from '@/scripts/copy-to-clipboard';
|
|||||||
import { url } from '@/config';
|
import { url } from '@/config';
|
||||||
import { mainRouter, routes, page } from '@/router';
|
import { mainRouter, routes, page } from '@/router';
|
||||||
import { $i } from '@/account';
|
import { $i } from '@/account';
|
||||||
import { Router } from '@/nirax';
|
import { Router, useScrollPositionManager } from '@/nirax';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
||||||
import { openingWindowsCount } from '@/os';
|
import { openingWindowsCount } from '@/os';
|
||||||
import { claimAchievement } from '@/scripts/achievements';
|
import { claimAchievement } from '@/scripts/achievements';
|
||||||
|
import { getScrollContainer } from '@/scripts/scroll';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
initialPath: string;
|
initialPath: string;
|
||||||
@@ -145,6 +146,8 @@ function popout() {
|
|||||||
windowEl.close();
|
windowEl.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useScrollPositionManager(() => getScrollContainer(contents.value), router);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
openingWindowsCount.value++;
|
openingWindowsCount.value++;
|
||||||
if (openingWindowsCount.value >= 3) {
|
if (openingWindowsCount.value >= 3) {
|
||||||
|
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
>
|
>
|
||||||
<MkLoading v-if="fetching"/>
|
<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">
|
<div v-else-if="empty" key="_empty_" class="empty">
|
||||||
<slot name="empty">
|
<slot name="empty">
|
||||||
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</MkButton>
|
</MkButton>
|
||||||
<MkLoading v-else class="loading"/>
|
<MkLoading v-else class="loading"/>
|
||||||
</div>
|
</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">
|
<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">
|
<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 }}
|
{{ 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 { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import * as os from '@/os';
|
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 { useDocumentVisibility } from '@/scripts/use-document-visibility';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
import { MisskeyEntity } from '@/types/date-separated-list';
|
import { MisskeyEntity } from '@/types/date-separated-list';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import { isWebKit } from '@/scripts/useragent';
|
|
||||||
|
|
||||||
const SECOND_FETCH_LIMIT = 30;
|
const SECOND_FETCH_LIMIT = 30;
|
||||||
const TOLERANCE = 6;
|
const TOLERANCE = 16;
|
||||||
const APPEAR_MINIMUM_INTERVAL = 600;
|
const APPEAR_MINIMUM_INTERVAL = 600;
|
||||||
|
|
||||||
export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
|
export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
|
||||||
endpoint: E;
|
endpoint: E;
|
||||||
|
|
||||||
/**
|
|
||||||
* 一度にAPIへ取得する件数
|
|
||||||
*/
|
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
||||||
/**
|
|
||||||
* タイムラインに表示する最大件数
|
|
||||||
*/
|
|
||||||
displayLimit?: number;
|
|
||||||
|
|
||||||
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
|
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 {
|
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
|
||||||
return new Map([...map, ...arrayToEntries(entities)]);
|
return new Map([...map, ...arrayToEntries(entities)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineBackTopBehavior = computed(() => isWebKit() ? 'newest' : defaultStore.reactiveState.timelineBackTopBehavior.value);
|
|
||||||
</script>
|
</script>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { infoImageUrl } from '@/instance';
|
import { infoImageUrl } from '@/instance';
|
||||||
@@ -107,19 +94,19 @@ import { infoImageUrl } from '@/instance';
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
pagination: Paging;
|
pagination: Paging;
|
||||||
disableAutoLoad?: boolean;
|
disableAutoLoad?: boolean;
|
||||||
|
displayLimit?: number;
|
||||||
}>(), {
|
}>(), {
|
||||||
|
displayLimit: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'queue', count: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
let rootEl = $shallowRef<HTMLElement>();
|
let rootEl = $shallowRef<HTMLElement>();
|
||||||
|
|
||||||
/**
|
// 遡り中かどうか
|
||||||
* スクロールが先頭にある場合はfalse
|
|
||||||
* スクロールが先頭にない場合にtrue
|
|
||||||
*/
|
|
||||||
// 先頭にいるか(prependでキューに追加するかどうかの判定に使う)
|
|
||||||
let backed = $ref(false);
|
let backed = $ref(false);
|
||||||
// true→falseの変更でexecuteQueueする
|
|
||||||
let weakBacked = $ref(false);
|
|
||||||
|
|
||||||
let scrollRemove = $ref<(() => void) | null>(null);
|
let scrollRemove = $ref<(() => void) | null>(null);
|
||||||
|
|
||||||
@@ -128,14 +115,12 @@ let scrollRemove = $ref<(() => void) | null>(null);
|
|||||||
* 最新が0番目
|
* 最新が0番目
|
||||||
*/
|
*/
|
||||||
const items = ref<MisskeyEntityMap>(new Map());
|
const items = ref<MisskeyEntityMap>(new Map());
|
||||||
const providingItems = computed(() => Array.from(items.value.values()));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* タブが非アクティブなどの場合に更新を貯めておく
|
* タブが非アクティブなどの場合に更新を貯めておく
|
||||||
* 最新が最後(パフォーマンス上の理由でitemsと逆にした)
|
* 最新が0番目
|
||||||
*/
|
*/
|
||||||
const queue = ref<MisskeyEntityMap>(new Map());
|
const queue = ref<MisskeyEntityMap>(new Map());
|
||||||
const queueSize = computed(() => queue.value.size);
|
|
||||||
|
|
||||||
const offset = ref(0);
|
const offset = ref(0);
|
||||||
|
|
||||||
@@ -144,153 +129,69 @@ const offset = ref(0);
|
|||||||
*/
|
*/
|
||||||
const fetching = ref(true);
|
const fetching = ref(true);
|
||||||
|
|
||||||
/**
|
|
||||||
* onActivatedでtrue, onDeactivatedでfalseになる
|
|
||||||
*/
|
|
||||||
const active = ref(true);
|
|
||||||
|
|
||||||
const moreFetching = ref(false);
|
const moreFetching = ref(false);
|
||||||
const more = ref(false);
|
const more = ref(false);
|
||||||
const preventAppearFetchMore = ref(false);
|
const preventAppearFetchMore = ref(false);
|
||||||
const preventAppearFetchMoreTimer = ref<number | null>(null);
|
const preventAppearFetchMoreTimer = ref<number | null>(null);
|
||||||
|
const isBackTop = ref(false);
|
||||||
const empty = computed(() => items.value.size === 0);
|
const empty = computed(() => items.value.size === 0);
|
||||||
const error = ref(false);
|
const error = ref(false);
|
||||||
const {
|
const {
|
||||||
enableInfiniteScroll,
|
enableInfiniteScroll,
|
||||||
} = defaultStore.reactiveState;
|
} = defaultStore.reactiveState;
|
||||||
|
|
||||||
const displayLimit = computed(() => props.pagination.displayLimit ?? props.pagination.limit * 2);
|
|
||||||
|
|
||||||
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
|
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
|
||||||
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) ?? null : null);
|
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body);
|
||||||
const scrollableElementOrHtml = $computed(() => scrollableElement ?? document.getElementsByName('html')[0]);
|
|
||||||
|
|
||||||
const visibility = useDocumentVisibility();
|
const visibility = useDocumentVisibility();
|
||||||
|
|
||||||
const isPausingUpdateByExecutingQueue = ref(false);
|
let isPausingUpdate = false;
|
||||||
const denyMoveTransition = ref(false);
|
let timerForSetPause: number | null = null;
|
||||||
|
const BACKGROUND_PAUSE_WAIT_SEC = 10;
|
||||||
|
|
||||||
//#region scrolling
|
// 先頭が表示されているかどうかを検出
|
||||||
const checkFn = props.pagination.reversed ? isBottomVisible : isTopVisible;
|
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
|
||||||
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
|
|
||||||
*/
|
|
||||||
let scrollObserver = $ref<IntersectionObserver>();
|
let scrollObserver = $ref<IntersectionObserver>();
|
||||||
|
|
||||||
watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
|
watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
|
||||||
if (scrollObserver) scrollObserver.disconnect();
|
if (scrollObserver) scrollObserver.disconnect();
|
||||||
|
|
||||||
scrollObserver = new IntersectionObserver(entries => {
|
scrollObserver = new IntersectionObserver(entries => {
|
||||||
if (!active.value) return; // activeでない時は触らない
|
backed = entries[0].isIntersecting;
|
||||||
weakBacked = entries[0].intersectionRatio >= 0.1;
|
|
||||||
}, {
|
}, {
|
||||||
root: scrollableElement,
|
root: scrollableElement,
|
||||||
rootMargin: props.pagination.reversed ? '-100% 0px 1000% 0px' : '1000% 0px -100% 0px',
|
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
|
||||||
threshold: [0.01, 0.05, 0.1, 0.12, 0.15],
|
threshold: 0.01,
|
||||||
});
|
});
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
watch([$$(rootEl), $$(scrollObserver)], () => {
|
watch($$(rootEl), () => {
|
||||||
scrollObserver?.disconnect();
|
scrollObserver?.disconnect();
|
||||||
if (rootEl) scrollObserver?.observe(rootEl);
|
nextTick(() => {
|
||||||
});
|
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;
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
//#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> {
|
async function init(): Promise<void> {
|
||||||
items.value = new Map();
|
items.value = new Map();
|
||||||
queue.value = new Map();
|
queue.value = new Map();
|
||||||
@@ -309,7 +210,7 @@ async function init(): Promise<void> {
|
|||||||
concatItems(res);
|
concatItems(res);
|
||||||
more.value = false;
|
more.value = false;
|
||||||
} else {
|
} else {
|
||||||
moreFetching.value = true;
|
if (props.pagination.reversed) moreFetching.value = true;
|
||||||
concatItems(res);
|
concatItems(res);
|
||||||
more.value = true;
|
more.value = true;
|
||||||
}
|
}
|
||||||
@@ -323,50 +224,10 @@ async function init(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const reload = (): Promise<void> => {
|
||||||
* initの後に呼ぶ
|
return 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();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (props.pagination.params && isRef(props.pagination.params)) {
|
|
||||||
watch(props.pagination.params, reload, { deep: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchMore = async (): Promise<void> => {
|
const fetchMore = async (): Promise<void> => {
|
||||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||||
moreFetching.value = true;
|
moreFetching.value = true;
|
||||||
@@ -385,13 +246,29 @@ const fetchMore = async (): Promise<void> => {
|
|||||||
if (i === 10) item._shouldInsertAd_ = true;
|
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 (res.length === 0) {
|
||||||
if (props.pagination.reversed) {
|
if (props.pagination.reversed) {
|
||||||
reverseConcat(res);
|
reverseConcat(res).then(() => {
|
||||||
more.value = false;
|
more.value = false;
|
||||||
moreFetching.value = false;
|
moreFetching.value = false;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
items.value = concatMapWithArray(items.value, res);
|
items.value = concatMapWithArray(items.value, res);
|
||||||
more.value = false;
|
more.value = false;
|
||||||
@@ -399,9 +276,10 @@ const fetchMore = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (props.pagination.reversed) {
|
if (props.pagination.reversed) {
|
||||||
reverseConcat(res);
|
reverseConcat(res).then(() => {
|
||||||
more.value = true;
|
more.value = true;
|
||||||
moreFetching.value = false;
|
moreFetching.value = false;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
items.value = concatMapWithArray(items.value, res);
|
items.value = concatMapWithArray(items.value, res);
|
||||||
more.value = true;
|
more.value = true;
|
||||||
@@ -466,19 +344,25 @@ const appearFetchMoreAhead = async (): Promise<void> => {
|
|||||||
fetchMoreAppearTimeout();
|
fetchMoreAppearTimeout();
|
||||||
};
|
};
|
||||||
|
|
||||||
onActivated(() => {
|
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE);
|
||||||
nextTick(() => {
|
|
||||||
active.value = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDeactivated(() => {
|
watch(visibility, () => {
|
||||||
active.value = false;
|
if (visibility.value === 'hidden') {
|
||||||
});
|
timerForSetPause = window.setTimeout(() => {
|
||||||
|
isPausingUpdate = true;
|
||||||
watch([active, visibility], () => {
|
timerForSetPause = null;
|
||||||
if (!backed && active.value && visibility.value === 'visible') {
|
},
|
||||||
executeQueue();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isTop() && !isPausingUpdate) unshiftItems([item]);
|
||||||
!isPausingUpdateByExecutingQueue.value && // スクロール調整中はキューに追加する
|
else prependQueue(item);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新着アイテムをitemsの先頭に追加し、limitを適用する
|
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
|
||||||
* @param newItems 新しいアイテムの配列
|
* @param newItems 新しいアイテムの配列
|
||||||
* @param limit デフォルトはdisplayLimit
|
|
||||||
*/
|
*/
|
||||||
function unshiftItems(newItems: MisskeyEntity[], limit = displayLimit.value) {
|
function unshiftItems(newItems: MisskeyEntity[]) {
|
||||||
const length = newItems.length + items.value.size;
|
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[]) {
|
function concatItems(oldItems: MisskeyEntity[]) {
|
||||||
const length = oldItems.length + items.value.size;
|
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() {
|
function executeQueue() {
|
||||||
// キューが空の場合でもタイムライン表示数を制限する役割がある
|
unshiftItems(Array.from(queue.value.values()));
|
||||||
// ため続行する!
|
queue.value = new Map();
|
||||||
// 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 prependQueue(newItem: MisskeyEntity) {
|
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();
|
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(() => {
|
onMounted(() => {
|
||||||
active.value = true;
|
inited.then(() => {
|
||||||
inited.then(scrollAfterInit);
|
if (props.pagination.reversed) {
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(toBottom, 800);
|
||||||
|
|
||||||
|
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
|
||||||
|
// more = trueを遅らせる
|
||||||
|
setTimeout(() => {
|
||||||
|
moreFetching.value = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (timerForSetPause) {
|
||||||
|
clearTimeout(timerForSetPause);
|
||||||
|
timerForSetPause = null;
|
||||||
|
}
|
||||||
if (preventAppearFetchMoreTimer.value) {
|
if (preventAppearFetchMoreTimer.value) {
|
||||||
clearTimeout(preventAppearFetchMoreTimer.value);
|
clearTimeout(preventAppearFetchMoreTimer.value);
|
||||||
preventAppearFetchMoreTimer.value = null;
|
preventAppearFetchMoreTimer.value = null;
|
||||||
}
|
}
|
||||||
scrollObserver?.disconnect();
|
scrollObserver?.disconnect();
|
||||||
if (scrollRemove) scrollRemove();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
items,
|
items,
|
||||||
queue,
|
queue,
|
||||||
|
backed,
|
||||||
more,
|
more,
|
||||||
inited,
|
inited,
|
||||||
queueSize,
|
|
||||||
backed: $$(backed),
|
|
||||||
reload,
|
reload,
|
||||||
prepend,
|
prepend,
|
||||||
append: appendItem,
|
append: appendItem,
|
||||||
|
@@ -4,17 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -24,8 +14,6 @@ import { useStream } from '@/stream';
|
|||||||
import * as sound from '@/scripts/sound';
|
import * as sound from '@/scripts/sound';
|
||||||
import { $i } from '@/account';
|
import { $i } from '@/account';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
import { i18n } from '@/i18n';
|
|
||||||
import { instance } from '@/instance';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
src: string;
|
src: string;
|
||||||
@@ -38,22 +26,15 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'note'): void;
|
(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'));
|
provide('inChannel', computed(() => props.src === 'channel'));
|
||||||
|
|
||||||
let tlComponent: InstanceType<typeof MkNotes> | undefined = $ref();
|
const tlComponent: InstanceType<typeof MkNotes> = $ref();
|
||||||
|
|
||||||
const queueSize = computed(() => {
|
|
||||||
return tlComponent?.pagingComponent?.queueSize ?? 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const prepend = note => {
|
const prepend = note => {
|
||||||
tlComponent?.pagingComponent?.prepend(note);
|
tlComponent.pagingComponent?.prepend(note);
|
||||||
|
|
||||||
emit('note');
|
emit('note');
|
||||||
|
|
||||||
@@ -178,48 +159,4 @@ const timetravel = (date?: Date) => {
|
|||||||
this.$refs.tl.reload();
|
this.$refs.tl.reload();
|
||||||
};
|
};
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const reload = () => {
|
|
||||||
tlComponent?.pagingComponent?.reload();
|
|
||||||
emit('reload');
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
reload,
|
|
||||||
queueSize,
|
|
||||||
});
|
|
||||||
</script>
|
</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>
|
|
||||||
|
@@ -40,7 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]">
|
<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]">
|
||||||
<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div>
|
<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div>
|
||||||
<MkTimeline src="local" :class="$style.tlBody"/>
|
<div :class="$style.tlBody">
|
||||||
|
<MkTimeline src="local"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.panel">
|
<div :class="$style.panel">
|
||||||
<XActiveUsersChart/>
|
<XActiveUsersChart/>
|
||||||
|
@@ -16,18 +16,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, inject, onBeforeUnmount, provide, nextTick } from 'vue';
|
import { inject, onBeforeUnmount, provide } from 'vue';
|
||||||
import { NiraxChangeEvent, Resolved, Router } from '@/nirax';
|
import { Resolved, Router } from '@/nirax';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
import { getScrollContainer } from '@/scripts/scroll';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
router?: Router;
|
router?: Router;
|
||||||
|
|
||||||
/**
|
|
||||||
* Set any element if scroll position management needed
|
|
||||||
*/
|
|
||||||
scrollContainer?: HTMLElement | null;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const router = props.router ?? inject('router');
|
const router = props.router ?? inject('router');
|
||||||
@@ -56,49 +50,17 @@ let currentPageComponent = $shallowRef(current.route.component);
|
|||||||
let currentPageProps = $ref(current.props);
|
let currentPageProps = $ref(current.props);
|
||||||
let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(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);
|
function onChange({ resolved, key: newKey }) {
|
||||||
|
const current = resolveNested(resolved);
|
||||||
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);
|
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
currentPageComponent = current.route.component;
|
currentPageComponent = current.route.component;
|
||||||
currentPageProps = current.props;
|
currentPageProps = current.props;
|
||||||
key = current.route.path + JSON.stringify(Object.fromEntries(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);
|
router.addListener('change', onChange);
|
||||||
|
|
||||||
function onSame() {
|
|
||||||
if (!scrollContainer.value) return;
|
|
||||||
scrollContainer.value.scroll({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
router.addListener('same', onSame);
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
router.removeListener('change', onChange);
|
router.removeListener('change', onChange);
|
||||||
router.removeListener('same', onSame);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@@ -54,30 +54,24 @@ function parsePath(path: string): ParsedPath {
|
|||||||
return res;
|
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<{
|
export class Router extends EventEmitter<{
|
||||||
change: (ctx: NiraxChangeEvent) => void;
|
change: (ctx: {
|
||||||
replace: (ctx: NiraxExportEvent) => void;
|
beforePath: string;
|
||||||
push: (ctx: NiraxExportEvent) => void;
|
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;
|
same: () => void;
|
||||||
}> {
|
}> {
|
||||||
private routes: RouteDef[];
|
private routes: RouteDef[];
|
||||||
@@ -282,3 +276,29 @@ export class Router extends EventEmitter<{
|
|||||||
this.navigate(path, key);
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@@ -8,13 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
<MkSpacer :contentMax="800">
|
<MkSpacer :contentMax="800">
|
||||||
<div ref="rootEl" v-hotkey.global="keymap">
|
<div ref="rootEl" v-hotkey.global="keymap">
|
||||||
<MkTimeline
|
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||||
ref="tlEl" :key="antennaId"
|
<div :class="$style.tl">
|
||||||
src="antenna"
|
<MkTimeline
|
||||||
:antenna="antennaId"
|
ref="tlEl" :key="antennaId"
|
||||||
:sound="true"
|
src="antenna"
|
||||||
:class="$style.tl"
|
:antenna="antennaId"
|
||||||
/>
|
:sound="true"
|
||||||
|
@queue="queueUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
@@ -23,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import MkTimeline from '@/components/MkTimeline.vue';
|
import MkTimeline from '@/components/MkTimeline.vue';
|
||||||
|
import { scroll } from '@/scripts/scroll';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { useRouter } from '@/router';
|
import { useRouter } from '@/router';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||||
@@ -35,14 +39,19 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
let antenna = $ref(null);
|
let antenna = $ref(null);
|
||||||
|
let queue = $ref(0);
|
||||||
let rootEl = $shallowRef<HTMLElement>();
|
let rootEl = $shallowRef<HTMLElement>();
|
||||||
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
|
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||||
const keymap = $computed(() => ({
|
const keymap = $computed(() => ({
|
||||||
't': focus,
|
't': focus,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function queueUpdated(q) {
|
||||||
|
queue = q;
|
||||||
|
}
|
||||||
|
|
||||||
function top() {
|
function top() {
|
||||||
tlEl?.reload();
|
scroll(rootEl, { top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function timetravel() {
|
async function timetravel() {
|
||||||
@@ -87,6 +96,25 @@ definePageMetadata(computed(() => antenna ? {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<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 {
|
.tl {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
@@ -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'"/>
|
<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>
|
||||||
<div v-else-if="tab === 'featured'">
|
<div v-else-if="tab === 'featured'">
|
||||||
<MkNotes :pagination="featuredPagination"/>
|
<MkNotes :pagination="featuredPagination"/>
|
||||||
|
@@ -26,18 +26,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</MkRadios>
|
</MkRadios>
|
||||||
|
|
||||||
<FormSection>
|
<FormSection>
|
||||||
<div class="_gaps_m">
|
<div class="_gaps_s">
|
||||||
<div class="_gaps_s">
|
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
|
||||||
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
|
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</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>
|
||||||
<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>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
@@ -201,8 +193,6 @@ import { unisonReload } from '@/scripts/unison-reload';
|
|||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||||
import { miLocalStorage } from '@/local-storage';
|
import { miLocalStorage } from '@/local-storage';
|
||||||
import { isWebKit } from '@/scripts/useragent';
|
|
||||||
import { testNotification } from '@/scripts/test-notification';
|
|
||||||
import { globalEvents } from '@/events';
|
import { globalEvents } from '@/events';
|
||||||
import { claimAchievement } from '@/scripts/achievements';
|
import { claimAchievement } from '@/scripts/achievements';
|
||||||
|
|
||||||
@@ -251,7 +241,6 @@ const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('
|
|||||||
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
|
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
|
||||||
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
|
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
|
||||||
const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies'));
|
const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies'));
|
||||||
const timelineBackTopBehavior = computed(defaultStore.makeGetterSetter('timelineBackTopBehavior'));
|
|
||||||
|
|
||||||
watch(lang, () => {
|
watch(lang, () => {
|
||||||
miLocalStorage.setItem('lang', lang.value as string);
|
miLocalStorage.setItem('lang', lang.value as string);
|
||||||
|
@@ -92,7 +92,6 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
|
|||||||
'numberOfPageCache',
|
'numberOfPageCache',
|
||||||
'aiChanMode',
|
'aiChanMode',
|
||||||
'mediaListWithOneImageAppearance',
|
'mediaListWithOneImageAppearance',
|
||||||
'timelineBackTopBehavior',
|
|
||||||
];
|
];
|
||||||
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
|
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
|
||||||
'lightTheme',
|
'lightTheme',
|
||||||
|
@@ -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);"/>
|
<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);"/>
|
<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
|
||||||
|
|
||||||
<MkTimeline
|
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||||
ref="tlComponent"
|
<div :class="$style.tl">
|
||||||
:key="src"
|
<MkTimeline
|
||||||
:src="src"
|
ref="tlComponent"
|
||||||
:sound="true"
|
:key="src"
|
||||||
:class="$style.tl"
|
:src="src"
|
||||||
/>
|
:sound="true"
|
||||||
|
@queue="queueUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
@@ -28,6 +31,7 @@ import { defineAsyncComponent, computed, watch, provide } from 'vue';
|
|||||||
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
|
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
|
||||||
import MkTimeline from '@/components/MkTimeline.vue';
|
import MkTimeline from '@/components/MkTimeline.vue';
|
||||||
import MkPostForm from '@/components/MkPostForm.vue';
|
import MkPostForm from '@/components/MkPostForm.vue';
|
||||||
|
import { scroll } from '@/scripts/scroll';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
@@ -50,11 +54,18 @@ const keymap = {
|
|||||||
const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>();
|
const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||||
const rootEl = $shallowRef<HTMLElement>();
|
const rootEl = $shallowRef<HTMLElement>();
|
||||||
|
|
||||||
|
let queue = $ref(0);
|
||||||
let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global');
|
let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global');
|
||||||
const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) });
|
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 {
|
function top(): void {
|
||||||
tlComponent?.reload();
|
if (rootEl) scroll(rootEl, { top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseList(ev: MouseEvent): Promise<void> {
|
async function chooseList(ev: MouseEvent): Promise<void> {
|
||||||
@@ -173,6 +184,25 @@ definePageMetadata(computed(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<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 {
|
.postForm {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
@@ -8,13 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
<MkSpacer :contentMax="800">
|
<MkSpacer :contentMax="800">
|
||||||
<div ref="rootEl">
|
<div ref="rootEl">
|
||||||
<MkTimeline
|
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||||
ref="tlEl" :key="listId"
|
<div :class="$style.tl">
|
||||||
src="list"
|
<MkTimeline
|
||||||
:list="listId"
|
ref="tlEl" :key="listId"
|
||||||
:sound="true"
|
src="list"
|
||||||
:class="$style.tl"
|
:list="listId"
|
||||||
/>
|
:sound="true"
|
||||||
|
@queue="queueUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
@@ -23,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import MkTimeline from '@/components/MkTimeline.vue';
|
import MkTimeline from '@/components/MkTimeline.vue';
|
||||||
|
import { scroll } from '@/scripts/scroll';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { useRouter } from '@/router';
|
import { useRouter } from '@/router';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||||
@@ -35,6 +39,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
let list = $ref(null);
|
let list = $ref(null);
|
||||||
|
let queue = $ref(0);
|
||||||
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
|
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||||
let rootEl = $shallowRef<HTMLElement>();
|
let rootEl = $shallowRef<HTMLElement>();
|
||||||
|
|
||||||
@@ -44,8 +49,12 @@ watch(() => props.listId, async () => {
|
|||||||
});
|
});
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
|
function queueUpdated(q) {
|
||||||
|
queue = q;
|
||||||
|
}
|
||||||
|
|
||||||
function top() {
|
function top() {
|
||||||
tlEl?.reload();
|
scroll(rootEl, { top: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function settings() {
|
function settings() {
|
||||||
@@ -80,6 +89,24 @@ definePageMetadata(computed(() => list ? {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<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 {
|
.tl {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
@@ -30,7 +30,7 @@ export function getScrollPosition(el: HTMLElement | null): number {
|
|||||||
|
|
||||||
export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) {
|
export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) {
|
||||||
// とりあえず評価してみる
|
// とりあえず評価してみる
|
||||||
if (el.isConnected && isTopVisible(el, tolerance)) {
|
if (el.isConnected && isTopVisible(el)) {
|
||||||
cb();
|
cb();
|
||||||
if (once) return null;
|
if (once) return null;
|
||||||
}
|
}
|
||||||
@@ -75,29 +75,12 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
|
|||||||
return removeListener;
|
return removeListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) {
|
||||||
* コンテナを指定してスクロールする
|
const container = getScrollContainer(el);
|
||||||
* @param el Container element
|
if (container == null) {
|
||||||
* @param options ScrollToOptions
|
|
||||||
*/
|
|
||||||
export function scroll(el: HTMLElement | null, options: ScrollToOptions | undefined) {
|
|
||||||
if (el == null) {
|
|
||||||
window.scroll(options);
|
window.scroll(options);
|
||||||
} else {
|
} else {
|
||||||
el.scroll(options);
|
container.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +89,8 @@ export function scrollBy(el: HTMLElement | null, options: ScrollToOptions | unde
|
|||||||
* @param el Scroll container element
|
* @param el Scroll container element
|
||||||
* @param options Scroll options
|
* @param options Scroll options
|
||||||
*/
|
*/
|
||||||
export function scrollToTop(el: HTMLElement | null, options: { behavior?: ScrollBehavior; } = {}) {
|
export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) {
|
||||||
scroll(getScrollContainer(el), { top: 0, ...options });
|
scroll(el, { top: 0, ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
import { UAParser } from 'ua-parser-js';
|
|
||||||
const ua = new UAParser(navigator.userAgent);
|
|
||||||
export const isWebKit = () => ua.getEngine().name === 'WebKit';
|
|
@@ -6,7 +6,6 @@
|
|||||||
import { markRaw, ref } from 'vue';
|
import { markRaw, ref } from 'vue';
|
||||||
import misskey from 'misskey-js';
|
import misskey from 'misskey-js';
|
||||||
import { Storage } from './pizzax';
|
import { Storage } from './pizzax';
|
||||||
import { isWebKit } from './scripts/useragent';
|
|
||||||
|
|
||||||
interface PostFormAction {
|
interface PostFormAction {
|
||||||
title: string,
|
title: string,
|
||||||
@@ -353,10 +352,6 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||||||
where: 'device',
|
where: 'device',
|
||||||
default: {} as Record<string, Record<string, string[]>>,
|
default: {} as Record<string, Record<string, string[]>>,
|
||||||
},
|
},
|
||||||
timelineBackTopBehavior: {
|
|
||||||
where: 'device',
|
|
||||||
default: (isWebKit() ? 'newest' : 'next') as 'newest' | 'next',
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// TODO: 他のタブと永続化されたstateを同期
|
// TODO: 他のタブと永続化されたstateを同期
|
||||||
|
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div ref="contents">
|
<div ref="contents">
|
||||||
<RouterView :scrollContainer="contents" @contextmenu.stop="onContextmenu"/>
|
<RouterView @contextmenu.stop="onContextmenu"/>
|
||||||
</div>
|
</div>
|
||||||
</XColumn>
|
</XColumn>
|
||||||
</template>
|
</template>
|
||||||
@@ -26,6 +26,8 @@ import * as os from '@/os';
|
|||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import { mainRouter } from '@/router';
|
import { mainRouter } from '@/router';
|
||||||
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
||||||
|
import { useScrollPositionManager } from '@/nirax';
|
||||||
|
import { getScrollContainer } from '@/scripts/scroll';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
column: Column;
|
column: Column;
|
||||||
@@ -69,4 +71,6 @@ function onContextmenu(ev: MouseEvent) {
|
|||||||
},
|
},
|
||||||
}], ev);
|
}], ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useScrollPositionManager(() => getScrollContainer(contents.value), mainRouter);
|
||||||
</script>
|
</script>
|
||||||
|
@@ -13,7 +13,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<span style="margin-left: 8px;">{{ column.name }}</span>
|
<span style="margin-left: 8px;">{{ column.name }}</span>
|
||||||
</template>
|
</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>
|
</XColumn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -23,16 +30,27 @@ import XColumn from './column.vue';
|
|||||||
import { removeColumn, updateColumn, Column } from './deck-store';
|
import { removeColumn, updateColumn, Column } from './deck-store';
|
||||||
import MkTimeline from '@/components/MkTimeline.vue';
|
import MkTimeline from '@/components/MkTimeline.vue';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
|
import { $i } from '@/account';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
import { instance } from '@/instance';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
column: Column;
|
column: Column;
|
||||||
isStacked: boolean;
|
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(() => {
|
onMounted(() => {
|
||||||
if (props.column.tl == null) {
|
if (props.column.tl == null) {
|
||||||
setType();
|
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,
|
action: setType,
|
||||||
}];
|
}];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.disabled {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabledTitle {
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabledDescription {
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<XStatusBars :class="$style.statusbars"/>
|
<XStatusBars :class="$style.statusbars"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<RouterView :scrollContainer="contents?.rootEl"/>
|
<RouterView/>
|
||||||
<div :class="$style.spacer"></div>
|
<div :class="$style.spacer"></div>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
|
|
||||||
@@ -105,6 +105,7 @@ import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
|||||||
import { deviceKind } from '@/scripts/device-kind';
|
import { deviceKind } from '@/scripts/device-kind';
|
||||||
import { miLocalStorage } from '@/local-storage';
|
import { miLocalStorage } from '@/local-storage';
|
||||||
import { CURRENT_STICKY_BOTTOM } from '@/const';
|
import { CURRENT_STICKY_BOTTOM } from '@/const';
|
||||||
|
import { useScrollPositionManager } from '@/nirax';
|
||||||
|
|
||||||
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
|
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
|
||||||
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
|
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
|
||||||
@@ -226,6 +227,8 @@ watch($$(navFooter), () => {
|
|||||||
}, {
|
}, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useScrollPositionManager(() => contents.value.rootEl, mainRouter);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@@ -20,20 +20,33 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</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>
|
</MkContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
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 { GetFormResultType } from '@/scripts/form';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import MkContainer from '@/components/MkContainer.vue';
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
import MkTimeline from '@/components/MkTimeline.vue';
|
import MkTimeline from '@/components/MkTimeline.vue';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
import { $i } from '@/account';
|
||||||
|
import { instance } from '@/instance';
|
||||||
|
|
||||||
const name = 'timeline';
|
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 = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
@@ -128,3 +141,17 @@ defineExpose<WidgetComponentExpose>({
|
|||||||
id: props.widget ? props.widget.id : null,
|
id: props.widget ? props.widget.id : null,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.disabled {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabledTitle {
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabledDescription {
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -799,9 +799,6 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: 5.2.2
|
specifier: 5.2.2
|
||||||
version: 5.2.2
|
version: 5.2.2
|
||||||
ua-parser-js:
|
|
||||||
specifier: 2.0.0-alpha.2
|
|
||||||
version: 2.0.0-alpha.2
|
|
||||||
uuid:
|
uuid:
|
||||||
specifier: 9.0.1
|
specifier: 9.0.1
|
||||||
version: 9.0.1
|
version: 9.0.1
|
||||||
@@ -11849,7 +11846,6 @@ packages:
|
|||||||
/form-data@3.0.1:
|
/form-data@3.0.1:
|
||||||
resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
|
resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
requiresBuild: true
|
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit: 0.4.0
|
asynckit: 0.4.0
|
||||||
combined-stream: 1.0.8
|
combined-stream: 1.0.8
|
||||||
@@ -18822,10 +18818,6 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
/ua-parser-js@2.0.0-alpha.2:
|
|
||||||
resolution: {integrity: sha512-Vz+BJN/EFC1OaUv0eu5kPyX7HEZIO7Dv29jIK7rMuKjUB1qqq+Is/XIpu5iV5XDvoNl62dM7ay8DtzYjBDI0WA==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/ufo@1.1.2:
|
/ufo@1.1.2:
|
||||||
resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==}
|
resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
Reference in New Issue
Block a user