merge: Add filter options to following feed (resolves #726) (!671)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/671

Closes #726

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2024-10-15 21:50:26 +00:00
commit de9b99c937
20 changed files with 478 additions and 104 deletions

41
UPGRADE_NOTES.md Normal file
View file

@ -0,0 +1,41 @@
# 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.
```

View file

@ -1263,6 +1263,9 @@ authentication: "Authentication"
authenticationRequiredToContinue: "Please authenticate to continue" authenticationRequiredToContinue: "Please authenticate to continue"
dateAndTime: "Timestamp" dateAndTime: "Timestamp"
showRenotes: "Show boosts" showRenotes: "Show boosts"
showQuotes: "Show quotes"
showReplies: "Show replies"
showNonPublicNotes: "Show non-public"
edited: "Edited" edited: "Edited"
notificationRecieveConfig: "Notification Settings" notificationRecieveConfig: "Notification Settings"
mutualFollow: "Mutual follow" mutualFollow: "Mutual follow"

12
locales/index.d.ts vendored
View file

@ -5065,6 +5065,18 @@ export interface Locale extends ILocale {
* *
*/ */
"showRenotes": string; "showRenotes": string;
/**
* Show quotes
*/
"showQuotes": string;
/**
* Show replies
*/
"showReplies": string;
/**
* Show non-public
*/
"showNonPublicNotes": string;
/** /**
* *
*/ */

View file

@ -1262,6 +1262,9 @@ authentication: "認証"
authenticationRequiredToContinue: "続けるには認証を行ってください" authenticationRequiredToContinue: "続けるには認証を行ってください"
dateAndTime: "日時" dateAndTime: "日時"
showRenotes: "ブーストを表示" showRenotes: "ブーストを表示"
showQuotes: "Show quotes"
showReplies: "Show replies"
showNonPublicNotes: "Show non-public"
edited: "編集済み" edited: "編集済み"
notificationRecieveConfig: "通知の受信設定" notificationRecieveConfig: "通知の受信設定"
mutualFollow: "相互フォロー" mutualFollow: "相互フォロー"

View file

@ -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 "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)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"`);
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")`);
}
}

View file

@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } 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 { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js'; import type { MiApp } from '@/models/App.js';
@ -63,7 +63,7 @@ import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.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'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -1151,18 +1151,21 @@ export class NoteCreateService implements OnApplicationShutdown {
if (note.visibility === 'specified') return; if (note.visibility === 'specified') return;
// Ignore pure renotes // 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. // Make sure that this isn't an *older* post.
// We can get older posts through replies, lookups, etc. // 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; if (currentLatest != null && currentLatest.noteId >= note.id) return;
// Record this as the latest note for the given user // Record this as the latest note for the given user
const latestNote = new LatestNote({ const latestNote = new SkLatestNote({
userId: note.userId, ...key,
noteId: note.id, noteId: note.id,
}); });
await this.latestNotesRepository.upsert(latestNote, ['userId']); await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
} }
} }

View file

@ -6,8 +6,8 @@
import { Brackets, In, Not } from 'typeorm'; import { Brackets, In, Not } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; 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 { LatestNote } from '@/models/LatestNote.js'; import { SkLatestNote } from '@/models/LatestNote.js';
import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { RelayService } from '@/core/RelayService.js'; import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -25,7 +25,7 @@ import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.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() @Injectable()
export class NoteDeleteService { 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 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 (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 // 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; if (hasLatestNote) return;
// Find the newest remaining note for the user. // Find the newest remaining note for the user.
@ -250,8 +256,16 @@ export class NoteDeleteService {
.createQueryBuilder('note') .createQueryBuilder('note')
.select() .select()
.where({ .where({
userId: note.userId, userId: key.userId,
visibility: Not('specified'), visibility: key.isPublic
? 'public'
: Not('specified'),
replyId: key.isReply
? Not(null)
: null,
renoteId: key.isQuote
? Not(null)
: null,
}) })
.andWhere(` .andWhere(`
( (
@ -268,8 +282,8 @@ export class NoteDeleteService {
if (!nextLatest) return; if (!nextLatest) return;
// Record it as the latest // Record it as the latest
const latestNote = new LatestNote({ const latestNote = new SkLatestNote({
userId: note.userId, ...key,
noteId: nextLatest.id, noteId: nextLatest.id,
}); });
@ -278,7 +292,7 @@ export class NoteDeleteService {
await this.latestNotesRepository await this.latestNotesRepository
.createQueryBuilder('latest') .createQueryBuilder('latest')
.insert() .insert()
.into(LatestNote) .into(SkLatestNote)
.values(latestNote) .values(latestNote)
.orIgnore() .orIgnore()
.execute(); .execute();

View file

@ -23,6 +23,17 @@ type Quote =
hasPoll: true hasPoll: true
}); });
type PureRenote =
Renote & {
text: null,
cw: null,
replyId: null,
hasPoll: false,
fileIds: {
length: 0,
},
};
export function isRenote(note: MiNote): note is Renote { export function isRenote(note: MiNote): note is Renote {
return note.renoteId != null; return note.renoteId != null;
} }
@ -36,6 +47,10 @@ export function isQuote(note: Renote): note is Quote {
note.fileIds.length > 0; note.fileIds.length > 0;
} }
export function isPureRenote(note: MiNote): note is PureRenote {
return isRenote(note) && !isQuote(note);
}
type PackedRenote = type PackedRenote =
Packed<'Note'> & { Packed<'Note'> & {
renoteId: NonNullable<Packed<'Note'>['renoteId']> renoteId: NonNullable<Packed<'Note'>['renoteId']>

View file

@ -6,6 +6,7 @@
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm'; import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm';
import { MiUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js';
import { MiNote } from '@/models/Note.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. * Maps a user to the most recent post by that user.
@ -13,7 +14,7 @@ import { MiNote } from '@/models/Note.js';
* DMs are not counted. * DMs are not counted.
*/ */
@Entity('latest_note') @Entity('latest_note')
export class LatestNote { export class SkLatestNote {
@PrimaryColumn({ @PrimaryColumn({
name: 'user_id', name: 'user_id',
type: 'varchar' as const, type: 'varchar' as const,
@ -21,6 +22,24 @@ export class LatestNote {
}) })
public userId: string; 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, { @ManyToOne(() => MiUser, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@ -44,11 +63,23 @@ export class LatestNote {
}) })
public note: MiNote | null; public note: MiNote | null;
constructor(data?: Partial<LatestNote>) { constructor(data?: Partial<SkLatestNote>) {
if (!data) return; if (!data) return;
for (const [k, v] of Object.entries(data)) { for (const [k, v] of Object.entries(data)) {
(this as Record<string, unknown>)[k] = v; (this as Record<string, unknown>)[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),
};
}
} }

View file

@ -7,7 +7,7 @@ import type { Provider } from '@nestjs/common';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { import {
LatestNote, SkLatestNote,
MiAbuseReportNotificationRecipient, MiAbuseReportNotificationRecipient,
MiAbuseUserReport, MiAbuseUserReport,
MiAccessToken, MiAccessToken,
@ -121,7 +121,7 @@ const $avatarDecorationsRepository: Provider = {
const $latestNotesRepository: Provider = { const $latestNotesRepository: Provider = {
provide: DI.latestNotesRepository, provide: DI.latestNotesRepository,
useFactory: (db: DataSource) => db.getRepository(LatestNote).extend(miRepository as MiRepository<LatestNote>), useFactory: (db: DataSource) => db.getRepository(SkLatestNote).extend(miRepository as MiRepository<SkLatestNote>),
inject: [DI.db], inject: [DI.db],
}; };

View file

@ -10,7 +10,7 @@ import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLo
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
import { OrmUtils } from 'typeorm/util/OrmUtils.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 { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAccessToken } from '@/models/AccessToken.js';
@ -127,7 +127,7 @@ export const miRepository = {
} satisfies MiRepository<ObjectLiteral>; } satisfies MiRepository<ObjectLiteral>;
export { export {
LatestNote, SkLatestNote,
MiAbuseUserReport, MiAbuseUserReport,
MiAbuseReportNotificationRecipient, MiAbuseReportNotificationRecipient,
MiAccessToken, MiAccessToken,
@ -226,7 +226,7 @@ export type GalleryPostsRepository = Repository<MiGalleryPost> & MiRepository<Mi
export type HashtagsRepository = Repository<MiHashtag> & MiRepository<MiHashtag>; export type HashtagsRepository = Repository<MiHashtag> & MiRepository<MiHashtag>;
export type InstancesRepository = Repository<MiInstance> & MiRepository<MiInstance>; export type InstancesRepository = Repository<MiInstance> & MiRepository<MiInstance>;
export type MetasRepository = Repository<MiMeta> & MiRepository<MiMeta>; export type MetasRepository = Repository<MiMeta> & MiRepository<MiMeta>;
export type LatestNotesRepository = Repository<LatestNote> & MiRepository<LatestNote>; export type LatestNotesRepository = Repository<SkLatestNote> & MiRepository<SkLatestNote>;
export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepository<MiModerationLog>; export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepository<MiModerationLog>;
export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>; export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>;
export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>; export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>;

View file

@ -83,7 +83,7 @@ import { MiReversiGame } from '@/models/ReversiGame.js';
import { Config } from '@/config.js'; import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js'; import MisskeyLogger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { LatestNote } from '@/models/LatestNote.js'; import { SkLatestNote } from '@/models/LatestNote.js';
pg.types.setTypeParser(20, Number); pg.types.setTypeParser(20, Number);
@ -131,7 +131,7 @@ class MyCustomLogger implements Logger {
} }
export const entities = [ export const entities = [
LatestNote, SkLatestNote,
MiAnnouncement, MiAnnouncement,
MiAnnouncementRead, MiAnnouncementRead,
MiMeta, MiMeta,

View file

@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; 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 type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@ -33,6 +33,12 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
mutualsOnly: { type: 'boolean', default: false }, mutualsOnly: { type: 'boolean', default: false },
filesOnly: { type: 'boolean', default: false },
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 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
@ -52,12 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
let query = this.notesRepository const query = this.notesRepository
.createQueryBuilder('note') .createQueryBuilder('note')
.setParameter('me', me.id) .setParameter('me', me.id)
// Limit to latest notes // 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 // Avoid N+1 queries from the "pack" method
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
@ -73,8 +79,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Limit to mutuals, if requested // Limit to mutuals, if requested
if (ps.mutualsOnly) { if (ps.mutualsOnly) {
query = query query.innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me');
.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');
}
// Match selected user types.
if (!ps.includeBots) {
query.andWhere('"user"."isBot" = false');
} }
// Respect blocks and mutes // Respect blocks and mutes
@ -82,7 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedUserQuery(query, me);
// Support pagination // Support pagination
query = this.queryService this.queryService
.makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.orderBy('note.id', 'DESC') .orderBy('note.id', 'DESC')
.take(ps.limit); .take(ps.limit);

View file

@ -17,6 +17,7 @@ import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
import { ApiError } from '@/server/api/error.js'; import { ApiError } from '@/server/api/error.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
export const meta = { export const meta = {
tags: ['users', 'notes'], tags: ['users', 'notes'],
@ -51,7 +52,11 @@ export const paramDef = {
properties: { properties: {
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
withReplies: { type: 'boolean', default: false }, withReplies: { type: 'boolean', default: false },
withRepliesToSelf: { type: 'boolean', default: true },
withQuotes: { type: 'boolean', default: true },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withBots: { type: 'boolean', default: true },
withNonPublic: { type: 'boolean', default: true },
withChannelNotes: { type: 'boolean', default: false }, withChannelNotes: { 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' },
@ -103,6 +108,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withChannelNotes: ps.withChannelNotes, withChannelNotes: ps.withChannelNotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
withQuotes: ps.withQuotes,
withBots: ps.withBots,
withNonPublic: ps.withNonPublic,
withRepliesToOthers: ps.withReplies,
withRepliesToSelf: ps.withRepliesToSelf,
}, me); }, me);
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
@ -127,11 +137,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
excludePureRenotes: !ps.withRenotes, excludePureRenotes: !ps.withRenotes,
excludeBots: !ps.withBots,
noteFilter: note => { noteFilter: note => {
if (note.channel?.isSensitive && !isSelf) return false; 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; 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; 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; return true;
}, },
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
@ -142,6 +158,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withChannelNotes: ps.withChannelNotes, withChannelNotes: ps.withChannelNotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
withQuotes: ps.withQuotes,
withBots: ps.withBots,
withNonPublic: ps.withNonPublic,
withRepliesToOthers: ps.withReplies,
withRepliesToSelf: ps.withRepliesToSelf,
}, me), }, me),
}); });
@ -157,6 +178,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withChannelNotes: boolean, withChannelNotes: boolean,
withFiles: boolean, withFiles: boolean,
withRenotes: boolean, withRenotes: boolean,
withQuotes: boolean,
withBots: boolean,
withNonPublic: boolean,
withRepliesToOthers: boolean,
withRepliesToSelf: boolean,
}, me: MiLocalUser | null) { }, me: MiLocalUser | null) {
const isSelf = me && (me.id === ps.userId); const isSelf = me && (me.id === ps.userId);
@ -188,7 +214,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('note.fileIds != \'{}\''); 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 => { query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :userId', { userId: ps.userId }); qb.orWhere('note.userId != :userId', { userId: ps.userId });
qb.orWhere('note.renoteId IS NULL'); qb.orWhere('note.renoteId IS NULL');
@ -196,6 +224,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
qb.orWhere('note.fileIds != \'{}\''); qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); 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\'');
}
if (!ps.withBots) {
query.andWhere('"user"."isBot" = false');
} }
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * 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'; import { MiNote } from '@/models/Note.js';
const base: MiNote = { const base: MiNote = {
@ -86,4 +86,24 @@ describe('misc:is-renote', () => {
expect(isRenote(note)).toBe(true); expect(isRenote(note)).toBe(true);
expect(isQuote(note as any)).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', fileIds: [] });
const result = isPureRenote(note);
expect(result).toBeTruthy();
});
it('should return false when note is quote', () => {
const note = new MiNote({ renoteId: 'abc123', text: 'text', fileIds: [] });
const result = isPureRenote(note);
expect(result).toBeFalsy();
});
it('should return false when note is not renote', () => {
const note = new MiNote({ renoteId: null, fileIds: [] });
const result = isPureRenote(note);
expect(result).toBeFalsy();
});
});
}); });

View file

@ -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', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.userId).toBe(note.userId);
});
it('should include isPublic when is public', () => {
const note = new MiNote({ visibility: 'public', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isPublic).toBeTruthy();
});
it('should include isPublic when is home-only', () => {
const note = new MiNote({ visibility: 'home', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isPublic).toBeFalsy();
});
it('should include isPublic when is followers-only', () => {
const note = new MiNote({ visibility: 'followers', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isPublic).toBeFalsy();
});
it('should include isPublic when is specified', () => {
const note = new MiNote({ visibility: 'specified', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isPublic).toBeFalsy();
});
it('should include isReply when is reply', () => {
const note = new MiNote({ replyId: 'abc123', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isReply).toBeTruthy();
});
it('should include isReply when is not reply', () => {
const note = new MiNote({ replyId: null, fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isReply).toBeFalsy();
});
it('should include isQuote when is quote', () => {
const note = new MiNote({ renoteId: 'abc123', text: 'text', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isQuote).toBeTruthy();
});
it('should include isQuote when is reblog', () => {
const note = new MiNote({ renoteId: 'abc123', fileIds: [] });
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, fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isQuote).toBeFalsy();
});
});
});

View file

@ -24,16 +24,14 @@ import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { Paging } from '@/components/MkPagination.vue'; import { Paging } from '@/components/MkPagination.vue';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
const props = withDefaults(defineProps<{ const props = defineProps<{
userId: string; userId: string;
withRenotes?: boolean; withNonPublic: boolean;
withReplies?: boolean; withQuotes: boolean;
onlyFiles?: boolean; withReplies: boolean;
}>(), { withBots: boolean;
withRenotes: false, onlyFiles: boolean;
withReplies: true, }>();
onlyFiles: false,
});
const loadError: Ref<string | null> = ref(null); const loadError: Ref<string | null> = ref(null);
const user: Ref<Misskey.entities.UserDetailed | null> = ref(null); const user: Ref<Misskey.entities.UserDetailed | null> = ref(null);
@ -43,9 +41,13 @@ const pagination: Paging<'users/notes'> = {
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
userId: props.userId, userId: props.userId,
withRenotes: props.withRenotes, withNonPublic: props.withNonPublic,
withRenotes: false,
withQuotes: props.withQuotes,
withReplies: props.withReplies, withReplies: props.withReplies,
withRepliesToSelf: props.withReplies,
withFiles: props.onlyFiles, withFiles: props.onlyFiles,
allowPartial: true,
})), })),
}; };

View file

@ -30,18 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="isWideViewport" ref="userScroll" :class="$style.user"> <div v-if="isWideViewport" ref="userScroll" :class="$style.user">
<MkHorizontalSwipe v-if="selectedUserId" v-model:tab="currentTab" :tabs="headerTabs"> <MkHorizontalSwipe v-if="selectedUserId" v-model:tab="currentTab" :tabs="headerTabs">
<SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withRenotes="withUserRenotes" :withReplies="withUserReplies" :onlyFiles="withOnlyFiles"/> <SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/>
</MkHorizontalSwipe> </MkHorizontalSwipe>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts">
export type FollowingFeedTab = typeof followingTab | typeof mutualsTab;
export const followingTab = 'following' as const;
export const mutualsTab = 'mutuals' as const;
</script>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, Ref, ref, shallowRef } from 'vue'; import { computed, Ref, ref, shallowRef } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
@ -63,20 +57,49 @@ import { checkWordMute } from '@/scripts/check-word-mute.js';
import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue'; import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue';
import { useScrollPositionManager } from '@/nirax.js'; import { useScrollPositionManager } from '@/nirax.js';
import { getScrollContainer } from '@/scripts/scroll.js'; import { getScrollContainer } from '@/scripts/scroll.js';
import { defaultStore } from '@/store.js';
import { deepMerge } from '@/scripts/merge.js';
const props = withDefaults(defineProps<{ const withNonPublic = computed({
initialTab?: FollowingFeedTab, get: () => defaultStore.reactiveState.followingFeed.value.withNonPublic,
}>(), { set: value => saveFollowingFilter('withNonPublic', value),
initialTab: followingTab,
}); });
const withQuotes = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withQuotes,
set: value => saveFollowingFilter('withQuotes', value),
});
const withBots = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withBots,
set: value => saveFollowingFilter('withBots', 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(); const router = useRouter();
// Vue complains, but we *want* to lose reactivity here. const followingTab = 'following' as const;
// Otherwise, the user would be unable to change the tab. const mutualsTab = 'mutuals' as const;
// eslint-disable-next-line vue/no-setup-props-reactivity-loss const currentTab = computed({
const currentTab: Ref<FollowingFeedTab> = ref(props.initialTab); get: () => onlyMutuals.value ? mutualsTab : followingTab,
const mutualsOnly: Ref<boolean> = computed(() => currentTab.value === mutualsTab); set: value => onlyMutuals.value = (value === mutualsTab),
});
const userRecentNotes = shallowRef<InstanceType<typeof SkUserRecentNotes>>(); const userRecentNotes = shallowRef<InstanceType<typeof SkUserRecentNotes>>();
const userScroll = shallowRef<HTMLElement>(); const userScroll = shallowRef<HTMLElement>();
const noteScroll = shallowRef<HTMLElement>(); const noteScroll = shallowRef<HTMLElement>();
@ -161,38 +184,46 @@ const latestNotesPagination: Paging<'notes/following'> = {
endpoint: 'notes/following' as const, endpoint: 'notes/following' as const,
limit: 20, limit: 20,
params: computed(() => ({ params: computed(() => ({
mutualsOnly: mutualsOnly.value, mutualsOnly: onlyMutuals.value,
filesOnly: onlyFiles.value,
includeNonPublic: withNonPublic.value,
includeReplies: withReplies.value,
includeQuotes: withQuotes.value,
includeBots: withBots.value,
})), })),
}; };
const withUserRenotes = ref(false); const headerActions: PageHeaderItem[] = [
const withUserReplies = ref(true);
const withOnlyFiles = ref(false);
const headerActions = computed(() => {
const actions: PageHeaderItem[] = [
{ {
icon: 'ti ti-refresh', icon: 'ti ti-refresh',
text: i18n.ts.reload, text: i18n.ts.reload,
handler: () => reload(), handler: () => reload(),
}, },
]; {
if (isWideViewport.value) {
actions.push({
icon: 'ti ti-dots', icon: 'ti ti-dots',
text: i18n.ts.options, text: i18n.ts.options,
handler: (ev) => { handler: (ev) => {
os.popupMenu([ os.popupMenu([
{ {
type: 'switch', type: 'switch',
text: i18n.ts.showRenotes, text: i18n.ts.showNonPublicNotes,
ref: withUserRenotes, ref: withNonPublic,
}, { },
{
type: 'switch', type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline, text: i18n.ts.showQuotes,
ref: withUserReplies, ref: withQuotes,
disabled: withOnlyFiles, },
{
type: 'switch',
text: i18n.ts.showBots,
ref: withBots,
},
{
type: 'switch',
text: i18n.ts.showReplies,
ref: withReplies,
disabled: onlyFiles,
}, },
{ {
type: 'divider', type: 'divider',
@ -200,16 +231,13 @@ const headerActions = computed(() => {
{ {
type: 'switch', type: 'switch',
text: i18n.ts.fileAttachedOnly, text: i18n.ts.fileAttachedOnly,
ref: withOnlyFiles, ref: onlyFiles,
disabled: withUserReplies, disabled: withReplies,
}, },
], ev.currentTarget ?? ev.target); ], ev.currentTarget ?? ev.target);
}, },
}); },
} ];
return actions;
});
const headerTabs = computed(() => [ const headerTabs = computed(() => [
{ {

View file

@ -239,6 +239,17 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'deviceAccount', where: 'deviceAccount',
default: [] as Misskey.entities.UserList[], default: [] as Misskey.entities.UserList[],
}, },
followingFeed: {
where: 'account',
default: {
withNonPublic: false,
withQuotes: false,
withBots: true,
withReplies: false,
onlyFiles: false,
onlyMutuals: false,
},
},
overridedDeviceKind: { overridedDeviceKind: {
where: 'device', where: 'device',

View file

@ -22296,6 +22296,16 @@ export type operations = {
'application/json': { 'application/json': {
/** @default false */ /** @default false */
mutualsOnly?: boolean; mutualsOnly?: boolean;
/** @default false */
filesOnly?: boolean;
/** @default false */
includeNonPublic?: boolean;
/** @default false */
includeReplies?: boolean;
/** @default false */
includeQuotes?: boolean;
/** @default true */
includeBots?: boolean;
/** @default 10 */ /** @default 10 */
limit?: number; limit?: number;
/** Format: misskey:id */ /** Format: misskey:id */
@ -27228,7 +27238,15 @@ export type operations = {
/** @default false */ /** @default false */
withReplies?: boolean; withReplies?: boolean;
/** @default true */ /** @default true */
withRepliesToSelf?: boolean;
/** @default true */
withQuotes?: boolean;
/** @default true */
withRenotes?: boolean; withRenotes?: boolean;
/** @default true */
withBots?: boolean;
/** @default true */
withNonPublic?: boolean;
/** @default false */ /** @default false */
withChannelNotes?: boolean; withChannelNotes?: boolean;
/** @default 10 */ /** @default 10 */