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
|
||||
-
|
||||
- 最初照会したユーザーの最新ノートを受け取るように
|
||||
|
||||
-->
|
||||
|
||||
|
@@ -187,6 +187,10 @@ id: "aidx"
|
||||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
# Limit of notes to fetch from outbox with remote user first fetched (default: 5)
|
||||
# https://github.com/misskey-dev/misskey/pull/11130
|
||||
outboxNotesFetchLimit: 5
|
||||
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
|
7
locales/index.d.ts
vendored
7
locales/index.d.ts
vendored
@@ -533,7 +533,7 @@ export interface Locale {
|
||||
"deleteAll": string;
|
||||
"showFixedPostForm": string;
|
||||
"showFixedPostFormInChannel": string;
|
||||
"goToTheHeadOfTimeline": string;
|
||||
"newNoteRecived": string;
|
||||
"sounds": string;
|
||||
"sound": string;
|
||||
"listen": string;
|
||||
@@ -1103,7 +1103,6 @@ export interface Locale {
|
||||
"doYouAgree": string;
|
||||
"beSureToReadThisAsItIsImportant": string;
|
||||
"iHaveReadXCarefullyAndAgree": string;
|
||||
"timelineBackTopBehavior": string;
|
||||
"dialog": string;
|
||||
"icon": string;
|
||||
"forYou": string;
|
||||
@@ -1673,10 +1672,6 @@ export interface Locale {
|
||||
"dialog": string;
|
||||
"quiet": string;
|
||||
};
|
||||
"_timelineBackTopBehavior": {
|
||||
"newest": string;
|
||||
"next": string;
|
||||
};
|
||||
"_channel": {
|
||||
"create": string;
|
||||
"edit": string;
|
||||
|
@@ -530,7 +530,7 @@ serverLogs: "サーバーログ"
|
||||
deleteAll: "全て削除"
|
||||
showFixedPostForm: "タイムライン上部に投稿フォームを表示する"
|
||||
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
|
||||
goToTheHeadOfTimeline: "最新のノートに移動"
|
||||
newNoteRecived: "新しいノートがあります"
|
||||
sounds: "サウンド"
|
||||
sound: "サウンド"
|
||||
listen: "聴く"
|
||||
@@ -1100,7 +1100,6 @@ expired: "期限切れ"
|
||||
doYouAgree: "同意しますか?"
|
||||
beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。"
|
||||
iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。"
|
||||
timelineBackTopBehavior: "タイムラインのスクロールが先頭に戻った時の挙動"
|
||||
dialog: "ダイアログ"
|
||||
icon: "アイコン"
|
||||
forYou: "あなたへ"
|
||||
@@ -1590,10 +1589,6 @@ _serverDisconnectedBehavior:
|
||||
dialog: "ダイアログで警告"
|
||||
quiet: "控えめに警告"
|
||||
|
||||
_timelineBackTopBehavior:
|
||||
newest: "最新の投稿を表示"
|
||||
next: "次の投稿を遡る"
|
||||
|
||||
_channel:
|
||||
create: "チャンネルを作成"
|
||||
edit: "チャンネルを編集"
|
||||
|
@@ -85,6 +85,7 @@ type Source = {
|
||||
videoThumbnailGenerator?: string;
|
||||
|
||||
signToActivityPubGet?: boolean;
|
||||
outboxNotesFetchLimit?: number;
|
||||
|
||||
perChannelMaxNoteCacheCount?: number;
|
||||
perUserNotificationsMaxCount?: number;
|
||||
|
@@ -27,7 +27,7 @@ import { QueueService } from '@/core/QueueService.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiRemoteUser } from '@/models/entities/User.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isOrderedCollectionPage, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
@@ -87,11 +87,19 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<void> {
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
public async performActivity(actor: MiRemoteUser, activity: IObject, {
|
||||
limit = Infinity,
|
||||
allow = null as (string[] | null) } = {},
|
||||
): Promise<void> {
|
||||
if (isCollectionOrOrderedCollection(activity) || isOrderedCollectionPage(activity)) {
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
|
||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems).slice(0, limit)) {
|
||||
const act = await resolver.resolve(item);
|
||||
const type = getApType(act);
|
||||
if (allow && !allow.includes(type)) {
|
||||
this.logger.info(`skipping activity type: ${type}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.performOneActivity(actor, act);
|
||||
} catch (err) {
|
||||
@@ -367,7 +375,7 @@ export class ApInboxService {
|
||||
});
|
||||
|
||||
if (isPost(object)) {
|
||||
this.createNote(resolver, actor, object, false, activity);
|
||||
await this.createNote(resolver, actor, object, false, activity);
|
||||
} else {
|
||||
this.logger.warn(`Unknown type: ${getApType(object)}`);
|
||||
}
|
||||
|
@@ -15,11 +15,11 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { isCollectionOrOrderedCollection } from './type.js';
|
||||
import { isCollectionOrOrderedCollection, isOrderedCollectionPage } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
import { ApRequestService } from './ApRequestService.js';
|
||||
import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
||||
import type { IObject, ICollection, IOrderedCollection, IOrderedCollectionPage } from './type.js';
|
||||
|
||||
export class Resolver {
|
||||
private history: Set<string>;
|
||||
@@ -64,6 +64,18 @@ export class Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
public async resolveOrderedCollectionPage(value: string | IObject): Promise<IOrderedCollectionPage> {
|
||||
const collection = typeof value === 'string'
|
||||
? await this.resolve(value)
|
||||
: value;
|
||||
|
||||
if (isOrderedCollectionPage(collection)) {
|
||||
return collection;
|
||||
} else {
|
||||
throw new Error(`unrecognized collection type: ${collection.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async resolve(value: string | IObject): Promise<IObject> {
|
||||
if (typeof value !== 'string') {
|
||||
|
@@ -38,7 +38,8 @@ import { MetaService } from '@/core/MetaService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage, isPropertyValue } from '../type.js';
|
||||
import { ApInboxService } from '../ApInboxService.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { ApNoteService } from './ApNoteService.js';
|
||||
@@ -68,6 +69,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
private apResolverService: ApResolverService;
|
||||
private apNoteService: ApNoteService;
|
||||
private apImageService: ApImageService;
|
||||
private apInboxService: ApInboxService;
|
||||
private apMfmService: ApMfmService;
|
||||
private mfmService: MfmService;
|
||||
private hashtagService: HashtagService;
|
||||
@@ -116,6 +118,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
this.apResolverService = this.moduleRef.get('ApResolverService');
|
||||
this.apNoteService = this.moduleRef.get('ApNoteService');
|
||||
this.apImageService = this.moduleRef.get('ApImageService');
|
||||
this.apInboxService = this.moduleRef.get('ApInboxService');
|
||||
this.apMfmService = this.moduleRef.get('ApMfmService');
|
||||
this.mfmService = this.moduleRef.get('MfmService');
|
||||
this.hashtagService = this.moduleRef.get('HashtagService');
|
||||
@@ -384,7 +387,10 @@ export class ApPersonService implements OnModuleInit {
|
||||
}
|
||||
//#endregion
|
||||
|
||||
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
|
||||
await Promise.allSettled([
|
||||
this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)),
|
||||
this.updateOutboxFirstPage(user, person.outbox, resolver).catch(err => this.logger.error(err)),
|
||||
]);
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -589,7 +595,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
||||
const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x)));
|
||||
|
||||
// Resolve and regist Notes
|
||||
// Resolve and register Notes
|
||||
const limit = promiseLimit<MiNote | null>(2);
|
||||
const featuredNotes = await Promise.all(items
|
||||
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
|
||||
@@ -616,6 +622,35 @@ export class ApPersonService implements OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve outbox from an actor object.
|
||||
*
|
||||
* This only retrieves the first page for now.
|
||||
*/
|
||||
public async updateOutboxFirstPage(user: RemoteUser, outbox: IActor['outbox'], resolver: Resolver): Promise<void> {
|
||||
if (!this.config.outboxNotesFetchLimit) return;
|
||||
|
||||
// https://www.w3.org/TR/activitypub/#actor-objects
|
||||
// Outbox is a required property for all actors
|
||||
if (!outbox) {
|
||||
throw new Error('No outbox property');
|
||||
}
|
||||
|
||||
this.logger.info(`Fetching the outbox for ${user.uri}: ${outbox}`);
|
||||
|
||||
const collection = await resolver.resolveCollection(outbox);
|
||||
if (!isOrderedCollection(collection)) {
|
||||
throw new Error('Outbox must be an ordered collection');
|
||||
}
|
||||
|
||||
const firstPage = collection.first ?
|
||||
await resolver.resolveOrderedCollectionPage(collection.first) :
|
||||
collection;
|
||||
|
||||
// Perform activity but only the first outboxNotesFetchLimit ones with `type: Create`
|
||||
await this.apInboxService.performActivity(user, firstPage, { limit: this.config.outboxNotesFetchLimit, allow: ['Create'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* リモート由来のアカウント移行処理を行います
|
||||
* @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提)
|
||||
|
@@ -92,16 +92,37 @@ export interface IActivity extends IObject {
|
||||
};
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection
|
||||
export interface ICollection extends IObject {
|
||||
type: 'Collection';
|
||||
totalItems: number;
|
||||
current?: ICollectionPage | string;
|
||||
first?: ICollectionPage | string;
|
||||
last?: ICollectionPage | string;
|
||||
items: ApObject;
|
||||
}
|
||||
|
||||
export interface IOrderedCollection extends IObject {
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection
|
||||
export interface IOrderedCollection extends Omit<ICollection, 'type' | 'items'> {
|
||||
type: 'OrderedCollection';
|
||||
totalItems: number;
|
||||
orderedItems: ApObject;
|
||||
|
||||
// orderedItems is not defined well
|
||||
// https://github.com/w3c/activitystreams/issues/494
|
||||
orderedItems?: ApObject;
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
|
||||
export interface ICollectionPage extends Omit<ICollection, 'type'> {
|
||||
type: 'CollectionPage';
|
||||
partOf?: ICollection | string;
|
||||
next?: ICollectionPage | string;
|
||||
prev?: ICollectionPage | string;
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollectionpage
|
||||
export interface IOrderedCollectionPage extends Omit<IOrderedCollection, 'type'>, Omit<ICollectionPage, 'type' | 'items'> {
|
||||
type: 'OrderedCollectionPage';
|
||||
startIndex?: number,
|
||||
}
|
||||
|
||||
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
|
||||
@@ -188,6 +209,9 @@ export const isCollection = (object: IObject): object is ICollection =>
|
||||
export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
|
||||
getApType(object) === 'OrderedCollection';
|
||||
|
||||
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
|
||||
getApType(object) === 'OrderedCollectionPage';
|
||||
|
||||
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
|
||||
isCollection(object) || isOrderedCollection(object);
|
||||
|
||||
|
@@ -68,7 +68,7 @@ export class MockResolver extends Resolver {
|
||||
const r = this.#responseMap.get(value);
|
||||
|
||||
if (!r) {
|
||||
throw new Error('Not registed for mock');
|
||||
throw new Error('Not registered for mock');
|
||||
}
|
||||
|
||||
const object = JSON.parse(r.content);
|
||||
|
@@ -17,7 +17,7 @@ import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { IActor, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js';
|
||||
import type { IActivity, IApDocument, IActor, ICollection, IObject, IOrderedCollection, IOrderedCollectionPage, IPost } from '@/core/activitypub/type.js';
|
||||
import { MiMeta, MiNote } from '@/models/_.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
@@ -29,6 +29,16 @@ const host = 'https://host1.test';
|
||||
|
||||
type NonTransientIActor = IActor & { id: string };
|
||||
type NonTransientIPost = IPost & { id: string };
|
||||
type NonTransientICollection = ICollection & { id: string };
|
||||
type NonTransientIOrderedCollection = IOrderedCollection & { id: string };
|
||||
type NonTransientIOrderedCollectionPage = IOrderedCollectionPage & { id: string };
|
||||
|
||||
/**
|
||||
* Use when the order of the array is not definitive
|
||||
*/
|
||||
function deepSortedEqual<T extends unknown[]>(array1: unknown[], array2: T): asserts array1 is T {
|
||||
return assert.deepStrictEqual(array1.sort(), array2.sort());
|
||||
}
|
||||
|
||||
function createRandomActor({ actorHost = host } = {}): NonTransientIActor {
|
||||
const preferredUsername = secureRndstr(8);
|
||||
@@ -60,7 +70,7 @@ function createRandomNotes(actor: NonTransientIActor, length: number): NonTransi
|
||||
return new Array(length).fill(null).map(() => createRandomNote(actor));
|
||||
}
|
||||
|
||||
function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): ICollection {
|
||||
function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): NonTransientICollection {
|
||||
const items = createRandomNotes(actor, length);
|
||||
|
||||
return {
|
||||
@@ -72,6 +82,53 @@ function createRandomFeaturedCollection(actor: NonTransientIActor, length: numbe
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomActivities(actor: NonTransientIActor, type: string, length: number): IActivity[] {
|
||||
return new Array(length).fill(null).map((): IActivity => {
|
||||
const note = createRandomNote(actor);
|
||||
|
||||
return {
|
||||
type,
|
||||
id: `${note.id}/activity`,
|
||||
actor,
|
||||
object: note,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createRandomNonPagedOutbox(actor: NonTransientIActor, length: number): NonTransientIOrderedCollection {
|
||||
const orderedItems = createRandomActivities(actor, 'Create', length);
|
||||
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'OrderedCollection',
|
||||
id: actor.outbox as string,
|
||||
totalItems: orderedItems.length,
|
||||
orderedItems,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomOutboxPage(actor: NonTransientIActor, id: string, length: number): NonTransientIOrderedCollectionPage {
|
||||
const orderedItems = createRandomActivities(actor, 'Create', length);
|
||||
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'OrderedCollectionPage',
|
||||
id,
|
||||
totalItems: orderedItems.length,
|
||||
orderedItems,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomPagedOutbox(actor: NonTransientIActor): NonTransientIOrderedCollection {
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'OrderedCollection',
|
||||
id: actor.outbox as string,
|
||||
totalItems: 10,
|
||||
first: `${actor.outbox}?first`,
|
||||
};
|
||||
}
|
||||
|
||||
async function createRandomRemoteUser(
|
||||
resolver: MockResolver,
|
||||
personService: ApPersonService,
|
||||
@@ -196,7 +253,7 @@ describe('ActivityPub', () => {
|
||||
|
||||
describe('Renderer', () => {
|
||||
test('Render an announce with visibility: followers', () => {
|
||||
rendererService.renderAnnounce(null, {
|
||||
rendererService.renderAnnounce('hoge', {
|
||||
createdAt: new Date(0),
|
||||
visibility: 'followers',
|
||||
} as MiNote);
|
||||
@@ -216,7 +273,7 @@ describe('ActivityPub', () => {
|
||||
await personService.createPerson(actor.id, resolver);
|
||||
|
||||
// All notes in `featured` are same-origin, no need to fetch notes again
|
||||
assert.deepStrictEqual(resolver.remoteGetTrials(), [actor.id, actor.featured]);
|
||||
deepSortedEqual(resolver.remoteGetTrials(), [actor.id, actor.featured, actor.outbox]);
|
||||
|
||||
// Created notes without resolving anything
|
||||
for (const item of featured.items as IPost[]) {
|
||||
@@ -247,9 +304,9 @@ describe('ActivityPub', () => {
|
||||
await personService.createPerson(actor1.id, resolver);
|
||||
|
||||
// actor2Note is from a different server and needs to be fetched again
|
||||
assert.deepStrictEqual(
|
||||
deepSortedEqual(
|
||||
resolver.remoteGetTrials(),
|
||||
[actor1.id, actor1.featured, actor2Note.id, actor2.id],
|
||||
[actor1.id, actor1.featured, actor1.outbox, actor2Note.id, actor2.id, actor2.outbox],
|
||||
);
|
||||
|
||||
const note = await noteService.fetchNote(actor2Note.id);
|
||||
@@ -276,6 +333,95 @@ describe('ActivityPub', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outbox', () => {
|
||||
test('Fetch non-paged outbox from IActor', async () => {
|
||||
const actor = createRandomActor();
|
||||
const outbox = createRandomNonPagedOutbox(actor, 10);
|
||||
|
||||
resolver.register(actor.id, actor);
|
||||
resolver.register(actor.outbox as string, outbox);
|
||||
|
||||
await personService.createPerson(actor.id, resolver);
|
||||
|
||||
deepSortedEqual(
|
||||
resolver.remoteGetTrials(),
|
||||
[actor.id, actor.outbox],
|
||||
);
|
||||
|
||||
for (const item of outbox.orderedItems as IActivity[]) {
|
||||
const note = await noteService.fetchNote(item.object);
|
||||
assert.ok(note);
|
||||
assert.strictEqual(note.text, 'test test foo');
|
||||
assert.strictEqual(note.uri, (item.object as IObject).id);
|
||||
}
|
||||
});
|
||||
|
||||
test('Fetch paged outbox from IActor', async () => {
|
||||
const actor = createRandomActor();
|
||||
const outbox = createRandomPagedOutbox(actor);
|
||||
const page = createRandomOutboxPage(actor, outbox.id, 10);
|
||||
|
||||
resolver.register(actor.id, actor);
|
||||
resolver.register(actor.outbox as string, outbox);
|
||||
resolver.register(outbox.first as string, page);
|
||||
|
||||
await personService.createPerson(actor.id, resolver);
|
||||
|
||||
deepSortedEqual(
|
||||
resolver.remoteGetTrials(),
|
||||
[actor.id, actor.outbox, outbox.first],
|
||||
);
|
||||
|
||||
for (const item of page.orderedItems as IActivity[]) {
|
||||
const note = await noteService.fetchNote(item.object);
|
||||
assert.ok(note);
|
||||
assert.strictEqual(note.text, 'test test foo');
|
||||
assert.strictEqual(note.uri, (item.object as IObject).id);
|
||||
}
|
||||
});
|
||||
|
||||
test('Fetch only the first 20 items', async () => {
|
||||
const actor = createRandomActor();
|
||||
const outbox = createRandomNonPagedOutbox(actor, 200);
|
||||
|
||||
resolver.register(actor.id, actor);
|
||||
resolver.register(actor.outbox as string, outbox);
|
||||
|
||||
await personService.createPerson(actor.id, resolver);
|
||||
|
||||
const items = outbox.orderedItems as IActivity[];
|
||||
|
||||
deepSortedEqual(
|
||||
resolver.remoteGetTrials(),
|
||||
[actor.id, actor.outbox],
|
||||
);
|
||||
|
||||
assert.ok(await noteService.fetchNote(items[19].object));
|
||||
assert.ok(!await noteService.fetchNote(items[20].object));
|
||||
});
|
||||
|
||||
test('Perform only Create activities', async () => {
|
||||
const actor = createRandomActor();
|
||||
const outbox = createRandomNonPagedOutbox(actor, 0);
|
||||
outbox.orderedItems = createRandomActivities(actor, 'Announce', 10);
|
||||
|
||||
resolver.register(actor.id, actor);
|
||||
resolver.register(actor.outbox as string, outbox);
|
||||
|
||||
await personService.createPerson(actor.id, resolver);
|
||||
|
||||
deepSortedEqual(
|
||||
resolver.remoteGetTrials(),
|
||||
[actor.id, actor.outbox],
|
||||
);
|
||||
|
||||
for (const item of outbox.orderedItems as IActivity[]) {
|
||||
const note = await noteService.fetchNote(item.object);
|
||||
assert.ok(!note);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Images', () => {
|
||||
test('Create images', async () => {
|
||||
const imageObject: IApDocument = {
|
||||
|
@@ -68,7 +68,6 @@
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typescript": "5.2.2",
|
||||
"ua-parser-js": "2.0.0-alpha.2",
|
||||
"uuid": "9.0.1",
|
||||
"vanilla-tilt": "1.8.1",
|
||||
"vite": "4.4.9",
|
||||
|
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, PropType, TransitionGroup, useCssModule, watch } from 'vue';
|
||||
import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue';
|
||||
import MkAd from '@/components/global/MkAd.vue';
|
||||
import { isDebuggerEnabled, stackTraceInstances } from '@/debug';
|
||||
import { i18n } from '@/i18n';
|
||||
@@ -38,11 +38,6 @@ export default defineComponent({
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
denyMoveTransition: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, { slots, expose }) {
|
||||
@@ -140,7 +135,6 @@ export default defineComponent({
|
||||
[$style['reversed']]: props.reversed,
|
||||
[$style['direction-down']]: props.direction === 'down',
|
||||
[$style['direction-up']]: props.direction === 'up',
|
||||
'deny-move-transition': props.denyMoveTransition,
|
||||
},
|
||||
...(defaultStore.state.animation ? {
|
||||
name: 'list',
|
||||
@@ -159,11 +153,15 @@ export default defineComponent({
|
||||
container-type: inline-size;
|
||||
|
||||
&:global {
|
||||
&:not(.deny-move-transition) > .list-move {
|
||||
> .list-move {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
&:not(.deny-move-transition) > .list-enter-active {
|
||||
&.deny-move-transition > .list-move {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
> .list-enter-active {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
|
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: notes, denyMoveTransition }">
|
||||
<template #default="{ items: notes }">
|
||||
<div :class="[$style.root, { [$style.noGap]: noGap }]">
|
||||
<MkDateSeparatedList
|
||||
ref="notes"
|
||||
@@ -23,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:noGap="noGap"
|
||||
:ad="true"
|
||||
:class="$style.notes"
|
||||
:denyMoveTransition="denyMoveTransition"
|
||||
>
|
||||
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
|
||||
</MkDateSeparatedList>
|
||||
|
@@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: notifications, denyMoveTransition }">
|
||||
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true" :denyMoveTransition="denyMoveTransition">
|
||||
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="`showNotificationAsNote:${notification.id}`" :note="notification.note"/>
|
||||
<template #default="{ items: notifications }">
|
||||
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
|
||||
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
|
||||
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/>
|
||||
</MkDateSeparatedList>
|
||||
</template>
|
||||
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, onMounted, computed, shallowRef, watch } from 'vue';
|
||||
import { onUnmounted, onMounted, computed, shallowRef } from 'vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import XNotification from '@/components/MkNotification.vue';
|
||||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
@@ -55,16 +55,10 @@ const onNotification = (notification) => {
|
||||
}
|
||||
|
||||
if (!isMuted) {
|
||||
pagingComponent.value?.prepend(notification);
|
||||
pagingComponent.value.prepend(notification);
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => pagingComponent.value?.backed, (backed) => {
|
||||
if (backed === false) {
|
||||
useStream().send('readNotification');
|
||||
}
|
||||
});
|
||||
|
||||
let connection;
|
||||
|
||||
onMounted(() => {
|
||||
|
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<div ref="contents" :class="$style.root" style="container-type: inline-size;">
|
||||
<RouterView :key="reloadCount" :router="router" :scrollContainer="contents"/>
|
||||
<RouterView :key="reloadCount" :router="router"/>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
@@ -37,11 +37,12 @@ import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import { url } from '@/config';
|
||||
import { mainRouter, routes, page } from '@/router';
|
||||
import { $i } from '@/account';
|
||||
import { Router } from '@/nirax';
|
||||
import { Router, useScrollPositionManager } from '@/nirax';
|
||||
import { i18n } from '@/i18n';
|
||||
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
||||
import { openingWindowsCount } from '@/os';
|
||||
import { claimAchievement } from '@/scripts/achievements';
|
||||
import { getScrollContainer } from '@/scripts/scroll';
|
||||
|
||||
const props = defineProps<{
|
||||
initialPath: string;
|
||||
@@ -145,6 +146,8 @@ function popout() {
|
||||
windowEl.close();
|
||||
}
|
||||
|
||||
useScrollPositionManager(() => getScrollContainer(contents.value), router);
|
||||
|
||||
onMounted(() => {
|
||||
openingWindowsCount.value++;
|
||||
if (openingWindowsCount.value >= 3) {
|
||||
|
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<MkLoading v-if="fetching"/>
|
||||
|
||||
<MkError v-else-if="empty && error" @retry="reload()"/>
|
||||
<MkError v-else-if="error" @retry="init()"/>
|
||||
|
||||
<div v-else-if="empty" key="_empty_" class="empty">
|
||||
<slot name="empty">
|
||||
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkButton>
|
||||
<MkLoading v-else class="loading"/>
|
||||
</div>
|
||||
<slot :items="providingItems" :fetching="fetching || moreFetching" :denyMoveTransition="denyMoveTransition"></slot>
|
||||
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
|
||||
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
|
||||
{{ i18n.ts.loadMore }}
|
||||
@@ -46,31 +46,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os';
|
||||
import { isBottomVisible, isTopVisible, getScrollContainer, scrollToBottom, scrollToTop, scrollBy, scroll, getBodyScrollHeight } from '@/scripts/scroll';
|
||||
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll';
|
||||
import { useDocumentVisibility } from '@/scripts/use-document-visibility';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list';
|
||||
import { i18n } from '@/i18n';
|
||||
import { isWebKit } from '@/scripts/useragent';
|
||||
|
||||
const SECOND_FETCH_LIMIT = 30;
|
||||
const TOLERANCE = 6;
|
||||
const TOLERANCE = 16;
|
||||
const APPEAR_MINIMUM_INTERVAL = 600;
|
||||
|
||||
export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
|
||||
endpoint: E;
|
||||
|
||||
/**
|
||||
* 一度にAPIへ取得する件数
|
||||
*/
|
||||
limit: number;
|
||||
|
||||
/**
|
||||
* タイムラインに表示する最大件数
|
||||
*/
|
||||
displayLimit?: number;
|
||||
|
||||
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
|
||||
|
||||
/**
|
||||
@@ -98,8 +87,6 @@ function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
|
||||
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
|
||||
return new Map([...map, ...arrayToEntries(entities)]);
|
||||
}
|
||||
|
||||
const timelineBackTopBehavior = computed(() => isWebKit() ? 'newest' : defaultStore.reactiveState.timelineBackTopBehavior.value);
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import { infoImageUrl } from '@/instance';
|
||||
@@ -107,19 +94,19 @@ import { infoImageUrl } from '@/instance';
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
disableAutoLoad?: boolean;
|
||||
displayLimit?: number;
|
||||
}>(), {
|
||||
displayLimit: 20,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'queue', count: number): void;
|
||||
}>();
|
||||
|
||||
let rootEl = $shallowRef<HTMLElement>();
|
||||
|
||||
/**
|
||||
* スクロールが先頭にある場合はfalse
|
||||
* スクロールが先頭にない場合にtrue
|
||||
*/
|
||||
// 先頭にいるか(prependでキューに追加するかどうかの判定に使う)
|
||||
// 遡り中かどうか
|
||||
let backed = $ref(false);
|
||||
// true→falseの変更でexecuteQueueする
|
||||
let weakBacked = $ref(false);
|
||||
|
||||
let scrollRemove = $ref<(() => void) | null>(null);
|
||||
|
||||
@@ -128,14 +115,12 @@ let scrollRemove = $ref<(() => void) | null>(null);
|
||||
* 最新が0番目
|
||||
*/
|
||||
const items = ref<MisskeyEntityMap>(new Map());
|
||||
const providingItems = computed(() => Array.from(items.value.values()));
|
||||
|
||||
/**
|
||||
* タブが非アクティブなどの場合に更新を貯めておく
|
||||
* 最新が最後(パフォーマンス上の理由でitemsと逆にした)
|
||||
* 最新が0番目
|
||||
*/
|
||||
const queue = ref<MisskeyEntityMap>(new Map());
|
||||
const queueSize = computed(() => queue.value.size);
|
||||
|
||||
const offset = ref(0);
|
||||
|
||||
@@ -144,153 +129,69 @@ const offset = ref(0);
|
||||
*/
|
||||
const fetching = ref(true);
|
||||
|
||||
/**
|
||||
* onActivatedでtrue, onDeactivatedでfalseになる
|
||||
*/
|
||||
const active = ref(true);
|
||||
|
||||
const moreFetching = ref(false);
|
||||
const more = ref(false);
|
||||
const preventAppearFetchMore = ref(false);
|
||||
const preventAppearFetchMoreTimer = ref<number | null>(null);
|
||||
const isBackTop = ref(false);
|
||||
const empty = computed(() => items.value.size === 0);
|
||||
const error = ref(false);
|
||||
const {
|
||||
enableInfiniteScroll,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const displayLimit = computed(() => props.pagination.displayLimit ?? props.pagination.limit * 2);
|
||||
|
||||
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
|
||||
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) ?? null : null);
|
||||
const scrollableElementOrHtml = $computed(() => scrollableElement ?? document.getElementsByName('html')[0]);
|
||||
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body);
|
||||
|
||||
const visibility = useDocumentVisibility();
|
||||
|
||||
const isPausingUpdateByExecutingQueue = ref(false);
|
||||
const denyMoveTransition = ref(false);
|
||||
let isPausingUpdate = false;
|
||||
let timerForSetPause: number | null = null;
|
||||
const BACKGROUND_PAUSE_WAIT_SEC = 10;
|
||||
|
||||
//#region scrolling
|
||||
const checkFn = props.pagination.reversed ? isBottomVisible : isTopVisible;
|
||||
const checkTop = (tolerance?: number) => {
|
||||
if (!contentEl) return true;
|
||||
if (!document.body.contains(contentEl)) return true;
|
||||
return checkFn(contentEl, tolerance, scrollableElement);
|
||||
};
|
||||
/**
|
||||
* IntersectionObserverで大まかに検出
|
||||
* https://qiita.com/mkataigi/items/0154aefd2223ce23398e
|
||||
*/
|
||||
// 先頭が表示されているかどうかを検出
|
||||
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
|
||||
let scrollObserver = $ref<IntersectionObserver>();
|
||||
|
||||
watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
|
||||
if (scrollObserver) scrollObserver.disconnect();
|
||||
|
||||
scrollObserver = new IntersectionObserver(entries => {
|
||||
if (!active.value) return; // activeでない時は触らない
|
||||
weakBacked = entries[0].intersectionRatio >= 0.1;
|
||||
backed = entries[0].isIntersecting;
|
||||
}, {
|
||||
root: scrollableElement,
|
||||
rootMargin: props.pagination.reversed ? '-100% 0px 1000% 0px' : '1000% 0px -100% 0px',
|
||||
threshold: [0.01, 0.05, 0.1, 0.12, 0.15],
|
||||
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
|
||||
threshold: 0.01,
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
watch([$$(rootEl), $$(scrollObserver)], () => {
|
||||
watch($$(rootEl), () => {
|
||||
scrollObserver?.disconnect();
|
||||
if (rootEl) scrollObserver?.observe(rootEl);
|
||||
});
|
||||
|
||||
/**
|
||||
* weakBackedがtrue→falseになったらexecuteQueue
|
||||
*/
|
||||
watch($$(weakBacked), () => {
|
||||
if (timelineBackTopBehavior.value === 'next' && !weakBacked) {
|
||||
executeQueue();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* backedがtrue→falseになってもexecuteQueue
|
||||
*/
|
||||
watch($$(backed), () => {
|
||||
if (!backed) {
|
||||
executeQueue();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* onScrollTop/onScrollBottomでbackedを厳密に検出する
|
||||
*/
|
||||
watch([$$(weakBacked), $$(contentEl)], () => {
|
||||
if (scrollRemove) scrollRemove();
|
||||
scrollRemove = null;
|
||||
|
||||
if (weakBacked || !contentEl) {
|
||||
if (weakBacked) backed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
scrollRemove = (() => {
|
||||
const checkBacked = () => {
|
||||
if (!active.value) return; // activeでない時は触らない
|
||||
backed = !checkTop(TOLERANCE);
|
||||
};
|
||||
|
||||
// とりあえず評価してみる
|
||||
checkBacked();
|
||||
|
||||
const container = scrollableElementOrHtml;
|
||||
|
||||
function removeListener() { container.removeEventListener('scroll', checkBacked); }
|
||||
container.addEventListener('scroll', checkBacked, { passive: true });
|
||||
return removeListener;
|
||||
})();
|
||||
});
|
||||
|
||||
function preventDefault(ev: Event) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* アイテムを上に追加した場合に追加分だけスクロールを下にずらす
|
||||
* Safariでは使わない方がいいかも?
|
||||
* @param fn DOM操作(unshiftItemsなど)
|
||||
*/
|
||||
async function adjustScroll(fn: () => void): Promise<void> {
|
||||
await nextTick();
|
||||
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
|
||||
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
|
||||
// スクロールをやめさせる
|
||||
try {
|
||||
// なぜかscrollableElementOrHtmlがundefinedであるというエラーが出る
|
||||
scrollableElementOrHtml.addEventListener('wheel', preventDefault, { passive: false });
|
||||
scrollableElementOrHtml.addEventListener('touchmove', preventDefault, { passive: false });
|
||||
// スクロールを強制的に停止
|
||||
scroll(scrollableElement, { top: oldScroll, behavior: 'instant' });
|
||||
} catch (err) {
|
||||
console.error(err, { scrollableElementOrHtml });
|
||||
}
|
||||
denyMoveTransition.value = true;
|
||||
fn();
|
||||
return await nextTick().then(() => {
|
||||
const top = oldScroll + ((scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight()) - oldHeight);
|
||||
scroll(scrollableElement, { top, behavior: 'instant' });
|
||||
// なぜかscrollableElementOrHtmlがundefinedであるというエラーが出る
|
||||
scrollableElementOrHtml.removeEventListener('wheel', preventDefault);
|
||||
scrollableElementOrHtml.removeEventListener('touchmove', preventDefault);
|
||||
}).then(() => nextTick()).finally(() => {
|
||||
denyMoveTransition.value = false;
|
||||
nextTick(() => {
|
||||
if (rootEl) scrollObserver?.observe(rootEl);
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
});
|
||||
|
||||
watch([$$(backed), $$(contentEl)], () => {
|
||||
if (!backed) {
|
||||
if (!contentEl) return;
|
||||
|
||||
scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, TOLERANCE);
|
||||
} else {
|
||||
if (scrollRemove) scrollRemove();
|
||||
scrollRemove = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (props.pagination.params && isRef(props.pagination.params)) {
|
||||
watch(props.pagination.params, init, { deep: true });
|
||||
}
|
||||
|
||||
watch(queue, (a, b) => {
|
||||
if (a.size === 0 && b.size === 0) return;
|
||||
emit('queue', queue.value.size);
|
||||
}, { deep: true });
|
||||
|
||||
/**
|
||||
* 初期化
|
||||
* scrollAfterInitなどの後処理もあるので、reload関数を使うべき
|
||||
*
|
||||
* 注意: moreFetchingをtrueにするのでfalseにする必要がある
|
||||
*/
|
||||
async function init(): Promise<void> {
|
||||
items.value = new Map();
|
||||
queue.value = new Map();
|
||||
@@ -309,7 +210,7 @@ async function init(): Promise<void> {
|
||||
concatItems(res);
|
||||
more.value = false;
|
||||
} else {
|
||||
moreFetching.value = true;
|
||||
if (props.pagination.reversed) moreFetching.value = true;
|
||||
concatItems(res);
|
||||
more.value = true;
|
||||
}
|
||||
@@ -323,50 +224,10 @@ async function init(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* initの後に呼ぶ
|
||||
* コンポーネント作成直後でinitが呼ばれた時はonMountedで呼ばれる
|
||||
* reloadでinitが呼ばれた時はreload内でinitの後に呼ばれる
|
||||
*/
|
||||
function scrollAfterInit() {
|
||||
if (props.pagination.reversed) {
|
||||
nextTick(() => {
|
||||
setTimeout(async () => {
|
||||
if (contentEl) {
|
||||
scrollToBottom(contentEl);
|
||||
// scrollToしてもbacked周りがうまく動かないので手動で戻す必要がある
|
||||
weakBacked = false;
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
|
||||
// more = trueを遅らせる
|
||||
setTimeout(() => {
|
||||
moreFetching.value = false;
|
||||
}, 2000);
|
||||
});
|
||||
} else {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
scrollToTop(scrollableElement);
|
||||
// scrollToしてもbacked周りがうまく動かないので手動で戻す必要がある
|
||||
weakBacked = false;
|
||||
|
||||
moreFetching.value = false;
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async (): Promise<void> => {
|
||||
await init();
|
||||
scrollAfterInit();
|
||||
const reload = (): Promise<void> => {
|
||||
return init();
|
||||
};
|
||||
|
||||
if (props.pagination.params && isRef(props.pagination.params)) {
|
||||
watch(props.pagination.params, reload, { deep: true });
|
||||
}
|
||||
|
||||
const fetchMore = async (): Promise<void> => {
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||
moreFetching.value = true;
|
||||
@@ -385,13 +246,29 @@ const fetchMore = async (): Promise<void> => {
|
||||
if (i === 10) item._shouldInsertAd_ = true;
|
||||
}
|
||||
|
||||
const reverseConcat = (_res) => adjustScroll(() => concatMapWithArray(items.value, _res));
|
||||
const reverseConcat = _res => {
|
||||
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
|
||||
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
|
||||
|
||||
items.value = concatMapWithArray(items.value, _res);
|
||||
|
||||
return nextTick(() => {
|
||||
if (scrollableElement) {
|
||||
scroll(scrollableElement, { top: oldScroll + (scrollableElement.scrollHeight - oldHeight), behavior: 'instant' });
|
||||
} else {
|
||||
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
|
||||
}
|
||||
|
||||
return nextTick();
|
||||
});
|
||||
};
|
||||
|
||||
if (res.length === 0) {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res);
|
||||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
@@ -399,9 +276,10 @@ const fetchMore = async (): Promise<void> => {
|
||||
}
|
||||
} else {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
@@ -466,19 +344,25 @@ const appearFetchMoreAhead = async (): Promise<void> => {
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
nextTick(() => {
|
||||
active.value = true;
|
||||
});
|
||||
});
|
||||
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE);
|
||||
|
||||
onDeactivated(() => {
|
||||
active.value = false;
|
||||
});
|
||||
|
||||
watch([active, visibility], () => {
|
||||
if (!backed && active.value && visibility.value === 'visible') {
|
||||
executeQueue();
|
||||
watch(visibility, () => {
|
||||
if (visibility.value === 'hidden') {
|
||||
timerForSetPause = window.setTimeout(() => {
|
||||
isPausingUpdate = true;
|
||||
timerForSetPause = null;
|
||||
},
|
||||
BACKGROUND_PAUSE_WAIT_SEC * 1000);
|
||||
} else { // 'visible'
|
||||
if (timerForSetPause) {
|
||||
clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
} else {
|
||||
isPausingUpdate = false;
|
||||
if (isTop()) {
|
||||
executeQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -494,39 +378,19 @@ const prepend = (item: MisskeyEntity): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isPausingUpdateByExecutingQueue.value && // スクロール調整中はキューに追加する
|
||||
visibility.value !== 'hidden' && // バックグラウンドの場合はキューに追加する
|
||||
queueSize.value === 0 && // キューに残っている場合はキューに追加する
|
||||
active.value // keepAliveで隠されている間はキューに追加する
|
||||
) {
|
||||
if (!backed) {
|
||||
// かなりスクロールの先頭にいる場合
|
||||
if (items.value.has(item.id)) return; // 既にタイムラインにある場合は何もしない
|
||||
unshiftItems([item]);
|
||||
} else if (timelineBackTopBehavior.value === 'next' && !weakBacked) {
|
||||
// ちょっと先頭にいる場合はスクロールを調整する
|
||||
prependQueue(item);
|
||||
executeQueue();
|
||||
} else {
|
||||
// 先頭にいない場合はキューに追加する
|
||||
prependQueue(item);
|
||||
}
|
||||
} else {
|
||||
prependQueue(item);
|
||||
}
|
||||
if (isTop() && !isPausingUpdate) unshiftItems([item]);
|
||||
else prependQueue(item);
|
||||
};
|
||||
|
||||
/**
|
||||
* 新着アイテムをitemsの先頭に追加し、limitを適用する
|
||||
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
|
||||
* @param newItems 新しいアイテムの配列
|
||||
* @param limit デフォルトはdisplayLimit
|
||||
*/
|
||||
function unshiftItems(newItems: MisskeyEntity[], limit = displayLimit.value) {
|
||||
function unshiftItems(newItems: MisskeyEntity[]) {
|
||||
const length = newItems.length + items.value.size;
|
||||
items.value = new Map([...arrayToEntries(newItems), ...(newItems.length >= limit ? [] : items.value)].slice(0, limit));
|
||||
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
|
||||
|
||||
if (length >= limit) more.value = true;
|
||||
if (length >= props.displayLimit) more.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -535,43 +399,18 @@ function unshiftItems(newItems: MisskeyEntity[], limit = displayLimit.value) {
|
||||
*/
|
||||
function concatItems(oldItems: MisskeyEntity[]) {
|
||||
const length = oldItems.length + items.value.size;
|
||||
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, displayLimit.value));
|
||||
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
|
||||
|
||||
if (length >= displayLimit.value) more.value = true;
|
||||
if (length >= props.displayLimit) more.value = true;
|
||||
}
|
||||
|
||||
async function executeQueue() {
|
||||
// キューが空の場合でもタイムライン表示数を制限する役割がある
|
||||
// ため続行する!
|
||||
// if (queue.value.size === 0) return;
|
||||
|
||||
if (isPausingUpdateByExecutingQueue.value) return;
|
||||
if (timelineBackTopBehavior.value === 'newest') {
|
||||
// Safariは最新のアイテムにするだけ
|
||||
const newItems = Array.from(queue.value.values()).slice(-1 * props.pagination.limit);
|
||||
unshiftItems(newItems);
|
||||
queue.value = new Map();
|
||||
} else {
|
||||
if (queue.value.size > 0) {
|
||||
const queueArr = Array.from(queue.value.entries());
|
||||
queue.value = new Map(queueArr.slice(props.pagination.limit));
|
||||
const newItems = Array.from({ length: Math.min(queueArr.length, props.pagination.limit) }, (_, i) => queueArr[i][1]).reverse();
|
||||
isPausingUpdateByExecutingQueue.value = true;
|
||||
|
||||
await adjustScroll(() => unshiftItems(newItems, Infinity));
|
||||
backed = true;
|
||||
}
|
||||
|
||||
denyMoveTransition.value = true;
|
||||
items.value = new Map([...items.value].slice(0, displayLimit.value));
|
||||
await nextTick();
|
||||
isPausingUpdateByExecutingQueue.value = false;
|
||||
denyMoveTransition.value = false;
|
||||
}
|
||||
function executeQueue() {
|
||||
unshiftItems(Array.from(queue.value.values()));
|
||||
queue.value = new Map();
|
||||
}
|
||||
|
||||
function prependQueue(newItem: MisskeyEntity) {
|
||||
queue.value.set(newItem.id, newItem);
|
||||
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -596,27 +435,52 @@ const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => M
|
||||
|
||||
const inited = init();
|
||||
|
||||
onActivated(() => {
|
||||
isBackTop.value = false;
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl ? rootEl.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
|
||||
});
|
||||
|
||||
function toBottom() {
|
||||
scrollToBottom(contentEl!);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
active.value = true;
|
||||
inited.then(scrollAfterInit);
|
||||
inited.then(() => {
|
||||
if (props.pagination.reversed) {
|
||||
nextTick(() => {
|
||||
setTimeout(toBottom, 800);
|
||||
|
||||
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
|
||||
// more = trueを遅らせる
|
||||
setTimeout(() => {
|
||||
moreFetching.value = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timerForSetPause) {
|
||||
clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
}
|
||||
if (preventAppearFetchMoreTimer.value) {
|
||||
clearTimeout(preventAppearFetchMoreTimer.value);
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
}
|
||||
scrollObserver?.disconnect();
|
||||
if (scrollRemove) scrollRemove();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
items,
|
||||
queue,
|
||||
backed,
|
||||
more,
|
||||
inited,
|
||||
queueSize,
|
||||
backed: $$(backed),
|
||||
reload,
|
||||
prepend,
|
||||
append: appendItem,
|
||||
|
@@ -4,17 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="queueSize > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="reload()">{{ i18n.ts.goToTheHeadOfTimeline }}</button></div>
|
||||
<div v-if="(((src === 'local' || src === 'social') && !isLocalTimelineAvailable) || (src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
|
||||
<p :class="$style.disabledTitle">
|
||||
<i class="ti ti-circle-minus"></i>
|
||||
{{ i18n.ts._disabledTimeline.title }}
|
||||
</p>
|
||||
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
|
||||
</div>
|
||||
<MkNotes v-else ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination"/>
|
||||
</div>
|
||||
<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -24,8 +14,6 @@ import { useStream } from '@/stream';
|
||||
import * as sound from '@/scripts/sound';
|
||||
import { $i } from '@/account';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
@@ -38,22 +26,15 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'note'): void;
|
||||
(ev: 'reload'): void;
|
||||
(ev: 'queue', count: number): void;
|
||||
}>();
|
||||
|
||||
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
|
||||
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
|
||||
|
||||
provide('inChannel', computed(() => props.src === 'channel'));
|
||||
|
||||
let tlComponent: InstanceType<typeof MkNotes> | undefined = $ref();
|
||||
|
||||
const queueSize = computed(() => {
|
||||
return tlComponent?.pagingComponent?.queueSize ?? 0;
|
||||
});
|
||||
const tlComponent: InstanceType<typeof MkNotes> = $ref();
|
||||
|
||||
const prepend = note => {
|
||||
tlComponent?.pagingComponent?.prepend(note);
|
||||
tlComponent.pagingComponent?.prepend(note);
|
||||
|
||||
emit('note');
|
||||
|
||||
@@ -178,48 +159,4 @@ const timetravel = (date?: Date) => {
|
||||
this.$refs.tl.reload();
|
||||
};
|
||||
*/
|
||||
|
||||
const reload = () => {
|
||||
tlComponent?.pagingComponent?.reload();
|
||||
emit('reload');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
reload,
|
||||
queueSize,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.new {
|
||||
position: sticky;
|
||||
top: calc(var(--stickyTop, 0px) + 12px);
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
margin: calc(-0.675em - 8px) 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: calc(-0.675em - 8px - var(--margin));
|
||||
}
|
||||
}
|
||||
|
||||
.newButton {
|
||||
display: block;
|
||||
margin: var(--margin) auto 0 auto;
|
||||
padding: 8px 16px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.disabledTitle {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.disabledDescription {
|
||||
font-size: 90%;
|
||||
margin: 16px;
|
||||
}
|
||||
</style>
|
||||
|
@@ -40,7 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]">
|
||||
<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div>
|
||||
<MkTimeline src="local" :class="$style.tlBody"/>
|
||||
<div :class="$style.tlBody">
|
||||
<MkTimeline src="local"/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.panel">
|
||||
<XActiveUsersChart/>
|
||||
|
@@ -16,18 +16,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onBeforeUnmount, provide, nextTick } from 'vue';
|
||||
import { NiraxChangeEvent, Resolved, Router } from '@/nirax';
|
||||
import { inject, onBeforeUnmount, provide } from 'vue';
|
||||
import { Resolved, Router } from '@/nirax';
|
||||
import { defaultStore } from '@/store';
|
||||
import { getScrollContainer } from '@/scripts/scroll';
|
||||
|
||||
const props = defineProps<{
|
||||
router?: Router;
|
||||
|
||||
/**
|
||||
* Set any element if scroll position management needed
|
||||
*/
|
||||
scrollContainer?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const router = props.router ?? inject('router');
|
||||
@@ -56,49 +50,17 @@ let currentPageComponent = $shallowRef(current.route.component);
|
||||
let currentPageProps = $ref(current.props);
|
||||
let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props)));
|
||||
|
||||
const scrollContainer = computed(() => props.scrollContainer ? (getScrollContainer(props.scrollContainer) ?? document.getElementsByTagName('html')[0]) : undefined);
|
||||
|
||||
const scrollPosStore = new Map<string, number>();
|
||||
|
||||
function onChange(ctx: NiraxChangeEvent) {
|
||||
// save scroll position
|
||||
if (scrollContainer.value) scrollPosStore.set(key, scrollContainer.value.scrollTop);
|
||||
|
||||
//#region change page
|
||||
const current = resolveNested(ctx.resolved);
|
||||
function onChange({ resolved, key: newKey }) {
|
||||
const current = resolveNested(resolved);
|
||||
if (current == null) return;
|
||||
currentPageComponent = current.route.component;
|
||||
currentPageProps = current.props;
|
||||
key = current.route.path + JSON.stringify(Object.fromEntries(current.props));
|
||||
//#endregion
|
||||
|
||||
//#region scroll
|
||||
nextTick(() => {
|
||||
if (!scrollContainer.value) return;
|
||||
|
||||
const scrollPos = scrollPosStore.get(key) ?? 0;
|
||||
scrollContainer.value.scroll({ top: scrollPos, behavior: 'instant' });
|
||||
if (scrollPos !== 0) {
|
||||
window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
|
||||
if (!scrollContainer.value) return;
|
||||
scrollContainer.value.scroll({ top: scrollPos, behavior: 'instant' });
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
}
|
||||
|
||||
router.addListener('change', onChange);
|
||||
|
||||
function onSame() {
|
||||
if (!scrollContainer.value) return;
|
||||
scrollContainer.value.scroll({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
router.addListener('same', onSame);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
router.removeListener('change', onChange);
|
||||
router.removeListener('same', onSame);
|
||||
});
|
||||
</script>
|
||||
|
@@ -54,30 +54,24 @@ function parsePath(path: string): ParsedPath {
|
||||
return res;
|
||||
}
|
||||
|
||||
export type NiraxChangeEvent = {
|
||||
beforePath: string;
|
||||
path: string;
|
||||
resolved: Resolved;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type NiraxExportEvent = {
|
||||
path: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type NiraxPushEvent = {
|
||||
beforePath: string;
|
||||
path: string;
|
||||
route: RouteDef | null;
|
||||
props: Map<string, string> | null;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export class Router extends EventEmitter<{
|
||||
change: (ctx: NiraxChangeEvent) => void;
|
||||
replace: (ctx: NiraxExportEvent) => void;
|
||||
push: (ctx: NiraxExportEvent) => void;
|
||||
change: (ctx: {
|
||||
beforePath: string;
|
||||
path: string;
|
||||
resolved: Resolved;
|
||||
key: string;
|
||||
}) => void;
|
||||
replace: (ctx: {
|
||||
path: string;
|
||||
key: string;
|
||||
}) => void;
|
||||
push: (ctx: {
|
||||
beforePath: string;
|
||||
path: string;
|
||||
route: RouteDef | null;
|
||||
props: Map<string, string> | null;
|
||||
key: string;
|
||||
}) => void;
|
||||
same: () => void;
|
||||
}> {
|
||||
private routes: RouteDef[];
|
||||
@@ -282,3 +276,29 @@ export class Router extends EventEmitter<{
|
||||
this.navigate(path, key);
|
||||
}
|
||||
}
|
||||
|
||||
export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: Router) {
|
||||
const scrollPosStore = new Map<string, number>();
|
||||
|
||||
onMounted(() => {
|
||||
const scrollContainer = getScrollContainer();
|
||||
|
||||
scrollContainer.addEventListener('scroll', () => {
|
||||
scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop);
|
||||
}, { passive: true });
|
||||
|
||||
router.addListener('change', ctx => {
|
||||
const scrollPos = scrollPosStore.get(ctx.key) ?? 0;
|
||||
scrollContainer.scroll({ top: scrollPos, behavior: 'instant' });
|
||||
if (scrollPos !== 0) {
|
||||
window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
|
||||
scrollContainer.scroll({ top: scrollPos, behavior: 'instant' });
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
router.addListener('same', () => {
|
||||
scrollContainer.scroll({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -8,13 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="800">
|
||||
<div ref="rootEl" v-hotkey.global="keymap">
|
||||
<MkTimeline
|
||||
ref="tlEl" :key="antennaId"
|
||||
src="antenna"
|
||||
:antenna="antennaId"
|
||||
:sound="true"
|
||||
:class="$style.tl"
|
||||
/>
|
||||
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||
<div :class="$style.tl">
|
||||
<MkTimeline
|
||||
ref="tlEl" :key="antennaId"
|
||||
src="antenna"
|
||||
:antenna="antennaId"
|
||||
:sound="true"
|
||||
@queue="queueUpdated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@@ -23,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import { scroll } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
import { useRouter } from '@/router';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
@@ -35,14 +39,19 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
let antenna = $ref(null);
|
||||
let queue = $ref(0);
|
||||
let rootEl = $shallowRef<HTMLElement>();
|
||||
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||
const keymap = $computed(() => ({
|
||||
't': focus,
|
||||
}));
|
||||
|
||||
function queueUpdated(q) {
|
||||
queue = q;
|
||||
}
|
||||
|
||||
function top() {
|
||||
tlEl?.reload();
|
||||
scroll(rootEl, { top: 0 });
|
||||
}
|
||||
|
||||
async function timetravel() {
|
||||
@@ -87,6 +96,25 @@ definePageMetadata(computed(() => antenna ? {
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.new {
|
||||
position: sticky;
|
||||
top: calc(var(--stickyTop, 0px) + 16px);
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
margin: calc(-0.675em - 8px) 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: calc(-0.675em - 8px - var(--margin));
|
||||
}
|
||||
}
|
||||
|
||||
.newButton {
|
||||
display: block;
|
||||
margin: var(--margin) auto 0 auto;
|
||||
padding: 8px 16px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.tl {
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
|
@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
|
||||
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
|
||||
|
||||
<MkTimeline :key="channelId" src="channel" :channel="channelId" />
|
||||
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'featured'">
|
||||
<MkNotes :pagination="featuredPagination"/>
|
||||
|
@@ -26,18 +26,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkRadios>
|
||||
|
||||
<FormSection>
|
||||
<div class="_gaps_m">
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
|
||||
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
|
||||
<MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch>
|
||||
</div>
|
||||
|
||||
<MkSelect v-model="timelineBackTopBehavior" :disabled="isWebKit()" :readonly="isWebKit()">
|
||||
<template #label>{{ i18n.ts.timelineBackTopBehavior }}</template>
|
||||
<option value="newest">{{ i18n.ts._timelineBackTopBehavior.newest }}</option>
|
||||
<option value="next">{{ i18n.ts._timelineBackTopBehavior.next }}</option>
|
||||
</MkSelect>
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
|
||||
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
|
||||
<MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
@@ -201,8 +193,6 @@ import { unisonReload } from '@/scripts/unison-reload';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { miLocalStorage } from '@/local-storage';
|
||||
import { isWebKit } from '@/scripts/useragent';
|
||||
import { testNotification } from '@/scripts/test-notification';
|
||||
import { globalEvents } from '@/events';
|
||||
import { claimAchievement } from '@/scripts/achievements';
|
||||
|
||||
@@ -251,7 +241,6 @@ const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('
|
||||
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
|
||||
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
|
||||
const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies'));
|
||||
const timelineBackTopBehavior = computed(defaultStore.makeGetterSetter('timelineBackTopBehavior'));
|
||||
|
||||
watch(lang, () => {
|
||||
miLocalStorage.setItem('lang', lang.value as string);
|
||||
|
@@ -92,7 +92,6 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
|
||||
'numberOfPageCache',
|
||||
'aiChanMode',
|
||||
'mediaListWithOneImageAppearance',
|
||||
'timelineBackTopBehavior',
|
||||
];
|
||||
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
|
||||
'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);"/>
|
||||
<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
|
||||
|
||||
<MkTimeline
|
||||
ref="tlComponent"
|
||||
:key="src"
|
||||
:src="src"
|
||||
:sound="true"
|
||||
:class="$style.tl"
|
||||
/>
|
||||
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||
<div :class="$style.tl">
|
||||
<MkTimeline
|
||||
ref="tlComponent"
|
||||
:key="src"
|
||||
:src="src"
|
||||
:sound="true"
|
||||
@queue="queueUpdated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@@ -28,6 +31,7 @@ import { defineAsyncComponent, computed, watch, provide } from 'vue';
|
||||
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import MkPostForm from '@/components/MkPostForm.vue';
|
||||
import { scroll } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
@@ -50,11 +54,18 @@ const keymap = {
|
||||
const tlComponent = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||
const rootEl = $shallowRef<HTMLElement>();
|
||||
|
||||
let queue = $ref(0);
|
||||
let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global');
|
||||
const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) });
|
||||
|
||||
watch ($$(src), () => queue = 0);
|
||||
|
||||
function queueUpdated(q: number): void {
|
||||
queue = q;
|
||||
}
|
||||
|
||||
function top(): void {
|
||||
tlComponent?.reload();
|
||||
if (rootEl) scroll(rootEl, { top: 0 });
|
||||
}
|
||||
|
||||
async function chooseList(ev: MouseEvent): Promise<void> {
|
||||
@@ -173,6 +184,25 @@ definePageMetadata(computed(() => ({
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.new {
|
||||
position: sticky;
|
||||
top: calc(var(--stickyTop, 0px) + 16px);
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
margin: calc(-0.675em - 8px) 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: calc(-0.675em - 8px - var(--margin));
|
||||
}
|
||||
}
|
||||
|
||||
.newButton {
|
||||
display: block;
|
||||
margin: var(--margin) auto 0 auto;
|
||||
padding: 8px 16px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.postForm {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
@@ -8,13 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="800">
|
||||
<div ref="rootEl">
|
||||
<MkTimeline
|
||||
ref="tlEl" :key="listId"
|
||||
src="list"
|
||||
:list="listId"
|
||||
:sound="true"
|
||||
:class="$style.tl"
|
||||
/>
|
||||
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||
<div :class="$style.tl">
|
||||
<MkTimeline
|
||||
ref="tlEl" :key="listId"
|
||||
src="list"
|
||||
:list="listId"
|
||||
:sound="true"
|
||||
@queue="queueUpdated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@@ -23,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import { scroll } from '@/scripts/scroll';
|
||||
import * as os from '@/os';
|
||||
import { useRouter } from '@/router';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
@@ -35,6 +39,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
let list = $ref(null);
|
||||
let queue = $ref(0);
|
||||
let tlEl = $shallowRef<InstanceType<typeof MkTimeline>>();
|
||||
let rootEl = $shallowRef<HTMLElement>();
|
||||
|
||||
@@ -44,8 +49,12 @@ watch(() => props.listId, async () => {
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
function queueUpdated(q) {
|
||||
queue = q;
|
||||
}
|
||||
|
||||
function top() {
|
||||
tlEl?.reload();
|
||||
scroll(rootEl, { top: 0 });
|
||||
}
|
||||
|
||||
function settings() {
|
||||
@@ -80,6 +89,24 @@ definePageMetadata(computed(() => list ? {
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.new {
|
||||
position: sticky;
|
||||
top: calc(var(--stickyTop, 0px) + 16px);
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
margin: calc(-0.675em - 8px) 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: calc(-0.675em - 8px - var(--margin));
|
||||
}
|
||||
}
|
||||
|
||||
.newButton {
|
||||
display: block;
|
||||
margin: var(--margin) auto 0 auto;
|
||||
padding: 8px 16px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.tl {
|
||||
background: var(--bg);
|
||||
|
@@ -30,7 +30,7 @@ export function getScrollPosition(el: HTMLElement | null): number {
|
||||
|
||||
export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) {
|
||||
// とりあえず評価してみる
|
||||
if (el.isConnected && isTopVisible(el, tolerance)) {
|
||||
if (el.isConnected && isTopVisible(el)) {
|
||||
cb();
|
||||
if (once) return null;
|
||||
}
|
||||
@@ -75,29 +75,12 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
|
||||
return removeListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* コンテナを指定してスクロールする
|
||||
* @param el Container element
|
||||
* @param options ScrollToOptions
|
||||
*/
|
||||
export function scroll(el: HTMLElement | null, options: ScrollToOptions | undefined) {
|
||||
if (el == null) {
|
||||
export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) {
|
||||
const container = getScrollContainer(el);
|
||||
if (container == null) {
|
||||
window.scroll(options);
|
||||
} else {
|
||||
el.scroll(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* コンテナを指定してscrollByする
|
||||
* @param el Container element
|
||||
* @param options ScrollToOptions
|
||||
*/
|
||||
export function scrollBy(el: HTMLElement | null, options: ScrollToOptions | undefined) {
|
||||
if (el == null) {
|
||||
window.scrollBy(options);
|
||||
} else {
|
||||
el.scrollBy(options);
|
||||
container.scroll(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +89,8 @@ export function scrollBy(el: HTMLElement | null, options: ScrollToOptions | unde
|
||||
* @param el Scroll container element
|
||||
* @param options Scroll options
|
||||
*/
|
||||
export function scrollToTop(el: HTMLElement | null, options: { behavior?: ScrollBehavior; } = {}) {
|
||||
scroll(getScrollContainer(el), { top: 0, ...options });
|
||||
export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) {
|
||||
scroll(el, { top: 0, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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 misskey from 'misskey-js';
|
||||
import { Storage } from './pizzax';
|
||||
import { isWebKit } from './scripts/useragent';
|
||||
|
||||
interface PostFormAction {
|
||||
title: string,
|
||||
@@ -353,10 +352,6 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||
where: 'device',
|
||||
default: {} as Record<string, Record<string, string[]>>,
|
||||
},
|
||||
timelineBackTopBehavior: {
|
||||
where: 'device',
|
||||
default: (isWebKit() ? 'newest' : 'next') as 'newest' | 'next',
|
||||
},
|
||||
}));
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<div ref="contents">
|
||||
<RouterView :scrollContainer="contents" @contextmenu.stop="onContextmenu"/>
|
||||
<RouterView @contextmenu.stop="onContextmenu"/>
|
||||
</div>
|
||||
</XColumn>
|
||||
</template>
|
||||
@@ -26,6 +26,8 @@ import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { mainRouter } from '@/router';
|
||||
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
||||
import { useScrollPositionManager } from '@/nirax';
|
||||
import { getScrollContainer } from '@/scripts/scroll';
|
||||
|
||||
defineProps<{
|
||||
column: Column;
|
||||
@@ -69,4 +71,6 @@ function onContextmenu(ev: MouseEvent) {
|
||||
},
|
||||
}], ev);
|
||||
}
|
||||
|
||||
useScrollPositionManager(() => getScrollContainer(contents.value), mainRouter);
|
||||
</script>
|
||||
|
@@ -13,7 +13,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span style="margin-left: 8px;">{{ column.name }}</span>
|
||||
</template>
|
||||
|
||||
<MkTimeline v-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/>
|
||||
<div v-if="(((column.tl === 'local' || column.tl === 'social') && !isLocalTimelineAvailable) || (column.tl === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
|
||||
<p :class="$style.disabledTitle">
|
||||
<i class="ti ti-circle-minus"></i>
|
||||
{{ i18n.ts._disabledTimeline.title }}
|
||||
</p>
|
||||
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
|
||||
</div>
|
||||
<MkTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/>
|
||||
</XColumn>
|
||||
</template>
|
||||
|
||||
@@ -23,16 +30,27 @@ import XColumn from './column.vue';
|
||||
import { removeColumn, updateColumn, Column } from './deck-store';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
|
||||
const props = defineProps<{
|
||||
column: Column;
|
||||
isStacked: boolean;
|
||||
}>();
|
||||
|
||||
let disabled = $ref(false);
|
||||
|
||||
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
|
||||
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
|
||||
|
||||
onMounted(() => {
|
||||
if (props.column.tl == null) {
|
||||
setType();
|
||||
} else if ($i) {
|
||||
disabled = (
|
||||
(!((instance.policies.ltlAvailable) || ($i.policies.ltlAvailable)) && ['local', 'social'].includes(props.column.tl)) ||
|
||||
(!((instance.policies.gtlAvailable) || ($i.policies.gtlAvailable)) && ['global'].includes(props.column.tl)));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,3 +84,17 @@ const menu = [{
|
||||
action: setType,
|
||||
}];
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.disabledTitle {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.disabledDescription {
|
||||
font-size: 90%;
|
||||
}
|
||||
</style>
|
||||
|
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<XStatusBars :class="$style.statusbars"/>
|
||||
</div>
|
||||
</template>
|
||||
<RouterView :scrollContainer="contents?.rootEl"/>
|
||||
<RouterView/>
|
||||
<div :class="$style.spacer"></div>
|
||||
</MkStickyContainer>
|
||||
|
||||
@@ -105,6 +105,7 @@ import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
||||
import { deviceKind } from '@/scripts/device-kind';
|
||||
import { miLocalStorage } from '@/local-storage';
|
||||
import { CURRENT_STICKY_BOTTOM } from '@/const';
|
||||
import { useScrollPositionManager } from '@/nirax';
|
||||
|
||||
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
|
||||
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
|
||||
@@ -226,6 +227,8 @@ watch($$(navFooter), () => {
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
useScrollPositionManager(() => contents.value.rootEl, mainRouter);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
@@ -20,20 +20,33 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
|
||||
<div v-if="(((widgetProps.src === 'local' || widgetProps.src === 'social') && !isLocalTimelineAvailable) || (widgetProps.src === 'global' && !isGlobalTimelineAvailable))" :class="$style.disabled">
|
||||
<p :class="$style.disabledTitle">
|
||||
<i class="ti ti-minus"></i>
|
||||
{{ i18n.ts._disabledTimeline.title }}
|
||||
</p>
|
||||
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<MkTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
|
||||
</div>
|
||||
</MkContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
|
||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
|
||||
import { GetFormResultType } from '@/scripts/form';
|
||||
import * as os from '@/os';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import { instance } from '@/instance';
|
||||
|
||||
const name = 'timeline';
|
||||
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
|
||||
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
|
||||
|
||||
const widgetPropsDef = {
|
||||
showHeader: {
|
||||
@@ -128,3 +141,17 @@ defineExpose<WidgetComponentExpose>({
|
||||
id: props.widget ? props.widget.id : null,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.disabledTitle {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.disabledDescription {
|
||||
font-size: 90%;
|
||||
}
|
||||
</style>
|
||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -799,9 +799,6 @@ importers:
|
||||
typescript:
|
||||
specifier: 5.2.2
|
||||
version: 5.2.2
|
||||
ua-parser-js:
|
||||
specifier: 2.0.0-alpha.2
|
||||
version: 2.0.0-alpha.2
|
||||
uuid:
|
||||
specifier: 9.0.1
|
||||
version: 9.0.1
|
||||
@@ -11849,7 +11846,6 @@ packages:
|
||||
/form-data@3.0.1:
|
||||
resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
|
||||
engines: {node: '>= 6'}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
@@ -18822,10 +18818,6 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
/ua-parser-js@2.0.0-alpha.2:
|
||||
resolution: {integrity: sha512-Vz+BJN/EFC1OaUv0eu5kPyX7HEZIO7Dv29jIK7rMuKjUB1qqq+Is/XIpu5iV5XDvoNl62dM7ay8DtzYjBDI0WA==}
|
||||
dev: false
|
||||
|
||||
/ufo@1.1.2:
|
||||
resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==}
|
||||
dev: true
|
||||
|
Reference in New Issue
Block a user