diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c330881..1ce96146c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,22 @@ --> +## 2023.10.1 +### General +- Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に + +### Client +- Fix: 絵文字ピッカーで横に長いカスタム絵文字が見切れる問題を修正 + +### Server +- Fix: フォローしているユーザーからの自分の投稿への返信がタイムラインに含まれない問題を修正 +- Fix: users/notesでセンシティブチャンネルの投稿が含まれる場合がある問題を修正 + ## 2023.10.0 ### NOTE - 2023.9.2で導入されたノート編集機能はクオリティの高い実装が困難であることが判明したため撤回されました - アップデートを行うと、タイムラインが一時的にリセットされます +- ソフトミュート設定はクライアントではなくサーバー側に保存されるようになったため、アップデートを行うとソフトミュートの設定がリセットされます ### Changes - API: users/notes, notes/local-timeline で fileType 指定はできなくなりました diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index e715cae54..ccdb87379 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -2141,3 +2141,11 @@ _moderationLogTypes: createAd: "建立廣告" deleteAd: "刪除廣告" updateAd: "更新廣告" +_fileViewer: + title: "檔案詳細資訊" + type: "檔案類型 " + size: "檔案大小" + url: "URL" + uploadedAt: "加入日期" + attachedNotes: "含有附件的貼文" + thisPageCanBeSeenFromTheAuthor: "本頁面僅限上傳了這個檔案的使用者可以檢視。" diff --git a/package.json b/package.json index 36fde5c1d..c0be95d45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2023.10.0-io", + "version": "2023.10.1-io", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 4fdf367c3..fca502211 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -868,8 +868,8 @@ export class NoteCreateService implements OnApplicationShutdown { // 基本的にvisibleUserIdsには自身のidが含まれている前提であること if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; - // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { + // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 + if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === following.followerId)) { if (!following.withReplies) continue; } @@ -886,8 +886,8 @@ export class NoteCreateService implements OnApplicationShutdown { !note.visibleUserIds.some(v => v === userListMembership.userListUserId) ) continue; - // 自分自身以外への返信 - if (note.replyId && note.replyUserId !== note.userId) { + // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 + if (note.replyId && !(note.replyUserId === note.userId || note.replyUserId === userListMembership.userListUserId)) { if (!userListMembership.withReplies) continue; } @@ -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) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 1b77285d4..8ac5f1b03 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -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 { // 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); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 2357f32d5..d10c3bedb 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -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,19 @@ export default class extends Endpoint { // eslint- this.cacheService.userBlockedCache.fetch(me.id), ]) : [new Set(), new Set(), new Set()]; - 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 { + 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); + } + noteIds = noteIds.slice(0, ps.limit); if (noteIds.length === 0) { @@ -112,6 +125,7 @@ export default class extends Endpoint { // eslint- if (me && (note.userId === me.id)) { return true; } + if (!ps.withReplies && note.replyId && (me == null || note.replyUserId !== me.id)) return false; if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; if (note.renoteId) { diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index dfef35986..df0951ce7 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -150,7 +150,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (!ps.withChannelNotes) { + if (ps.withChannelNotes) { + if (!isSelf) query.andWhere('channel.isSensitive = false'); + } else { query.andWhere('note.channelId IS NULL'); } diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index dbb38f6d5..f3e2c91aa 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -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 @@ -90,7 +92,7 @@ class HybridTimelineChannel extends Channel { if (isInstanceMuted(note, new Set(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; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 511072253..018d2e445 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -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 @@ -69,7 +71,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; diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 8a3e60314..5dd9b5cd7 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -19,7 +19,7 @@ function genHost() { } function waitForPushToTl() { - return sleep(300); + return sleep(500); } let app: INestApplicationContext; @@ -41,7 +41,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); @@ -57,7 +57,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -73,7 +73,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); @@ -90,7 +90,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -107,7 +107,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -124,7 +124,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -141,7 +141,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -159,7 +159,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, 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); @@ -178,7 +178,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, 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); @@ -194,12 +194,28 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }); + test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await sleep(1000); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('/notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + test.concurrent('自分の他人への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -208,7 +224,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); @@ -224,7 +240,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -275,7 +291,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -291,7 +307,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -309,7 +325,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -324,7 +340,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); @@ -338,7 +354,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); @@ -359,7 +375,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', { withFiles: true }, alice); + const res = await api('/notes/timeline', { limit: 100, withFiles: true }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); @@ -377,7 +393,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -389,7 +405,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); @@ -404,7 +420,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); @@ -417,7 +433,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -431,7 +447,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -444,7 +460,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'ok'); @@ -459,7 +475,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'ok'); @@ -475,7 +491,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/timeline', {}, alice); + const res = await api('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -490,12 +506,26 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); 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', { limit: 100 }, 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()]); @@ -504,7 +534,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -516,7 +546,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -532,7 +562,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -548,7 +578,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -565,7 +595,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); @@ -583,12 +613,41 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); + test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await sleep(1000); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('/notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.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/local-timeline', { limit: 100, withReplies: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -598,7 +657,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', { withFiles: true }, alice); + const res = await api('/notes/local-timeline', { limit: 100, withFiles: true }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); @@ -613,7 +672,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/hybrid-timeline', {}, alice); + const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); @@ -625,7 +684,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/hybrid-timeline', {}, alice); + const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -639,11 +698,41 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/hybrid-timeline', {}, alice); + const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); + test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('/following/create', { userId: bob.id }, alice); + await sleep(1000); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.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 () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); @@ -651,7 +740,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/local-timeline', {}, alice); + const res = await api('/notes/local-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); @@ -665,7 +754,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/hybrid-timeline', {}, alice); + const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); @@ -679,7 +768,20 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/hybrid-timeline', {}, alice); + const res = await api('/notes/hybrid-timeline', { limit: 100 }, alice); + + 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', { limit: 100, withReplies: true }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); @@ -693,7 +795,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('/notes/hybrid-timeline', { withFiles: true }, alice); + const res = await api('/notes/hybrid-timeline', { limit: 100, withFiles: true }, alice); assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); @@ -779,6 +881,22 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }); + test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice); + await sleep(1000); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('/notes/user-list-timeline', { listId: list.id, withReplies: false }, alice); + + 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()]); @@ -1034,6 +1152,32 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); + test.concurrent('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await api('/channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('/users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test.concurrent('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { + const [bob] = await Promise.all([signup()]); + + const channel = await api('/channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('/users/notes', { userId: bob.id, withChannelNotes: true }, bob); + + 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()]); diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index f711a2672..a34620c2f 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -616,6 +616,8 @@ defineExpose({ height: 1.25em; vertical-align: -.25em; pointer-events: none; + width: 100%; + object-fit: contain; } } } diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index df0db77ba..8b9426b89 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -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); @@ -112,10 +116,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); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index eb53c9054..f205a8992 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -15,10 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only
($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', diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 290fcdbf4..b844a2406 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -31,6 +31,7 @@ export type Column = { excludeTypes?: typeof notificationTypes[number][]; tl?: 'home' | 'local' | 'media' | 'social' | 'global'; withRenotes?: boolean; + withReplies?: boolean; onlyFiles?: boolean; }; diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 6937f73e4..9c9f039c6 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -24,9 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -52,6 +53,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 => { @@ -110,7 +112,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),