Compare commits

...

6 commits

Author SHA1 Message Date
dakkar
9f8c74ccfe merge: more timeline filters - #228 (!455)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/455
2024-03-30 13:36:05 +00:00
Amelia Yukii
126248e58d merge: some validation fixes (!484)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/484

Closes #469

Approved-by: Amelia Yukii <amelia.yukii@shourai.de>
2024-03-30 11:05:58 +00:00
dakkar
074de82bf7 some validation fixes 2024-03-30 11:05:58 +00:00
dakkar
2071e72b2b use all filters to dbFallback for channel timeline 2024-03-04 20:48:27 +00:00
dakkar
3bb8a91124 add boost / files filters to antenna timeline - #228 2024-03-03 14:03:21 +00:00
dakkar
84abe50f84 add boost / files filters to channel timeline - #228 2024-03-03 13:33:32 +00:00
14 changed files with 216 additions and 24 deletions

View file

@ -15,6 +15,7 @@ import type { Config } from '@/config.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from '@/core/activitypub/type.js'; import type { IObject } from '@/core/activitypub/type.js';
import type { Response } from 'node-fetch'; import type { Response } from 'node-fetch';
import type { URL } from 'node:url'; import type { URL } from 'node:url';
@ -125,7 +126,12 @@ export class HttpRequestService {
validators: [validateContentTypeSetAsActivityPub], validators: [validateContentTypeSetAsActivityPub],
}); });
return await res.json() as IObject; const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [url, finalUrl]);
return activity;
} }
@bindThis @bindThis

View file

@ -86,7 +86,7 @@ export class UtilityService {
@bindThis @bindThis
public extractDbHost(uri: string): string { public extractDbHost(uri: string): string {
const url = new URL(uri); const url = new URL(uri);
return this.toPuny(url.hostname); return this.toPuny(url.host);
} }
@bindThis @bindThis
@ -99,4 +99,11 @@ export class UtilityService {
if (host == null) return null; if (host == null) return null;
return toASCII(host.toLowerCase()); return toASCII(host.toLowerCase());
} }
@bindThis
public punyHost(url: string): string {
const urlObj = new URL(url);
const host = `${this.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
return host;
}
} }

View file

@ -14,7 +14,9 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import type { IObject } from './type.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
type Request = { type Request = {
url: string; url: string;
@ -201,6 +203,11 @@ export class ApRequestService {
validators: [validateContentTypeSetAsActivityPub], validators: [validateContentTypeSetAsActivityPub],
}); });
return await res.json(); const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [url, finalUrl]);
return activity;
} }
} }

View file

@ -115,6 +115,14 @@ export class Resolver {
throw new Error('invalid response'); throw new Error('invalid response');
} }
// HttpRequestService / ApRequestService have already checked that
// `object.id` or `object.url` matches the URL used to fetch the
// object after redirects; here we double-check that no redirects
// bounced between hosts
if (object.id && (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value))) {
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
}
return object; return object;
} }

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: dakkar and sharkey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { IObject } from '../type.js';
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
const idOk = activity.id !== undefined && urls.includes(activity.id);
// technically `activity.url` could be an `ApObject = IObject |
// string | (IObject | string)[]`, but if it's a complicated thing
// and the `activity.id` doesn't match, I think we're fine
// rejecting the activity
const urlOk = typeof(activity.url) === 'string' && urls.includes(activity.url);
if (!idOk && !urlOk) {
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`);
}
}

View file

@ -127,12 +127,6 @@ export class ApPersonService implements OnModuleInit {
this.logger = this.apLoggerService.logger; this.logger = this.apLoggerService.logger;
} }
private punyHost(url: string): string {
const urlObj = new URL(url);
const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
return host;
}
/** /**
* Validate and convert to actor object * Validate and convert to actor object
* @param x Fetched object * @param x Fetched object
@ -140,7 +134,7 @@ export class ApPersonService implements OnModuleInit {
*/ */
@bindThis @bindThis
private validateActor(x: IObject, uri: string): IActor { private validateActor(x: IObject, uri: string): IActor {
const expectHost = this.punyHost(uri); const expectHost = this.utilityService.punyHost(uri);
if (!isActor(x)) { if (!isActor(x)) {
throw new Error(`invalid Actor type '${x.type}'`); throw new Error(`invalid Actor type '${x.type}'`);
@ -154,6 +148,19 @@ export class ApPersonService implements OnModuleInit {
throw new Error('invalid Actor: wrong inbox'); throw new Error('invalid Actor: wrong inbox');
} }
if (this.utilityService.punyHost(x.inbox) !== expectHost) {
throw new Error('invalid Actor: inbox has different host');
}
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
const collectionUri = (x as IActor)[collection];
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
if (this.utilityService.punyHost(collectionUri) !== expectHost) {
throw new Error(`invalid Actor: ${collection} has different host`);
}
}
}
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
throw new Error('invalid Actor: wrong username'); throw new Error('invalid Actor: wrong username');
} }
@ -177,7 +184,7 @@ export class ApPersonService implements OnModuleInit {
x.summary = truncate(x.summary, summaryLength); x.summary = truncate(x.summary, summaryLength);
} }
const idHost = this.punyHost(x.id); const idHost = this.utilityService.punyHost(x.id);
if (idHost !== expectHost) { if (idHost !== expectHost) {
throw new Error('invalid Actor: id has different host'); throw new Error('invalid Actor: id has different host');
} }
@ -187,7 +194,7 @@ export class ApPersonService implements OnModuleInit {
throw new Error('invalid Actor: publicKey.id is not a string'); throw new Error('invalid Actor: publicKey.id is not a string');
} }
const publicKeyIdHost = this.punyHost(x.publicKey.id); const publicKeyIdHost = this.utilityService.punyHost(x.publicKey.id);
if (publicKeyIdHost !== expectHost) { if (publicKeyIdHost !== expectHost) {
throw new Error('invalid Actor: publicKey.id has different host'); throw new Error('invalid Actor: publicKey.id has different host');
} }
@ -286,7 +293,7 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Creating the Person: ${person.id}`); this.logger.info(`Creating the Person: ${person.id}`);
const host = this.punyHost(object.id); const host = this.utilityService.punyHost(object.id);
const fields = this.analyzeAttachments(person.attachment ?? []); const fields = this.analyzeAttachments(person.attachment ?? []);

View file

@ -113,8 +113,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@bindThis @bindThis
private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> { private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> {
// ブロックしてたら中断 // ブロックしてたら中断
const host = this.utilityService.extractDbHost(uri);
const fetchedMeta = await this.metaService.fetch(); const fetchedMeta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, this.utilityService.extractDbHost(uri))) return null; if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, host)) return null;
let local = await this.mergePack(me, ...await Promise.all([ let local = await this.mergePack(me, ...await Promise.all([
this.apDbResolverService.getUserFromApId(uri), this.apDbResolverService.getUserFromApId(uri),
@ -122,6 +123,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
])); ]));
if (local != null) return local; if (local != null) return local;
// local object, not found in db? fail
if (this.utilityService.isSelfHost(host)) return null;
// リモートから一旦オブジェクトフェッチ // リモートから一旦オブジェクトフェッチ
const resolver = this.apResolverService.createResolver(); const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri) as any; const object = await resolver.resolve(uri) as any;

View file

@ -51,6 +51,12 @@ export const paramDef = {
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
withRenotes: { type: 'boolean', default: true },
withFiles: {
type: 'boolean',
default: false,
description: 'Only show notes that have attached files.',
},
}, },
required: ['channelId'], required: ['channelId'],
} as const; } as const;
@ -89,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) this.activeUsersChart.read(me); if (me) this.activeUsersChart.read(me);
if (!serverSettings.enableFanoutTimeline) { if (!serverSettings.enableFanoutTimeline) {
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me); return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
} }
return await this.fanoutTimelineEndpointService.timeline({ return await this.fanoutTimelineEndpointService.timeline({
@ -100,9 +106,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
me, me,
useDbFallback: true, useDbFallback: true,
redisTimelines: [`channelTimeline:${channel.id}`], redisTimelines: [`channelTimeline:${channel.id}`],
excludePureRenotes: false, excludePureRenotes: !ps.withRenotes,
excludeNoFiles: ps.withFiles,
dbFallback: async (untilId, sinceId, limit) => { dbFallback: async (untilId, sinceId, limit) => {
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me); return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me);
}, },
}); });
}); });
@ -112,7 +119,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId: string | null, untilId: string | null,
sinceId: string | null, sinceId: string | null,
limit: number, limit: number,
channelId: string channelId: string,
withFiles: boolean,
withRenotes: boolean,
}, me: MiLocalUser | null) { }, me: MiLocalUser | null) {
//#region fallback to database //#region fallback to database
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
@ -128,6 +137,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me);
} }
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -15,6 +15,8 @@ class ChannelChannel extends Channel {
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = false as const; public static requireCredential = false as const;
private channelId: string; private channelId: string;
private withFiles: boolean;
private withRenotes: boolean;
constructor( constructor(
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
@ -29,6 +31,8 @@ class ChannelChannel extends Channel {
@bindThis @bindThis
public async init(params: any) { public async init(params: any) {
this.channelId = params.channelId as string; this.channelId = params.channelId as string;
this.withFiles = params.withFiles ?? false;
this.withRenotes = params.withRenotes ?? true;
// Subscribe stream // Subscribe stream
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
@ -38,6 +42,10 @@ class ChannelChannel extends Channel {
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
if (note.channelId !== this.channelId) return; if (note.channelId !== this.channelId) return;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return; if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する

View file

@ -154,6 +154,8 @@ function connectChannel() {
} else if (props.src === 'channel') { } else if (props.src === 'channel') {
if (props.channel == null) return; if (props.channel == null) return;
connection = stream.useChannel('channel', { connection = stream.useChannel('channel', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
channelId: props.channel, channelId: props.channel,
}); });
} else if (props.src === 'role') { } else if (props.src === 'role') {
@ -234,6 +236,8 @@ function updatePaginationQuery() {
} else if (props.src === 'channel') { } else if (props.src === 'channel') {
endpoint = 'channels/timeline'; endpoint = 'channels/timeline';
query = { query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
channelId: props.channel, channelId: props.channel,
}; };
} else if (props.src === 'role') { } else if (props.src === 'role') {

View file

@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる --> <!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/> <MkTimeline :key="channelId + withRenotes + onlyFiles" src="channel" :channel="channelId" :withRenotes="withRenotes" :onlyFiles="onlyFiles" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
</div> </div>
<div v-else-if="tab === 'featured'" key="featured"> <div v-else-if="tab === 'featured'" key="featured">
<MkNotes :pagination="featuredPagination"/> <MkNotes :pagination="featuredPagination"/>
@ -95,6 +95,7 @@ import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
import { deepMerge } from '@/scripts/merge.js';
const router = useRouter(); const router = useRouter();
@ -116,6 +117,15 @@ const featuredPagination = computed(() => ({
channelId: props.channelId, channelId: props.channelId,
}, },
})); }));
const withRenotes = computed<boolean>({
get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
set: (x) => saveTlFilter('withRenotes', x),
});
const onlyFiles = computed<boolean>({
get: () => defaultStore.reactiveState.tl.value.filter.onlyFiles,
set: (x) => saveTlFilter('onlyFiles', x),
});
watch(() => props.channelId, async () => { watch(() => props.channelId, async () => {
channel.value = await misskeyApi('channels/show', { channel.value = await misskeyApi('channels/show', {
@ -136,6 +146,13 @@ watch(() => props.channelId, async () => {
} }
}, { immediate: true }); }, { immediate: true });
function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) {
if (key !== 'withReplies' || $i) {
const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl);
defaultStore.set('tl', out);
}
}
function edit() { function edit() {
router.push(`/channels/${channel.value?.id}/edit`); router.push(`/channels/${channel.value?.id}/edit`);
} }
@ -192,7 +209,21 @@ async function search() {
const headerActions = computed(() => { const headerActions = computed(() => {
if (channel.value && channel.value.userId) { if (channel.value && channel.value.userId) {
const headerItems: PageHeaderItem[] = []; const headerItems: PageHeaderItem[] = [{
icon: 'ph-dots-three ph-bold ph-lg',
text: i18n.ts.options,
handler: (ev) => {
os.popupMenu([{
type: 'switch',
text: i18n.ts.showRenotes,
ref: withRenotes,
}, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
}], ev.currentTarget ?? ev.target);
},
}];
headerItems.push({ headerItems.push({
icon: 'ph-share-network ph-bold ph-lg', icon: 'ph-share-network ph-bold ph-lg',

View file

@ -11,10 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <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"> <div :class="$style.tl">
<MkTimeline <MkTimeline
ref="tlEl" :key="listId" ref="tlEl" :key="listId + withRenotes + onlyFiles"
src="list" src="list"
:list="listId" :list="listId"
:sound="true" :sound="true"
:withRenotes="withRenotes"
:onlyFiles="onlyFiles"
@queue="queueUpdated" @queue="queueUpdated"
/> />
</div> </div>
@ -32,6 +34,9 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
import { defaultStore } from '@/store.js';
import { deepMerge } from '@/scripts/merge.js';
import * as os from '@/os.js';
const router = useRouter(); const router = useRouter();
@ -43,6 +48,21 @@ const list = ref<Misskey.entities.UserList | null>(null);
const queue = ref(0); const queue = ref(0);
const tlEl = shallowRef<InstanceType<typeof MkTimeline>>(); const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
const rootEl = shallowRef<HTMLElement>(); const rootEl = shallowRef<HTMLElement>();
const withRenotes = computed<boolean>({
get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
set: (x) => saveTlFilter('withRenotes', x),
});
const onlyFiles = computed<boolean>({
get: () => defaultStore.reactiveState.tl.value.filter.onlyFiles,
set: (x) => saveTlFilter('onlyFiles', x),
});
function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) {
if (key !== 'withReplies' || $i) {
const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl);
defaultStore.set('tl', out);
}
}
watch(() => props.listId, async () => { watch(() => props.listId, async () => {
list.value = await misskeyApi('users/lists/show', { list.value = await misskeyApi('users/lists/show', {
@ -63,6 +83,20 @@ function settings() {
} }
const headerActions = computed(() => list.value ? [{ const headerActions = computed(() => list.value ? [{
icon: 'ph-dots-three ph-bold ph-lg',
text: i18n.ts.options,
handler: (ev) => {
os.popupMenu([{
type: 'switch',
text: i18n.ts.showRenotes,
ref: withRenotes,
}, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
}], ev.currentTarget ?? ev.target);
},
}, {
icon: 'ph-gear ph-bold ph-lg', icon: 'ph-gear ph-bold ph-lg',
text: i18n.ts.settings, text: i18n.ts.settings,
handler: settings, handler: settings,

View file

@ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="padding: 8px; text-align: center;"> <div style="padding: 8px; text-align: center;">
<MkButton primary gradate rounded inline small @click="post"><i class="ph-pencil-simple ph-bold ph-lg"></i></MkButton> <MkButton primary gradate rounded inline small @click="post"><i class="ph-pencil-simple ph-bold ph-lg"></i></MkButton>
</div> </div>
<MkTimeline ref="timeline" src="channel" :channel="column.channelId"/> <MkTimeline ref="timeline" src="channel" :channel="column.channelId" :key="column.channelId + column.withRenotes + column.onlyFiles" :withRenotes="withRenotes" :onlyFiles="onlyFiles"/>
</template> </template>
</XColumn> </XColumn>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { shallowRef } from 'vue'; import { watch, ref, shallowRef } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XColumn from './column.vue'; import XColumn from './column.vue';
import { updateColumn, Column } from './deck-store.js'; import { updateColumn, Column } from './deck-store.js';
@ -36,6 +36,20 @@ const props = defineProps<{
const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const channel = shallowRef<Misskey.entities.Channel>(); const channel = shallowRef<Misskey.entities.Channel>();
const withRenotes = ref(props.column.withRenotes ?? true);
const onlyFiles = ref(props.column.onlyFiles ?? false);
watch(withRenotes, v => {
updateColumn(props.column.id, {
withRenotes: v,
});
});
watch(onlyFiles, v => {
updateColumn(props.column.id, {
onlyFiles: v,
});
});
if (props.column.channelId == null) { if (props.column.channelId == null) {
setChannel(); setChannel();
@ -75,5 +89,13 @@ const menu = [{
icon: 'ph-pencil-simple ph-bold ph-lg', icon: 'ph-pencil-simple ph-bold ph-lg',
text: i18n.ts.selectChannel, text: i18n.ts.selectChannel,
action: setChannel, action: setChannel,
}, {
type: 'switch',
text: i18n.ts.showRenotes,
ref: withRenotes,
}, {
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
}]; }];
</script> </script>

View file

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ph-list ph-bold ph-lg"></i><span style="margin-left: 8px;">{{ column.name }}</span> <i class="ph-list ph-bold ph-lg"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template> </template>
<MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/> <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :key="column.listId + column.withRenotes + column.onlyFiles" :withRenotes="withRenotes" :onlyFiles="onlyFiles"/>
</XColumn> </XColumn>
</template> </template>
@ -29,6 +29,7 @@ const props = defineProps<{
const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
const withRenotes = ref(props.column.withRenotes ?? true); const withRenotes = ref(props.column.withRenotes ?? true);
const onlyFiles = ref(props.column.onlyFiles ?? false);
if (props.column.listId == null) { if (props.column.listId == null) {
setList(); setList();
@ -40,6 +41,12 @@ watch(withRenotes, v => {
}); });
}); });
watch(onlyFiles, v => {
updateColumn(props.column.id, {
onlyFiles: v,
});
});
async function setList() { async function setList() {
const lists = await misskeyApi('users/lists/list'); const lists = await misskeyApi('users/lists/list');
const { canceled, result: list } = await os.select({ const { canceled, result: list } = await os.select({
@ -75,5 +82,10 @@ const menu = [
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,
ref: withRenotes, ref: withRenotes,
}, },
{
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
},
]; ];
</script> </script>