enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に

Resolve #12001
This commit is contained in:
syuilo 2023-10-11 10:15:44 +09:00
parent 1f0c27edf2
commit 7a8d5e5840
11 changed files with 131 additions and 12 deletions

View File

@ -13,6 +13,9 @@
--> -->
## 2023.10.1 ## 2023.10.1
### General
- Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に
### Server ### Server
- Fix: フォローしているユーザーからの自分の投稿への返信がタイムラインに含まれない問題を修正 - Fix: フォローしているユーザーからの自分の投稿への返信がタイムラインに含まれない問題を修正

View File

@ -907,6 +907,10 @@ export class NoteCreateService implements OnApplicationShutdown {
// 自分自身以外への返信 // 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) { if (note.replyId && note.replyUserId !== note.userId) {
this.redisTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); 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 { } else {
this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); this.redisTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
if (note.fileIds.length > 0) { if (note.fileIds.length > 0) {

View File

@ -55,6 +55,7 @@ export const paramDef = {
includeLocalRenotes: { type: 'boolean', default: true }, includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false },
}, },
required: [], required: [],
} as const; } as const;
@ -94,12 +95,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
]); ]);
const [htlNoteIds, ltlNoteIds] = await this.redisTimelineService.getMulti([ let noteIds: string[];
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', if (ps.withFiles) {
], untilId, sinceId); 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.sort((a, b) => a > b ? -1 : 1);
noteIds = noteIds.slice(0, ps.limit); noteIds = noteIds.slice(0, ps.limit);

View File

@ -45,6 +45,7 @@ export const paramDef = {
properties: { properties: {
withFiles: { type: 'boolean', default: false }, withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false },
excludeNsfw: { type: 'boolean', default: false }, excludeNsfw: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, 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), this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>(), new Set<string>()]; ]) : [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); noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) { if (noteIds.length === 0) {

View File

@ -19,6 +19,7 @@ class HybridTimelineChannel extends Channel {
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = true; public static requireCredential = true;
private withRenotes: boolean; private withRenotes: boolean;
private withReplies: boolean;
private withFiles: boolean; private withFiles: boolean;
constructor( constructor(
@ -39,6 +40,7 @@ class HybridTimelineChannel extends Channel {
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.withReplies = params.withReplies ?? false;
this.withFiles = params.withFiles ?? false; this.withFiles = params.withFiles ?? false;
// Subscribe events // Subscribe events
@ -87,7 +89,7 @@ class HybridTimelineChannel extends Channel {
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return; 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; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;

View File

@ -18,6 +18,7 @@ class LocalTimelineChannel extends Channel {
public static shouldShare = false; public static shouldShare = false;
public static requireCredential = false; public static requireCredential = false;
private withRenotes: boolean; private withRenotes: boolean;
private withReplies: boolean;
private withFiles: boolean; private withFiles: boolean;
constructor( constructor(
@ -38,6 +39,7 @@ class LocalTimelineChannel extends Channel {
if (!policies.ltlAvailable) return; if (!policies.ltlAvailable) return;
this.withRenotes = params.withRenotes ?? true; this.withRenotes = params.withRenotes ?? true;
this.withReplies = params.withReplies ?? false;
this.withFiles = params.withFiles ?? false; this.withFiles = params.withFiles ?? false;
// Subscribe events // 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; const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;

View File

@ -512,6 +512,20 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); 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 () => { test.concurrent('チャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); 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 () => { test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); 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); 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 () => { test.concurrent('リモートユーザーのノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); 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); 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 () => { test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);

View File

@ -24,9 +24,11 @@ const props = withDefaults(defineProps<{
role?: string; role?: string;
sound?: boolean; sound?: boolean;
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean;
onlyFiles?: boolean; onlyFiles?: boolean;
}>(), { }>(), {
withRenotes: true, withRenotes: true,
withReplies: false,
onlyFiles: false, onlyFiles: false,
}); });
@ -90,10 +92,12 @@ if (props.src === 'antenna') {
endpoint = 'notes/local-timeline'; endpoint = 'notes/local-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('localTimeline', { connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);
@ -101,10 +105,12 @@ if (props.src === 'antenna') {
endpoint = 'notes/hybrid-timeline'; endpoint = 'notes/hybrid-timeline';
query = { query = {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}; };
connection = stream.useChannel('hybridTimeline', { connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
}); });
connection.on('note', prepend); connection.on('note', prepend);

View File

@ -15,10 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.tl"> <div :class="$style.tl">
<MkTimeline <MkTimeline
ref="tlComponent" ref="tlComponent"
:key="src + withRenotes + onlyFiles" :key="src + withRenotes + withReplies + onlyFiles"
:src="src.split(':')[0]" :src="src.split(':')[0]"
:list="src.split(':')[1]" :list="src.split(':')[1]"
:withRenotes="withRenotes" :withRenotes="withRenotes"
:withReplies="withReplies"
:onlyFiles="onlyFiles" :onlyFiles="onlyFiles"
:sound="true" :sound="true"
@queue="queueUpdated" @queue="queueUpdated"
@ -61,6 +62,7 @@ let queue = $ref(0);
let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global');
const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) }); const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) });
const withRenotes = $ref(true); const withRenotes = $ref(true);
const withReplies = $ref(false);
const onlyFiles = $ref(false); const onlyFiles = $ref(false);
watch($$(src), () => queue = 0); watch($$(src), () => queue = 0);
@ -142,7 +144,11 @@ const headerActions = $computed(() => [{
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
ref: $$(withRenotes), ref: $$(withRenotes),
}, { }, src === 'local' || src === 'social' ? {
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: $$(withReplies),
} : undefined, {
type: 'switch', type: 'switch',
text: i18n.ts.fileAttachedOnly, text: i18n.ts.fileAttachedOnly,
icon: 'ti ti-photo', icon: 'ti ti-photo',

View File

@ -31,6 +31,7 @@ export type Column = {
excludeTypes?: typeof notificationTypes[number][]; excludeTypes?: typeof notificationTypes[number][];
tl?: 'home' | 'local' | 'social' | 'global'; tl?: 'home' | 'local' | 'social' | 'global';
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean;
onlyFiles?: boolean; onlyFiles?: boolean;
}; };

View File

@ -23,9 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTimeline <MkTimeline
v-else-if="column.tl" v-else-if="column.tl"
ref="timeline" ref="timeline"
:key="column.tl + withRenotes + onlyFiles" :key="column.tl + withRenotes + withReplies + onlyFiles"
:src="column.tl" :src="column.tl"
:withRenotes="withRenotes" :withRenotes="withRenotes"
:withReplies="withReplies"
:onlyFiles="onlyFiles" :onlyFiles="onlyFiles"
/> />
</XColumn> </XColumn>
@ -51,6 +52,7 @@ let disabled = $ref(false);
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable)); 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 isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
const withRenotes = $ref(props.column.withRenotes ?? true); const withRenotes = $ref(props.column.withRenotes ?? true);
const withReplies = $ref(props.column.withReplies ?? false);
const onlyFiles = $ref(props.column.onlyFiles ?? false); const onlyFiles = $ref(props.column.onlyFiles ?? false);
watch($$(withRenotes), v => { watch($$(withRenotes), v => {
@ -107,7 +109,11 @@ const menu = [{
type: 'switch', type: 'switch',
text: i18n.ts.showRenotes, text: i18n.ts.showRenotes,
ref: $$(withRenotes), ref: $$(withRenotes),
}, { }, props.column.tl === 'local' || props.column.tl === 'social' ? {
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: $$(withReplies),
} : undefined, {
type: 'switch', type: 'switch',
text: i18n.ts.fileAttachedOnly, text: i18n.ts.fileAttachedOnly,
ref: $$(onlyFiles), ref: $$(onlyFiles),