From fea993f6b2e6b8d34f0ed07cf1a565c3d725e7e6 Mon Sep 17 00:00:00 2001 From: Hazel K Date: Tue, 8 Oct 2024 16:37:44 -0400 Subject: [PATCH 01/13] correct name of `SkLatestNote` --- packages/backend/src/core/NoteCreateService.ts | 4 ++-- packages/backend/src/core/NoteDeleteService.ts | 6 +++--- packages/backend/src/models/LatestNote.ts | 4 ++-- packages/backend/src/models/RepositoryModule.ts | 4 ++-- packages/backend/src/models/_.ts | 6 +++--- packages/backend/src/postgres.ts | 4 ++-- .../backend/src/server/api/endpoints/notes/following.ts | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index ef0047ca90..03701c33e5 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import { LatestNote } from '@/models/LatestNote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; @@ -1159,7 +1159,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (currentLatest != null && currentLatest.noteId >= note.id) return; // Record this as the latest note for the given user - const latestNote = new LatestNote({ + const latestNote = new SkLatestNote({ userId: note.userId, noteId: note.id, }); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 3f86f41942..b81e7f6471 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -7,7 +7,7 @@ import { Brackets, In, Not } from 'typeorm'; import { Injectable, Inject } from '@nestjs/common'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; -import { LatestNote } from '@/models/LatestNote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -268,7 +268,7 @@ export class NoteDeleteService { if (!nextLatest) return; // Record it as the latest - const latestNote = new LatestNote({ + const latestNote = new SkLatestNote({ userId: note.userId, noteId: nextLatest.id, }); @@ -278,7 +278,7 @@ export class NoteDeleteService { await this.latestNotesRepository .createQueryBuilder('latest') .insert() - .into(LatestNote) + .into(SkLatestNote) .values(latestNote) .orIgnore() .execute(); diff --git a/packages/backend/src/models/LatestNote.ts b/packages/backend/src/models/LatestNote.ts index 1163ff3bc0..d1c96adae2 100644 --- a/packages/backend/src/models/LatestNote.ts +++ b/packages/backend/src/models/LatestNote.ts @@ -13,7 +13,7 @@ import { MiNote } from '@/models/Note.js'; * DMs are not counted. */ @Entity('latest_note') -export class LatestNote { +export class SkLatestNote { @PrimaryColumn({ name: 'user_id', type: 'varchar' as const, @@ -44,7 +44,7 @@ export class LatestNote { }) public note: MiNote | null; - constructor(data?: Partial) { + constructor(data?: Partial) { if (!data) return; for (const [k, v] of Object.entries(data)) { diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index f44334d84e..eb45b9a631 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -7,7 +7,7 @@ import type { Provider } from '@nestjs/common'; import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import { - LatestNote, + SkLatestNote, MiAbuseReportNotificationRecipient, MiAbuseUserReport, MiAccessToken, @@ -121,7 +121,7 @@ const $avatarDecorationsRepository: Provider = { const $latestNotesRepository: Provider = { provide: DI.latestNotesRepository, - useFactory: (db: DataSource) => db.getRepository(LatestNote).extend(miRepository as MiRepository), + useFactory: (db: DataSource) => db.getRepository(SkLatestNote).extend(miRepository as MiRepository), inject: [DI.db], }; diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 9e01f4b6d7..ac2dd62aa2 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -10,7 +10,7 @@ import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLo import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; import { OrmUtils } from 'typeorm/util/OrmUtils.js'; -import { LatestNote } from '@/models/LatestNote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; @@ -127,7 +127,7 @@ export const miRepository = { } satisfies MiRepository; export { - LatestNote, + SkLatestNote, MiAbuseUserReport, MiAbuseReportNotificationRecipient, MiAccessToken, @@ -226,7 +226,7 @@ export type GalleryPostsRepository = Repository & MiRepository & MiRepository; export type InstancesRepository = Repository & MiRepository; export type MetasRepository = Repository & MiRepository; -export type LatestNotesRepository = Repository & MiRepository; +export type LatestNotesRepository = Repository & MiRepository; export type ModerationLogsRepository = Repository & MiRepository; export type MutingsRepository = Repository & MiRepository; export type RenoteMutingsRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 0d17b3d046..2d66e6e445 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -83,7 +83,7 @@ import { MiReversiGame } from '@/models/ReversiGame.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import { LatestNote } from '@/models/LatestNote.js'; +import { SkLatestNote } from '@/models/LatestNote.js'; pg.types.setTypeParser(20, Number); @@ -131,7 +131,7 @@ class MyCustomLogger implements Logger { } export const entities = [ - LatestNote, + SkLatestNote, MiAnnouncement, MiAnnouncementRead, MiMeta, diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 436160f250..1d9ce9704e 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -4,7 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { LatestNote, MiFollowing } from '@/models/_.js'; +import { SkLatestNote, MiFollowing } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -57,7 +57,7 @@ export default class extends Endpoint { // eslint- .setParameter('me', me.id) // Limit to latest notes - .innerJoin(LatestNote, 'latest', 'note.id = latest.note_id') + .innerJoin(SkLatestNote, 'latest', 'note.id = latest.note_id') // Avoid N+1 queries from the "pack" method .innerJoinAndSelect('note.user', 'user') From 9d3292e6e95c3abea407d85fc807d6a626a1aaf7 Mon Sep 17 00:00:00 2001 From: Hazel K Date: Tue, 8 Oct 2024 17:02:31 -0400 Subject: [PATCH 02/13] add type columns to SkLatestNote --- .../1728420772835-track-latest-note-type.js | 24 +++++++++++++++++++ packages/backend/src/models/LatestNote.ts | 18 ++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 packages/backend/migration/1728420772835-track-latest-note-type.js diff --git a/packages/backend/migration/1728420772835-track-latest-note-type.js b/packages/backend/migration/1728420772835-track-latest-note-type.js new file mode 100644 index 0000000000..cef379c7f3 --- /dev/null +++ b/packages/backend/migration/1728420772835-track-latest-note-type.js @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class TrackLatestNoteType1728420772835 { + name = 'TrackLatestNoteType1728420772835' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a"`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD "isPublic" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD "isReply" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD "isQuote" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", "isPublic", "isReply", "isQuote")`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isQuote"`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isReply"`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isPublic"`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a" PRIMARY KEY ("user_id")`); + } +} diff --git a/packages/backend/src/models/LatestNote.ts b/packages/backend/src/models/LatestNote.ts index d1c96adae2..f7b0ca6a23 100644 --- a/packages/backend/src/models/LatestNote.ts +++ b/packages/backend/src/models/LatestNote.ts @@ -21,6 +21,24 @@ export class SkLatestNote { }) public userId: string; + @PrimaryColumn('boolean', { + name: 'is_public', + default: false, + }) + public isPublic: boolean; + + @PrimaryColumn('boolean', { + name: 'is_reply', + default: false, + }) + public isReply: boolean; + + @PrimaryColumn('boolean', { + name: 'is_quote', + default: false, + }) + public isQuote: boolean; + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) From 56e7d7e0b16be930fe0aaac67c00216e9b998b3d Mon Sep 17 00:00:00 2001 From: Hazel K Date: Tue, 8 Oct 2024 23:45:51 -0400 Subject: [PATCH 03/13] remove un-necessary assignment to query --- .../backend/src/server/api/endpoints/notes/following.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 1d9ce9704e..56e0fcd03c 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -52,7 +52,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - let query = this.notesRepository + const query = this.notesRepository .createQueryBuilder('note') .setParameter('me', me.id) @@ -73,8 +73,7 @@ export default class extends Endpoint { // eslint- // Limit to mutuals, if requested if (ps.mutualsOnly) { - query = query - .innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me'); + query.innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me'); } // Respect blocks and mutes @@ -82,7 +81,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQuery(query, me); // Support pagination - query = this.queryService + this.queryService .makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .orderBy('note.id', 'DESC') .take(ps.limit); From 463b9ac59def86dd1b9065cbe7382325c1e5824e Mon Sep 17 00:00:00 2001 From: Hazel K Date: Wed, 9 Oct 2024 15:09:55 -0400 Subject: [PATCH 04/13] add filters for following feed --- locales/en-US.yml | 3 + locales/index.d.ts | 12 +++ locales/ja-JP.yml | 3 + .../1728420772835-track-latest-note-type.js | 8 +- .../backend/src/core/NoteCreateService.ts | 13 ++- .../backend/src/core/NoteDeleteService.ts | 26 ++++-- packages/backend/src/misc/is-renote.ts | 15 +++ packages/backend/src/models/LatestNote.ts | 13 +++ packages/backend/src/postgres.ts | 10 +- .../server/api/endpoints/notes/following.ts | 21 +++++ .../src/server/api/endpoints/users/notes.ts | 50 +++++++++- packages/backend/test/unit/misc/is-renote.ts | 23 ++++- .../backend/test/unit/models/LatestNote.ts | 66 +++++++++++++ .../src/components/SkUserRecentNotes.vue | 21 +++-- .../frontend/src/pages/following-feed.vue | 92 ++++++++++--------- packages/misskey-js/src/autogen/types.ts | 14 +++ 16 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 packages/backend/test/unit/models/LatestNote.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index 2fb4700fcf..215519d153 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1263,6 +1263,9 @@ authentication: "Authentication" authenticationRequiredToContinue: "Please authenticate to continue" dateAndTime: "Timestamp" showRenotes: "Show boosts" +showQuotes: "Show quotes" +showReplies: "Show replies" +showNonPublicNotes: "Show non-public" edited: "Edited" notificationRecieveConfig: "Notification Settings" mutualFollow: "Mutual follow" diff --git a/locales/index.d.ts b/locales/index.d.ts index 6d6ee68c1c..e89165066a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5065,6 +5065,18 @@ export interface Locale extends ILocale { * ブーストを表示 */ "showRenotes": string; + /** + * Show quotes + */ + "showQuotes": string; + /** + * Show replies + */ + "showReplies": string; + /** + * Show non-public + */ + "showNonPublicNotes": string; /** * 編集済み */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f2c3d67133..957c49f367 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1262,6 +1262,9 @@ authentication: "認証" authenticationRequiredToContinue: "続けるには認証を行ってください" dateAndTime: "日時" showRenotes: "ブーストを表示" +showQuotes: "Show quotes" +showReplies: "Show replies" +showNonPublicNotes: "Show non-public" edited: "編集済み" notificationRecieveConfig: "通知の受信設定" mutualFollow: "相互フォロー" diff --git a/packages/backend/migration/1728420772835-track-latest-note-type.js b/packages/backend/migration/1728420772835-track-latest-note-type.js index cef379c7f3..8f8198707e 100644 --- a/packages/backend/migration/1728420772835-track-latest-note-type.js +++ b/packages/backend/migration/1728420772835-track-latest-note-type.js @@ -11,14 +11,14 @@ export class TrackLatestNoteType1728420772835 { await queryRunner.query(`ALTER TABLE "latest_note" ADD "isPublic" boolean NOT NULL DEFAULT false`); await queryRunner.query(`ALTER TABLE "latest_note" ADD "isReply" boolean NOT NULL DEFAULT false`); await queryRunner.query(`ALTER TABLE "latest_note" ADD "isQuote" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", "isPublic", "isReply", "isQuote")`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", is_public, is_reply, is_quote)`); } async down(queryRunner) { await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"`); - await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isQuote"`); - await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isReply"`); - await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isPublic"`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_quote`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_reply`); + await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_public`); await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a" PRIMARY KEY ("user_id")`); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 03701c33e5..cbc9dcaf8f 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -63,7 +63,7 @@ import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { isPureRenote } from '@/misc/is-renote.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -1151,18 +1151,21 @@ export class NoteCreateService implements OnApplicationShutdown { if (note.visibility === 'specified') return; // Ignore pure renotes - if (isRenote(note) && !isQuote(note)) return; + if (isPureRenote(note)) return; + + // Compute the compound key of the entry to check + const key = SkLatestNote.keyFor(note); // Make sure that this isn't an *older* post. // We can get older posts through replies, lookups, etc. - const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId }); + const currentLatest = await this.latestNotesRepository.findOneBy(key); if (currentLatest != null && currentLatest.noteId >= note.id) return; // Record this as the latest note for the given user const latestNote = new SkLatestNote({ - userId: note.userId, + ...key, noteId: note.id, }); - await this.latestNotesRepository.upsert(latestNote, ['userId']); + await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']); } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index b81e7f6471..fa77caabd1 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -6,7 +6,7 @@ import { Brackets, In, Not } from 'typeorm'; import { Injectable, Inject } from '@nestjs/common'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; +import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; import { SkLatestNote } from '@/models/LatestNote.js'; import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; @@ -25,7 +25,7 @@ import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; @Injectable() export class NoteDeleteService { @@ -240,8 +240,14 @@ export class NoteDeleteService { // If it's a DM, then it can't possibly be the latest note so we can safely skip this. if (note.visibility === 'specified') return; + // If it's a pure renote, then it can't possibly be the latest note so we can safely skip this. + if (isPureRenote(note)) return; + + // Compute the compound key of the entry to check + const key = SkLatestNote.keyFor(note); + // Check if the deleted note was possibly the latest for the user - const hasLatestNote = await this.latestNotesRepository.existsBy({ userId: note.userId }); + const hasLatestNote = await this.latestNotesRepository.existsBy(key); if (hasLatestNote) return; // Find the newest remaining note for the user. @@ -250,8 +256,16 @@ export class NoteDeleteService { .createQueryBuilder('note') .select() .where({ - userId: note.userId, - visibility: Not('specified'), + userId: key.userId, + visibility: key.isPublic + ? 'public' + : Not('specified'), + replyId: key.isReply + ? Not(null) + : null, + renoteId: key.isQuote + ? Not(null) + : null, }) .andWhere(` ( @@ -269,7 +283,7 @@ export class NoteDeleteService { // Record it as the latest const latestNote = new SkLatestNote({ - userId: note.userId, + ...key, noteId: nextLatest.id, }); diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts index 48f821806c..c128fded14 100644 --- a/packages/backend/src/misc/is-renote.ts +++ b/packages/backend/src/misc/is-renote.ts @@ -23,6 +23,17 @@ type Quote = hasPoll: true }); +type PureRenote = + Renote & { + text: null, + cw: null, + replyId: null, + hasPoll: false, + fileIds: { + length: 0, + }, + }; + export function isRenote(note: MiNote): note is Renote { return note.renoteId != null; } @@ -36,6 +47,10 @@ export function isQuote(note: Renote): note is Quote { note.fileIds.length > 0; } +export function isPureRenote(note: MiNote): note is PureRenote { + return isRenote(note) && !isQuote(note); +} + type PackedRenote = Packed<'Note'> & { renoteId: NonNullable['renoteId']> diff --git a/packages/backend/src/models/LatestNote.ts b/packages/backend/src/models/LatestNote.ts index f7b0ca6a23..d36a4d568a 100644 --- a/packages/backend/src/models/LatestNote.ts +++ b/packages/backend/src/models/LatestNote.ts @@ -6,6 +6,7 @@ import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm'; import { MiUser } from '@/models/User.js'; import { MiNote } from '@/models/Note.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; /** * Maps a user to the most recent post by that user. @@ -69,4 +70,16 @@ export class SkLatestNote { (this as Record)[k] = v; } } + + /** + * Generates a compound key matching a provided note. + */ + static keyFor(note: MiNote) { + return { + userId: note.userId, + isPublic: note.visibility === 'public', + isReply: note.replyId != null, + isQuote: isRenote(note) && isQuote(note), + }; + } } diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 2d66e6e445..eaa0eac57c 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -92,6 +92,8 @@ export const dbLogger = new MisskeyLogger('db'); const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); class MyCustomLogger implements Logger { + private readonly isDevelopment = process.env.NODE_ENV === 'development'; + @bindThis private highlight(sql: string) { return highlight.highlight(sql, { @@ -101,7 +103,13 @@ class MyCustomLogger implements Logger { @bindThis public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.highlight(query).substring(0, 100)); + let message = this.highlight(query); + + if (!this.isDevelopment) { + message = message.substring(0, 100); + } + + sqlLogger.info(message); } @bindThis diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 56e0fcd03c..9606c0f19e 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -33,6 +33,11 @@ export const paramDef = { type: 'object', properties: { mutualsOnly: { type: 'boolean', default: false }, + filesOnly: { type: 'boolean', default: false }, + includeNonPublic: { type: 'boolean', default: true }, + includeReplies: { type: 'boolean', default: false }, + includeQuotes: { type: 'boolean', default: true }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, @@ -76,6 +81,22 @@ export default class extends Endpoint { // eslint- query.innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me'); } + // Limit to files, if requested + if (ps.filesOnly) { + query.andWhere('note."fileIds" != \'{}\''); + } + + // Match selected note types. + if (!ps.includeNonPublic) { + query.andWhere('latest.is_public'); + } + if (!ps.includeReplies) { + query.andWhere('latest.is_reply = false'); + } + if (!ps.includeQuotes) { + query.andWhere('latest.is_quote = false'); + } + // Respect blocks and mutes this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index cc76c12f1d..884760a88f 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -17,6 +17,7 @@ import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { ApiError } from '@/server/api/error.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; export const meta = { tags: ['users', 'notes'], @@ -51,7 +52,10 @@ export const paramDef = { properties: { userId: { type: 'string', format: 'misskey:id' }, withReplies: { type: 'boolean', default: false }, + withRepliesToSelf: { type: 'boolean', default: true }, + withQuotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true }, + withNonPublic: { type: 'boolean', default: true }, withChannelNotes: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, @@ -103,6 +107,10 @@ export default class extends Endpoint { // eslint- withChannelNotes: ps.withChannelNotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, + withQuotes: ps.withQuotes, + withNonPublic: ps.withNonPublic, + withRepliesToOthers: ps.withReplies, + withRepliesToSelf: ps.withRepliesToSelf, }, me); return await this.noteEntityService.packMany(timeline, me); @@ -132,6 +140,11 @@ export default class extends Endpoint { // eslint- if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; if (note.visibility === 'followers' && !isFollowing && !isSelf) return false; + // These are handled by DB fallback, but we duplicate them here in case a timeline was already populated with notes + if (!ps.withRepliesToSelf && note.reply?.userId === note.userId) return false; + if (!ps.withQuotes && isRenote(note) && isQuote(note)) return false; + if (!ps.withNonPublic && note.visibility !== 'public') return false; + return true; }, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ @@ -142,6 +155,10 @@ export default class extends Endpoint { // eslint- withChannelNotes: ps.withChannelNotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, + withQuotes: ps.withQuotes, + withNonPublic: ps.withNonPublic, + withRepliesToOthers: ps.withReplies, + withRepliesToSelf: ps.withRepliesToSelf, }, me), }); @@ -157,6 +174,10 @@ export default class extends Endpoint { // eslint- withChannelNotes: boolean, withFiles: boolean, withRenotes: boolean, + withQuotes: boolean, + withNonPublic: boolean, + withRepliesToOthers: boolean, + withRepliesToSelf: boolean, }, me: MiLocalUser | null) { const isSelf = me && (me.id === ps.userId); @@ -188,7 +209,9 @@ export default class extends Endpoint { // eslint- query.andWhere('note.fileIds != \'{}\''); } - if (ps.withRenotes === false) { + if (!ps.withRenotes && !ps.withQuotes) { + query.andWhere('note.renoteId IS NULL'); + } else if (!ps.withRenotes) { query.andWhere(new Brackets(qb => { qb.orWhere('note.userId != :userId', { userId: ps.userId }); qb.orWhere('note.renoteId IS NULL'); @@ -196,6 +219,31 @@ export default class extends Endpoint { // eslint- qb.orWhere('note.fileIds != \'{}\''); qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); })); + } else if (!ps.withQuotes) { + query.andWhere(` + ( + note."renoteId" IS NULL + OR ( + note.text IS NULL + AND note.cw IS NULL + AND note."replyId" IS NULL + AND note."hasPoll" IS FALSE + AND note."fileIds" = '{}' + ) + ) + `); + } + + if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) { + query.andWhere('reply.id IS NULL'); + } else if (!ps.withRepliesToOthers) { + query.andWhere('(reply.id IS NULL OR reply."userId" = note."userId")'); + } else if (!ps.withRepliesToSelf) { + query.andWhere('(reply.id IS NULL OR reply."userId" != note."userId")'); + } + + if (!ps.withNonPublic) { + query.andWhere('note.visibility = \'public\''); } return await query.limit(ps.limit).getMany(); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 080271e404..4da00bcf25 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; import { MiNote } from '@/models/Note.js'; const base: MiNote = { @@ -86,4 +86,25 @@ describe('misc:is-renote', () => { expect(isRenote(note)).toBe(true); expect(isQuote(note as any)).toBe(true); }); + + describe('isPureRenote', () => { + it('should return true when note is pure renote', () => { + const note = new MiNote({ renoteId: 'abc123' }); + const result = isPureRenote(note); + expect(result).toBeTruthy(); + }); + + it('should return false when note is quote', () => { + const note = new MiNote({ renoteId: 'abc123', text: 'text' }); + const result = isPureRenote(note); + expect(result).toBeFalsy(); + + }); + + it('should return false when note is not renote', () => { + const note = new MiNote({ renoteId: null }); + const result = isPureRenote(note); + expect(result).toBeFalsy(); + }); + }); }); diff --git a/packages/backend/test/unit/models/LatestNote.ts b/packages/backend/test/unit/models/LatestNote.ts new file mode 100644 index 0000000000..f1ea8c95d2 --- /dev/null +++ b/packages/backend/test/unit/models/LatestNote.ts @@ -0,0 +1,66 @@ +import { SkLatestNote } from '@/models/LatestNote.js'; +import { MiNote } from '@/models/Note.js'; + +describe(SkLatestNote, () => { + describe('keyFor', () => { + it('should include userId', () => { + const note = new MiNote({ userId: 'abc123' }); + const key = SkLatestNote.keyFor(note); + expect(key.userId).toBe(note.userId); + }); + + it('should include isPublic when is public', () => { + const note = new MiNote({ visibility: 'public' }); + const key = SkLatestNote.keyFor(note); + expect(key.isPublic).toBeTruthy(); + }); + + it('should include isPublic when is home-only', () => { + const note = new MiNote({ visibility: 'home' }); + const key = SkLatestNote.keyFor(note); + expect(key.isPublic).toBeFalsy(); + }); + + it('should include isPublic when is followers-only', () => { + const note = new MiNote({ visibility: 'followers' }); + const key = SkLatestNote.keyFor(note); + expect(key.isPublic).toBeFalsy(); + }); + + it('should include isPublic when is specified', () => { + const note = new MiNote({ visibility: 'specified' }); + const key = SkLatestNote.keyFor(note); + expect(key.isPublic).toBeFalsy(); + }); + + it('should include isReply when is reply', () => { + const note = new MiNote({ replyId: 'abc123' }); + const key = SkLatestNote.keyFor(note); + expect(key.isReply).toBeTruthy(); + }); + + it('should include isReply when is not reply', () => { + const note = new MiNote({ replyId: null }); + const key = SkLatestNote.keyFor(note); + expect(key.isReply).toBeFalsy(); + }); + + it('should include isQuote when is quote', () => { + const note = new MiNote({ renoteId: 'abc123', text: 'text' }); + const key = SkLatestNote.keyFor(note); + expect(key.isQuote).toBeTruthy(); + }); + + it('should include isQuote when is reblog', () => { + const note = new MiNote({ renoteId: 'abc123' }); + const key = SkLatestNote.keyFor(note); + expect(key.isQuote).toBeFalsy(); + }); + + it('should include isQuote when is neither quote nor reblog', () => { + const note = new MiNote({ renoteId: null }); + const key = SkLatestNote.keyFor(note); + expect(key.isQuote).toBeFalsy(); + }); + }); +}); diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue index 1d124b4932..31580075ef 100644 --- a/packages/frontend/src/components/SkUserRecentNotes.vue +++ b/packages/frontend/src/components/SkUserRecentNotes.vue @@ -24,16 +24,13 @@ import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { Paging } from '@/components/MkPagination.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; -const props = withDefaults(defineProps<{ +const props = defineProps<{ userId: string; - withRenotes?: boolean; - withReplies?: boolean; - onlyFiles?: boolean; -}>(), { - withRenotes: false, - withReplies: true, - onlyFiles: false, -}); + withNonPublic: boolean; + withQuotes: boolean; + withReplies: boolean; + onlyFiles: boolean; +}>(); const loadError: Ref = ref(null); const user: Ref = ref(null); @@ -43,9 +40,13 @@ const pagination: Paging<'users/notes'> = { limit: 10, params: computed(() => ({ userId: props.userId, - withRenotes: props.withRenotes, + withNonPublic: props.withNonPublic, + withRenotes: false, + withQuotes: props.withQuotes, withReplies: props.withReplies, + withRepliesToSelf: props.withReplies, withFiles: props.onlyFiles, + allowPartial: true, })), }; diff --git a/packages/frontend/src/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue index 9050cd93f8..f460086ff0 100644 --- a/packages/frontend/src/pages/following-feed.vue +++ b/packages/frontend/src/pages/following-feed.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -162,54 +162,58 @@ const latestNotesPagination: Paging<'notes/following'> = { limit: 20, params: computed(() => ({ mutualsOnly: mutualsOnly.value, + filesOnly: onlyFiles.value, + includeNonPublic: withNonPublic.value, + includeReplies: withReplies.value, + includeQuotes: withQuotes.value, })), }; -const withUserRenotes = ref(false); -const withUserReplies = ref(true); -const withOnlyFiles = ref(false); +const withNonPublic = ref(false); +const withQuotes = ref(false); +const withReplies = ref(false); +const onlyFiles = ref(false); -const headerActions = computed(() => { - const actions: PageHeaderItem[] = [ - { - icon: 'ti ti-refresh', - text: i18n.ts.reload, - handler: () => reload(), +const headerActions: PageHeaderItem[] = [ + { + icon: 'ti ti-refresh', + text: i18n.ts.reload, + handler: () => reload(), + }, + { + icon: 'ti ti-dots', + text: i18n.ts.options, + handler: (ev) => { + os.popupMenu([ + { + type: 'switch', + text: i18n.ts.showNonPublicNotes, + ref: withNonPublic, + }, + { + type: 'switch', + text: i18n.ts.showQuotes, + ref: withQuotes, + }, + { + type: 'switch', + text: i18n.ts.showReplies, + ref: withReplies, + disabled: onlyFiles, + }, + { + type: 'divider', + }, + { + type: 'switch', + text: i18n.ts.fileAttachedOnly, + ref: onlyFiles, + disabled: withReplies, + }, + ], ev.currentTarget ?? ev.target); }, - ]; - - if (isWideViewport.value) { - actions.push({ - icon: 'ti ti-dots', - text: i18n.ts.options, - handler: (ev) => { - os.popupMenu([ - { - type: 'switch', - text: i18n.ts.showRenotes, - ref: withUserRenotes, - }, { - type: 'switch', - text: i18n.ts.showRepliesToOthersInTimeline, - ref: withUserReplies, - disabled: withOnlyFiles, - }, - { - type: 'divider', - }, - { - type: 'switch', - text: i18n.ts.fileAttachedOnly, - ref: withOnlyFiles, - disabled: withUserReplies, - }, - ], ev.currentTarget ?? ev.target); - }, - }); - } - - return actions; -}); + }, +]; const headerTabs = computed(() => [ { diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 4bebaf8d9a..cedf0cad7d 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -22296,6 +22296,14 @@ export type operations = { 'application/json': { /** @default false */ mutualsOnly?: boolean; + /** @default false */ + filesOnly?: boolean; + /** @default true */ + includeNonPublic?: boolean; + /** @default false */ + includeReplies?: boolean; + /** @default true */ + includeQuotes?: boolean; /** @default 10 */ limit?: number; /** Format: misskey:id */ @@ -27228,7 +27236,13 @@ export type operations = { /** @default false */ withReplies?: boolean; /** @default true */ + withRepliesToSelf?: boolean; + /** @default true */ + withQuotes?: boolean; + /** @default true */ withRenotes?: boolean; + /** @default true */ + withNonPublic?: boolean; /** @default false */ withChannelNotes?: boolean; /** @default 10 */ From 499e8895c5a4f03c33e910c410a29276e9e05917 Mon Sep 17 00:00:00 2001 From: Hazel K Date: Wed, 9 Oct 2024 15:45:16 -0400 Subject: [PATCH 05/13] save filters for following feed --- .../frontend/src/pages/following-feed.vue | 47 +++++++++++++------ packages/frontend/src/store.ts | 10 ++++ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/frontend/src/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue index f460086ff0..1b3f303dfe 100644 --- a/packages/frontend/src/pages/following-feed.vue +++ b/packages/frontend/src/pages/following-feed.vue @@ -63,20 +63,42 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue'; import { useScrollPositionManager } from '@/nirax.js'; import { getScrollContainer } from '@/scripts/scroll.js'; +import { defaultStore } from '@/store.js'; +import { deepMerge } from '@/scripts/merge.js'; -const props = withDefaults(defineProps<{ - initialTab?: FollowingFeedTab, -}>(), { - initialTab: followingTab, +const withNonPublic = computed({ + get: () => defaultStore.reactiveState.followingFeed.value.withNonPublic, + set: value => saveFollowingFilter('withNonPublic', value), }); +const withQuotes = computed({ + get: () => defaultStore.reactiveState.followingFeed.value.withQuotes, + set: value => saveFollowingFilter('withQuotes', value), +}); +const withReplies = computed({ + get: () => defaultStore.reactiveState.followingFeed.value.withReplies, + set: value => saveFollowingFilter('withReplies', value), +}); +const onlyFiles = computed({ + get: () => defaultStore.reactiveState.followingFeed.value.onlyFiles, + set: value => saveFollowingFilter('onlyFiles', value), +}); +const onlyMutuals = computed({ + get: () => defaultStore.reactiveState.followingFeed.value.onlyMutuals, + set: value => saveFollowingFilter('onlyMutuals', value), +}); + +// Based on timeline.saveTlFilter() +function saveFollowingFilter(key: keyof typeof defaultStore.state.followingFeed, value: boolean) { + const out = deepMerge({ [key]: value }, defaultStore.state.followingFeed); + defaultStore.set('followingFeed', out); +} const router = useRouter(); -// Vue complains, but we *want* to lose reactivity here. -// Otherwise, the user would be unable to change the tab. -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const currentTab: Ref = ref(props.initialTab); -const mutualsOnly: Ref = computed(() => currentTab.value === mutualsTab); +const currentTab = computed({ + get: () => onlyMutuals.value ? mutualsTab : followingTab, + set: value => onlyMutuals.value = (value === mutualsTab), +}); const userRecentNotes = shallowRef>(); const userScroll = shallowRef(); const noteScroll = shallowRef(); @@ -161,7 +183,7 @@ const latestNotesPagination: Paging<'notes/following'> = { endpoint: 'notes/following' as const, limit: 20, params: computed(() => ({ - mutualsOnly: mutualsOnly.value, + mutualsOnly: onlyMutuals.value, filesOnly: onlyFiles.value, includeNonPublic: withNonPublic.value, includeReplies: withReplies.value, @@ -169,11 +191,6 @@ const latestNotesPagination: Paging<'notes/following'> = { })), }; -const withNonPublic = ref(false); -const withQuotes = ref(false); -const withReplies = ref(false); -const onlyFiles = ref(false); - const headerActions: PageHeaderItem[] = [ { icon: 'ti ti-refresh', diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 8665cdaf76..5f78330147 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -239,6 +239,16 @@ export const defaultStore = markRaw(new Storage('base', { where: 'deviceAccount', default: [] as Misskey.entities.UserList[], }, + followingFeed: { + where: 'account', + default: { + withNonPublic: false, + withQuotes: false, + withReplies: false, + onlyFiles: false, + onlyMutuals: false, + }, + }, overridedDeviceKind: { where: 'device', From fb7ac68eceeabfa503eb8566bf5f22b52218b3c4 Mon Sep 17 00:00:00 2001 From: Hazel K Date: Wed, 9 Oct 2024 16:09:03 -0400 Subject: [PATCH 06/13] match following endpoint default values with frontend defaults --- packages/backend/src/server/api/endpoints/notes/following.ts | 4 ++-- packages/misskey-js/src/autogen/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 9606c0f19e..a75a928009 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -34,9 +34,9 @@ export const paramDef = { properties: { mutualsOnly: { type: 'boolean', default: false }, filesOnly: { type: 'boolean', default: false }, - includeNonPublic: { type: 'boolean', default: true }, + includeNonPublic: { type: 'boolean', default: false }, includeReplies: { type: 'boolean', default: false }, - includeQuotes: { type: 'boolean', default: true }, + includeQuotes: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index cedf0cad7d..98f09c047e 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -22298,11 +22298,11 @@ export type operations = { mutualsOnly?: boolean; /** @default false */ filesOnly?: boolean; - /** @default true */ + /** @default false */ includeNonPublic?: boolean; /** @default false */ includeReplies?: boolean; - /** @default true */ + /** @default false */ includeQuotes?: boolean; /** @default 10 */ limit?: number; From 158cd3649d41b4b5fc34a8adee137d5fa1a918f4 Mon Sep 17 00:00:00 2001 From: Hazel K Date: Thu, 10 Oct 2024 09:43:50 -0400 Subject: [PATCH 07/13] docs: add post-upgrade query to backfill following feed --- IMPORTANT_NOTES.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/IMPORTANT_NOTES.md b/IMPORTANT_NOTES.md index 54d0440bea..da9cc8fd26 100644 --- a/IMPORTANT_NOTES.md +++ b/IMPORTANT_NOTES.md @@ -14,3 +14,45 @@ Any data uploaded, whether shared via post or not, will be publicly accessible. 5. Please disable ad blockers. Some servers may rely on advertising revenue to cover operating costs. Additionally, ad blockers can mistakenly block content and features unrelated to ads, potentially causing issues with the client's functionality and preventing normal use of Sharkey. Therefore, we recommend turning off ad blockers and similar features when using Sharkey. Please understand these points and enjoy using the service. + +# Upgrade Notes + +## 2024.9.0 + +### Following Feed + +When upgrading an existing instance to version 2024.9.0, the Following Feed will initially be empty. +The feed will gradually fill as new posts federate, but it may be desirable to back-fill the feed with existing data. +This database script will populate the feed with the latest post of each type for all users, ensuring that data is fully populated after the update. +Run this after migrations but before starting the instance. +Warning: the script may take a long time to execute! + +```postgresql +INSERT INTO latest_note (user_id, note_id, is_public, is_reply, is_quote) +SELECT + "userId" as user_id, + id as note_id, + visibility = 'public' AS is_public, + "replyId" IS NOT NULL AS is_reply, + ( + "renoteId" IS NOT NULL + AND ( + text IS NOT NULL + OR cw IS NOT NULL + OR "replyId" IS NOT NULL + OR "hasPoll" + OR "fileIds" != '{}' + ) + ) AS is_quote +FROM note +WHERE ( -- Exclude pure renotes (boosts) + "renoteId" IS NULL + OR text IS NOT NULL + OR cw IS NOT NULL + OR "replyId" IS NOT NULL + OR "hasPoll" + OR "fileIds" != '{}' + ) +ORDER BY id DESC -- This part is very important: it ensures that we only load the *latest* notes of each type. Do not remove it! +ON CONFLICT DO NOTHING; -- Any conflicts are guaranteed to be older notes that we can ignore. +``` From e3c79b0c83837f65185789a3ae17a0d94aee64a5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 11 Oct 2024 09:41:49 -0400 Subject: [PATCH 08/13] fix typos in track-latest-note-type migration --- .../migration/1728420772835-track-latest-note-type.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/migration/1728420772835-track-latest-note-type.js b/packages/backend/migration/1728420772835-track-latest-note-type.js index 8f8198707e..4c9b4ca594 100644 --- a/packages/backend/migration/1728420772835-track-latest-note-type.js +++ b/packages/backend/migration/1728420772835-track-latest-note-type.js @@ -8,9 +8,9 @@ export class TrackLatestNoteType1728420772835 { async up(queryRunner) { await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a"`); - await queryRunner.query(`ALTER TABLE "latest_note" ADD "isPublic" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "latest_note" ADD "isReply" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "latest_note" ADD "isQuote" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_public" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_reply" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_quote" boolean NOT NULL DEFAULT false`); await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", is_public, is_reply, is_quote)`); } From 24fd35e03dcfa9062f417c19f77fa14aa0b768d1 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Fri, 11 Oct 2024 09:44:12 -0400 Subject: [PATCH 09/13] revert accidental change to postgres.ts --- packages/backend/src/postgres.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index eaa0eac57c..2d66e6e445 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -92,8 +92,6 @@ export const dbLogger = new MisskeyLogger('db'); const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); class MyCustomLogger implements Logger { - private readonly isDevelopment = process.env.NODE_ENV === 'development'; - @bindThis private highlight(sql: string) { return highlight.highlight(sql, { @@ -103,13 +101,7 @@ class MyCustomLogger implements Logger { @bindThis public logQuery(query: string, parameters?: any[]) { - let message = this.highlight(query); - - if (!this.isDevelopment) { - message = message.substring(0, 100); - } - - sqlLogger.info(message); + sqlLogger.info(this.highlight(query).substring(0, 100)); } @bindThis From 9b1bae653d96fd276bbebde7c9dab3adf6236a77 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 12 Oct 2024 14:56:04 -0400 Subject: [PATCH 10/13] add "show bots" toggle to following feed --- .../server/api/endpoints/notes/following.ts | 6 ++++++ .../src/server/api/endpoints/users/notes.ts | 9 ++++++++ .../src/components/SkUserRecentNotes.vue | 1 + .../frontend/src/pages/following-feed.vue | 21 ++++++++++++------- packages/frontend/src/store.ts | 1 + packages/misskey-js/src/autogen/types.ts | 4 ++++ 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index a75a928009..83e8f404e9 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -37,6 +37,7 @@ export const paramDef = { includeNonPublic: { type: 'boolean', default: false }, includeReplies: { type: 'boolean', default: false }, includeQuotes: { type: 'boolean', default: false }, + includeBots: { type: 'boolean', default: true }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, @@ -97,6 +98,11 @@ export default class extends Endpoint { // eslint- query.andWhere('latest.is_quote = false'); } + // Match selected user types. + if (!ps.includeBots) { + query.andWhere('"user"."isBot" = false'); + } + // Respect blocks and mutes this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 884760a88f..efea15ca80 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -55,6 +55,7 @@ export const paramDef = { withRepliesToSelf: { type: 'boolean', default: true }, withQuotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true }, + withBots: { type: 'boolean', default: true }, withNonPublic: { type: 'boolean', default: true }, withChannelNotes: { type: 'boolean', default: false }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, @@ -108,6 +109,7 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withRenotes: ps.withRenotes, withQuotes: ps.withQuotes, + withBots: ps.withBots, withNonPublic: ps.withNonPublic, withRepliesToOthers: ps.withReplies, withRepliesToSelf: ps.withRepliesToSelf, @@ -135,6 +137,7 @@ export default class extends Endpoint { // eslint- excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludePureRenotes: !ps.withRenotes, + excludeBots: !ps.withBots, noteFilter: note => { if (note.channel?.isSensitive && !isSelf) return false; if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; @@ -156,6 +159,7 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withRenotes: ps.withRenotes, withQuotes: ps.withQuotes, + withBots: ps.withBots, withNonPublic: ps.withNonPublic, withRepliesToOthers: ps.withReplies, withRepliesToSelf: ps.withRepliesToSelf, @@ -175,6 +179,7 @@ export default class extends Endpoint { // eslint- withFiles: boolean, withRenotes: boolean, withQuotes: boolean, + withBots: boolean, withNonPublic: boolean, withRepliesToOthers: boolean, withRepliesToSelf: boolean, @@ -246,6 +251,10 @@ export default class extends Endpoint { // eslint- query.andWhere('note.visibility = \'public\''); } + if (!ps.withBots) { + query.andWhere('"user"."isBot" = false'); + } + return await query.limit(ps.limit).getMany(); } } diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue index 31580075ef..2cdb4b6586 100644 --- a/packages/frontend/src/components/SkUserRecentNotes.vue +++ b/packages/frontend/src/components/SkUserRecentNotes.vue @@ -29,6 +29,7 @@ const props = defineProps<{ withNonPublic: boolean; withQuotes: boolean; withReplies: boolean; + withBots: boolean; onlyFiles: boolean; }>(); diff --git a/packages/frontend/src/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue index 1b3f303dfe..7b90b563e6 100644 --- a/packages/frontend/src/pages/following-feed.vue +++ b/packages/frontend/src/pages/following-feed.vue @@ -30,18 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
- -