add filters for following feed

This commit is contained in:
Hazel K 2024-10-09 15:09:55 -04:00 committed by Hazelnoot
parent 56e7d7e0b1
commit 463b9ac59d
16 changed files with 318 additions and 72 deletions

View file

@ -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")`);
}
}

View file

@ -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']);
}
}

View file

@ -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,
});

View file

@ -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<Packed<'Note'>['renoteId']>

View file

@ -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<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

@ -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

View file

@ -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<typeof meta, typeof paramDef> { // 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);

View file

@ -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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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();

View file

@ -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();
});
});
});

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' });
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();
});
});
});