mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-11-04 07:24:13 +00:00 
			
		
		
		
	
							parent
							
								
									1f0c27edf2
								
							
						
					
					
						commit
						7a8d5e5840
					
				
					 11 changed files with 131 additions and 12 deletions
				
			
		| 
						 | 
				
			
			@ -13,6 +13,9 @@
 | 
			
		|||
-->
 | 
			
		||||
 | 
			
		||||
## 2023.10.1
 | 
			
		||||
### General
 | 
			
		||||
- Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に
 | 
			
		||||
 | 
			
		||||
### Server
 | 
			
		||||
- Fix: フォローしているユーザーからの自分の投稿への返信がタイムラインに含まれない問題を修正
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -907,6 +907,10 @@ export class NoteCreateService implements OnApplicationShutdown {
 | 
			
		|||
			// 自分自身以外への返信
 | 
			
		||||
			if (note.replyId && note.replyUserId !== note.userId) {
 | 
			
		||||
				this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
 | 
			
		||||
 | 
			
		||||
				if (note.visibility === 'public' && note.userHost == null) {
 | 
			
		||||
					this.redisTimelineService.push('localTimelineWithReplies', note.id, 300, r);
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
 | 
			
		||||
				if (note.fileIds.length > 0) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -55,6 +55,7 @@ export const paramDef = {
 | 
			
		|||
		includeLocalRenotes: { type: 'boolean', default: true },
 | 
			
		||||
		withFiles: { type: 'boolean', default: false },
 | 
			
		||||
		withRenotes: { type: 'boolean', default: true },
 | 
			
		||||
		withReplies: { type: 'boolean', default: false },
 | 
			
		||||
	},
 | 
			
		||||
	required: [],
 | 
			
		||||
} as const;
 | 
			
		||||
| 
						 | 
				
			
			@ -94,12 +95,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				this.cacheService.userBlockedCache.fetch(me.id),
 | 
			
		||||
			]);
 | 
			
		||||
 | 
			
		||||
			const [htlNoteIds, ltlNoteIds] = await this.redisTimelineService.getMulti([
 | 
			
		||||
				ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
 | 
			
		||||
				ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
 | 
			
		||||
			], untilId, sinceId);
 | 
			
		||||
			let noteIds: string[];
 | 
			
		||||
 | 
			
		||||
			if (ps.withFiles) {
 | 
			
		||||
				const [htlNoteIds, ltlNoteIds] = await this.redisTimelineService.getMulti([
 | 
			
		||||
					`homeTimelineWithFiles:${me.id}`,
 | 
			
		||||
					'localTimelineWithFiles',
 | 
			
		||||
				], untilId, sinceId);
 | 
			
		||||
				noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
 | 
			
		||||
			} else if (ps.withReplies) {
 | 
			
		||||
				const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.redisTimelineService.getMulti([
 | 
			
		||||
					`homeTimeline:${me.id}`,
 | 
			
		||||
					'localTimeline',
 | 
			
		||||
					'localTimelineWithReplies',
 | 
			
		||||
				], untilId, sinceId);
 | 
			
		||||
				noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
 | 
			
		||||
			} else {
 | 
			
		||||
				const [htlNoteIds, ltlNoteIds] = await this.redisTimelineService.getMulti([
 | 
			
		||||
					`homeTimeline:${me.id}`,
 | 
			
		||||
					'localTimeline',
 | 
			
		||||
				], untilId, sinceId);
 | 
			
		||||
				noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
 | 
			
		||||
			noteIds.sort((a, b) => a > b ? -1 : 1);
 | 
			
		||||
			noteIds = noteIds.slice(0, ps.limit);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,6 +45,7 @@ export const paramDef = {
 | 
			
		|||
	properties: {
 | 
			
		||||
		withFiles: { type: 'boolean', default: false },
 | 
			
		||||
		withRenotes: { type: 'boolean', default: true },
 | 
			
		||||
		withReplies: { type: 'boolean', default: false },
 | 
			
		||||
		excludeNsfw: { type: 'boolean', default: false },
 | 
			
		||||
		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
 | 
			
		||||
		sinceId: { type: 'string', format: 'misskey:id' },
 | 
			
		||||
| 
						 | 
				
			
			@ -90,7 +91,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				this.cacheService.userBlockedCache.fetch(me.id),
 | 
			
		||||
			]) : [new Set<string>(), new Set<string>(), new Set<string>()];
 | 
			
		||||
 | 
			
		||||
			let noteIds = await this.redisTimelineService.get(ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', untilId, sinceId);
 | 
			
		||||
			let noteIds: string[];
 | 
			
		||||
 | 
			
		||||
			if (ps.withFiles) {
 | 
			
		||||
				noteIds = await this.redisTimelineService.get('localTimelineWithFiles', untilId, sinceId);
 | 
			
		||||
			} else if (ps.withReplies) {
 | 
			
		||||
				const [nonReplyNoteIds, replyNoteIds] = await this.redisTimelineService.getMulti([
 | 
			
		||||
					'localTimeline',
 | 
			
		||||
					'localTimelineWithReplies',
 | 
			
		||||
				], untilId, sinceId);
 | 
			
		||||
				noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds]));
 | 
			
		||||
				noteIds.sort((a, b) => a > b ? -1 : 1);
 | 
			
		||||
			} else {
 | 
			
		||||
				noteIds = await this.redisTimelineService.get('localTimeline', untilId, sinceId);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			noteIds = noteIds.slice(0, ps.limit);
 | 
			
		||||
 | 
			
		||||
			if (noteIds.length === 0) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,6 +19,7 @@ class HybridTimelineChannel extends Channel {
 | 
			
		|||
	public static shouldShare = false;
 | 
			
		||||
	public static requireCredential = true;
 | 
			
		||||
	private withRenotes: boolean;
 | 
			
		||||
	private withReplies: boolean;
 | 
			
		||||
	private withFiles: boolean;
 | 
			
		||||
 | 
			
		||||
	constructor(
 | 
			
		||||
| 
						 | 
				
			
			@ -39,6 +40,7 @@ class HybridTimelineChannel extends Channel {
 | 
			
		|||
		if (!policies.ltlAvailable) return;
 | 
			
		||||
 | 
			
		||||
		this.withRenotes = params.withRenotes ?? true;
 | 
			
		||||
		this.withReplies = params.withReplies ?? false;
 | 
			
		||||
		this.withFiles = params.withFiles ?? false;
 | 
			
		||||
 | 
			
		||||
		// Subscribe events
 | 
			
		||||
| 
						 | 
				
			
			@ -87,7 +89,7 @@ class HybridTimelineChannel extends Channel {
 | 
			
		|||
		if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return;
 | 
			
		||||
 | 
			
		||||
		// 関係ない返信は除外
 | 
			
		||||
		if (note.reply && !this.following[note.userId]?.withReplies) {
 | 
			
		||||
		if (note.reply && !this.following[note.userId]?.withReplies && !this.withReplies) {
 | 
			
		||||
			const reply = note.reply;
 | 
			
		||||
			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 | 
			
		||||
			if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,7 @@ class LocalTimelineChannel extends Channel {
 | 
			
		|||
	public static shouldShare = false;
 | 
			
		||||
	public static requireCredential = false;
 | 
			
		||||
	private withRenotes: boolean;
 | 
			
		||||
	private withReplies: boolean;
 | 
			
		||||
	private withFiles: boolean;
 | 
			
		||||
 | 
			
		||||
	constructor(
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +39,7 @@ class LocalTimelineChannel extends Channel {
 | 
			
		|||
		if (!policies.ltlAvailable) return;
 | 
			
		||||
 | 
			
		||||
		this.withRenotes = params.withRenotes ?? true;
 | 
			
		||||
		this.withReplies = params.withReplies ?? false;
 | 
			
		||||
		this.withFiles = params.withFiles ?? false;
 | 
			
		||||
 | 
			
		||||
		// Subscribe events
 | 
			
		||||
| 
						 | 
				
			
			@ -66,7 +68,7 @@ class LocalTimelineChannel extends Channel {
 | 
			
		|||
		}
 | 
			
		||||
 | 
			
		||||
		// 関係ない返信は除外
 | 
			
		||||
		if (note.reply && this.user && !this.following[note.userId]?.withReplies) {
 | 
			
		||||
		if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) {
 | 
			
		||||
			const reply = note.reply;
 | 
			
		||||
			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 | 
			
		||||
			if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -512,6 +512,20 @@ describe('Timelines', () => {
 | 
			
		|||
			assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		test.concurrent('他人の他人への返信が含まれない', async () => {
 | 
			
		||||
			const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
 | 
			
		||||
 | 
			
		||||
			const carolNote = await post(carol, { text: 'hi' });
 | 
			
		||||
			const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
 | 
			
		||||
 | 
			
		||||
			await waitForPushToTl();
 | 
			
		||||
 | 
			
		||||
			const res = await api('/notes/local-timeline', {}, alice);
 | 
			
		||||
 | 
			
		||||
			assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
 | 
			
		||||
			assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		test.concurrent('チャンネル投稿が含まれない', async () => {
 | 
			
		||||
			const [alice, bob] = await Promise.all([signup(), signup()]);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -623,6 +637,20 @@ describe('Timelines', () => {
 | 
			
		|||
		});
 | 
			
		||||
		*/
 | 
			
		||||
 | 
			
		||||
		test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => {
 | 
			
		||||
			const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
 | 
			
		||||
 | 
			
		||||
			const carolNote = await post(carol, { text: 'hi' });
 | 
			
		||||
			const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
 | 
			
		||||
 | 
			
		||||
			await waitForPushToTl();
 | 
			
		||||
 | 
			
		||||
			const res = await api('/notes/local-timeline', { withReplies: true }, alice);
 | 
			
		||||
 | 
			
		||||
			assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
 | 
			
		||||
			assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
 | 
			
		||||
			const [alice, bob] = await Promise.all([signup(), signup()]);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -694,6 +722,20 @@ describe('Timelines', () => {
 | 
			
		|||
			assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		test.concurrent('他人の他人への返信が含まれない', async () => {
 | 
			
		||||
			const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
 | 
			
		||||
 | 
			
		||||
			const carolNote = await post(carol, { text: 'hi' });
 | 
			
		||||
			const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
 | 
			
		||||
 | 
			
		||||
			await waitForPushToTl();
 | 
			
		||||
 | 
			
		||||
			const res = await api('/notes/hybrid-timeline', { }, alice);
 | 
			
		||||
 | 
			
		||||
			assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
 | 
			
		||||
			assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		test.concurrent('リモートユーザーのノートが含まれない', async () => {
 | 
			
		||||
			const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -734,6 +776,20 @@ describe('Timelines', () => {
 | 
			
		|||
			assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => {
 | 
			
		||||
			const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
 | 
			
		||||
 | 
			
		||||
			const carolNote = await post(carol, { text: 'hi' });
 | 
			
		||||
			const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id });
 | 
			
		||||
 | 
			
		||||
			await waitForPushToTl();
 | 
			
		||||
 | 
			
		||||
			const res = await api('/notes/hybrid-timeline', { withReplies: true }, alice);
 | 
			
		||||
 | 
			
		||||
			assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
 | 
			
		||||
			assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
 | 
			
		||||
			const [alice, bob] = await Promise.all([signup(), signup()]);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,9 +24,11 @@ const props = withDefaults(defineProps<{
 | 
			
		|||
	role?: string;
 | 
			
		||||
	sound?: boolean;
 | 
			
		||||
	withRenotes?: boolean;
 | 
			
		||||
	withReplies?: boolean;
 | 
			
		||||
	onlyFiles?: boolean;
 | 
			
		||||
}>(), {
 | 
			
		||||
	withRenotes: true,
 | 
			
		||||
	withReplies: false,
 | 
			
		||||
	onlyFiles: false,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -90,10 +92,12 @@ if (props.src === 'antenna') {
 | 
			
		|||
	endpoint = 'notes/local-timeline';
 | 
			
		||||
	query = {
 | 
			
		||||
		withRenotes: props.withRenotes,
 | 
			
		||||
		withReplies: props.withReplies,
 | 
			
		||||
		withFiles: props.onlyFiles ? true : undefined,
 | 
			
		||||
	};
 | 
			
		||||
	connection = stream.useChannel('localTimeline', {
 | 
			
		||||
		withRenotes: props.withRenotes,
 | 
			
		||||
		withReplies: props.withReplies,
 | 
			
		||||
		withFiles: props.onlyFiles ? true : undefined,
 | 
			
		||||
	});
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
| 
						 | 
				
			
			@ -101,10 +105,12 @@ if (props.src === 'antenna') {
 | 
			
		|||
	endpoint = 'notes/hybrid-timeline';
 | 
			
		||||
	query = {
 | 
			
		||||
		withRenotes: props.withRenotes,
 | 
			
		||||
		withReplies: props.withReplies,
 | 
			
		||||
		withFiles: props.onlyFiles ? true : undefined,
 | 
			
		||||
	};
 | 
			
		||||
	connection = stream.useChannel('hybridTimeline', {
 | 
			
		||||
		withRenotes: props.withRenotes,
 | 
			
		||||
		withReplies: props.withReplies,
 | 
			
		||||
		withFiles: props.onlyFiles ? true : undefined,
 | 
			
		||||
	});
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,10 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			<div :class="$style.tl">
 | 
			
		||||
				<MkTimeline
 | 
			
		||||
					ref="tlComponent"
 | 
			
		||||
					:key="src + withRenotes + onlyFiles"
 | 
			
		||||
					:key="src + withRenotes + withReplies + onlyFiles"
 | 
			
		||||
					:src="src.split(':')[0]"
 | 
			
		||||
					:list="src.split(':')[1]"
 | 
			
		||||
					:withRenotes="withRenotes"
 | 
			
		||||
					:withReplies="withReplies"
 | 
			
		||||
					:onlyFiles="onlyFiles"
 | 
			
		||||
					:sound="true"
 | 
			
		||||
					@queue="queueUpdated"
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +62,7 @@ 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) });
 | 
			
		||||
const withRenotes = $ref(true);
 | 
			
		||||
const withReplies = $ref(false);
 | 
			
		||||
const onlyFiles = $ref(false);
 | 
			
		||||
 | 
			
		||||
watch($$(src), () => queue = 0);
 | 
			
		||||
| 
						 | 
				
			
			@ -142,7 +144,11 @@ const headerActions = $computed(() => [{
 | 
			
		|||
			text: i18n.ts.showRenotes,
 | 
			
		||||
			icon: 'ti ti-repeat',
 | 
			
		||||
			ref: $$(withRenotes),
 | 
			
		||||
		}, {
 | 
			
		||||
		}, src === 'local' || src === 'social' ? {
 | 
			
		||||
			type: 'switch',
 | 
			
		||||
			text: i18n.ts.showRepliesToOthersInTimeline,
 | 
			
		||||
			ref: $$(withReplies),
 | 
			
		||||
		} : undefined, {
 | 
			
		||||
			type: 'switch',
 | 
			
		||||
			text: i18n.ts.fileAttachedOnly,
 | 
			
		||||
			icon: 'ti ti-photo',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,6 +31,7 @@ export type Column = {
 | 
			
		|||
	excludeTypes?: typeof notificationTypes[number][];
 | 
			
		||||
	tl?: 'home' | 'local' | 'social' | 'global';
 | 
			
		||||
	withRenotes?: boolean;
 | 
			
		||||
	withReplies?: boolean;
 | 
			
		||||
	onlyFiles?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,9 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
	<MkTimeline
 | 
			
		||||
		v-else-if="column.tl"
 | 
			
		||||
		ref="timeline"
 | 
			
		||||
		:key="column.tl + withRenotes + onlyFiles"
 | 
			
		||||
		:key="column.tl + withRenotes + withReplies + onlyFiles"
 | 
			
		||||
		:src="column.tl"
 | 
			
		||||
		:withRenotes="withRenotes"
 | 
			
		||||
		:withReplies="withReplies"
 | 
			
		||||
		:onlyFiles="onlyFiles"
 | 
			
		||||
	/>
 | 
			
		||||
</XColumn>
 | 
			
		||||
| 
						 | 
				
			
			@ -51,6 +52,7 @@ 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));
 | 
			
		||||
const withRenotes = $ref(props.column.withRenotes ?? true);
 | 
			
		||||
const withReplies = $ref(props.column.withReplies ?? false);
 | 
			
		||||
const onlyFiles = $ref(props.column.onlyFiles ?? false);
 | 
			
		||||
 | 
			
		||||
watch($$(withRenotes), v => {
 | 
			
		||||
| 
						 | 
				
			
			@ -107,7 +109,11 @@ const menu = [{
 | 
			
		|||
	type: 'switch',
 | 
			
		||||
	text: i18n.ts.showRenotes,
 | 
			
		||||
	ref: $$(withRenotes),
 | 
			
		||||
}, {
 | 
			
		||||
}, props.column.tl === 'local' || props.column.tl === 'social' ? {
 | 
			
		||||
	type: 'switch',
 | 
			
		||||
	text: i18n.ts.showRepliesToOthersInTimeline,
 | 
			
		||||
	ref: $$(withReplies),
 | 
			
		||||
} : undefined, {
 | 
			
		||||
	type: 'switch',
 | 
			
		||||
	text: i18n.ts.fileAttachedOnly,
 | 
			
		||||
	ref: $$(onlyFiles),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue