From 89ab8903313b66df802892274e2e12b977c8865d Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Thu, 19 Oct 2023 18:54:23 +0200 Subject: [PATCH] [backend] [client] Add option to hide user lists from home timeline --- locales/en-US.yml | 1 + .../1697733603329-user-list-options.ts | 15 ++++++++ .../backend/src/models/entities/user-list.ts | 6 +++ .../src/models/repositories/user-list.ts | 1 + .../backend/src/models/schema/user-list.ts | 5 +++ .../server/api/common/generate-list-query.ts | 24 ++++++++++++ .../api/endpoints/notes/hybrid-timeline.ts | 2 + .../server/api/endpoints/notes/timeline.ts | 2 + .../server/api/endpoints/users/lists/pull.ts | 8 ++-- .../api/endpoints/users/lists/update.ts | 23 +++++++++--- .../src/server/api/mastodon/endpoints/list.ts | 3 +- .../src/server/api/mastodon/entities/list.ts | 1 + .../src/server/api/mastodon/helpers/list.ts | 37 +++++++++++++------ .../server/api/mastodon/helpers/timeline.ts | 2 + .../server/api/mastodon/streaming/channel.ts | 4 ++ .../api/mastodon/streaming/channels/user.ts | 4 +- .../server/api/mastodon/streaming/index.ts | 21 ++++++++++- .../backend/src/server/api/stream/channel.ts | 4 ++ .../api/stream/channels/home-timeline.ts | 2 + .../api/stream/channels/hybrid-timeline.ts | 2 + .../backend/src/server/api/stream/index.ts | 25 ++++++++++++- .../backend/src/server/api/stream/types.ts | 2 + .../backend/src/services/user-list/pull.ts | 12 ++++++ .../backend/src/services/user-list/push.ts | 9 +++-- packages/client/src/pages/my-lists/list.vue | 17 +++++++++ 25 files changed, 202 insertions(+), 30 deletions(-) create mode 100644 packages/backend/src/migration/1697733603329-user-list-options.ts create mode 100644 packages/backend/src/server/api/common/generate-list-query.ts create mode 100644 packages/backend/src/services/user-list/pull.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index c9a5c29e9..e56e7b4a2 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -2135,3 +2135,4 @@ _cwStyle: classic: "Classic (Misskey/Foundkey-like)" alternative: "Alternative (Firefish-like)" alwaysExpandCws: "Always expand posts with content warnings" +hideFromHome: "Hide from home timeline" \ No newline at end of file diff --git a/packages/backend/src/migration/1697733603329-user-list-options.ts b/packages/backend/src/migration/1697733603329-user-list-options.ts new file mode 100644 index 000000000..ecc2f1c14 --- /dev/null +++ b/packages/backend/src/migration/1697733603329-user-list-options.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserListOptions1697733603329 implements MigrationInterface { + name = 'UserListOptions1697733603329' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_list" ADD "hideFromHomeTl" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "user_list"."hideFromHomeTl" IS 'Whether posts from list members should be hidden from the home timeline.'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "user_list"."hideFromHomeTl" IS 'Whether posts from list members should be hidden from the home timeline.'`); + await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "hideFromHomeTl"`); + } +} diff --git a/packages/backend/src/models/entities/user-list.ts b/packages/backend/src/models/entities/user-list.ts index 3c95d44d6..1aba6d158 100644 --- a/packages/backend/src/models/entities/user-list.ts +++ b/packages/backend/src/models/entities/user-list.ts @@ -37,4 +37,10 @@ export class UserList { comment: "The name of the UserList.", }) public name: string; + + @Column("boolean", { + default: false, + comment: "Whether posts from list members should be hidden from the home timeline." + }) + public hideFromHomeTl: boolean; } diff --git a/packages/backend/src/models/repositories/user-list.ts b/packages/backend/src/models/repositories/user-list.ts index e3abeac3f..8454fa332 100644 --- a/packages/backend/src/models/repositories/user-list.ts +++ b/packages/backend/src/models/repositories/user-list.ts @@ -16,6 +16,7 @@ export const UserListRepository = db.getRepository(UserList).extend({ id: userList.id, createdAt: userList.createdAt.toISOString(), name: userList.name, + hideFromHomeTl: userList.hideFromHomeTl, userIds: users.map((x) => x.userId), }; }, diff --git a/packages/backend/src/models/schema/user-list.ts b/packages/backend/src/models/schema/user-list.ts index 1e203b63a..3ac37963a 100644 --- a/packages/backend/src/models/schema/user-list.ts +++ b/packages/backend/src/models/schema/user-list.ts @@ -19,6 +19,11 @@ export const packedUserListSchema = { optional: false, nullable: false, }, + hideFromHomeTl: { + type: "boolean", + optional: false, + nullable: false, + }, userIds: { type: "array", nullable: false, diff --git a/packages/backend/src/server/api/common/generate-list-query.ts b/packages/backend/src/server/api/common/generate-list-query.ts new file mode 100644 index 000000000..47bb70927 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-list-query.ts @@ -0,0 +1,24 @@ +import { Brackets, SelectQueryBuilder } from "typeorm"; +import { User } from "@/models/entities/user.js"; +import { UserListJoinings, UserLists } from "@/models/index.js"; + +export function generateListQuery( + q: SelectQueryBuilder, + me: { id: User["id"] }, +): void { + const listQuery = UserLists.createQueryBuilder("list") + .select("list.id") + .where("list.hideFromHomeTl = TRUE") + .andWhere("list.userId = :meId"); + + const memberQuery = UserListJoinings.createQueryBuilder("member") + .select("member.userId") + .where(`member.userListId IN (${listQuery.getQuery()})`) + + q.andWhere(new Brackets((qb) => { + qb.where(`note.userId = :meId`); + qb.orWhere(`note.userId NOT IN (${memberQuery.getQuery()})`); + })); + + q.setParameters({ meId: me.id }); +} diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 4e32b0ab2..909b11154 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -12,6 +12,7 @@ import { generateMutedNoteQuery } from "../../common/generate-muted-note-query.j import { generateChannelQuery } from "../../common/generate-channel-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; +import { generateListQuery } from "@/server/api/common/generate-list-query.js"; export const meta = { tags: ["notes"], @@ -108,6 +109,7 @@ export default define(meta, paramDef, async (ps, user) => { .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner") .setParameters(followingQuery.getParameters()); + generateListQuery(query, user); generateChannelQuery(query, user); generateRepliesQuery(query, ps.withReplies, user); generateVisibilityQuery(query, user); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index d629deebb..d2e097a77 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -11,6 +11,7 @@ import { generateChannelQuery } from "../../common/generate-channel-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; import { ApiError } from "../../error.js"; +import { generateListQuery } from "@/server/api/common/generate-list-query.js"; export const meta = { tags: ["notes"], @@ -104,6 +105,7 @@ export default define(meta, paramDef, async (ps, user) => { .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner") .setParameters(followingQuery.getParameters()); + generateListQuery(query, user); generateChannelQuery(query, user); generateRepliesQuery(query, ps.withReplies, user); generateVisibilityQuery(query, user); diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index 07fae2067..b536d22d4 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -1,8 +1,8 @@ -import { publishUserListStream } from "@/services/stream.js"; -import { UserLists, UserListJoinings, Users } from "@/models/index.js"; +import { UserLists } from "@/models/index.js"; import define from "../../../define.js"; import { ApiError } from "../../../error.js"; import { getUser } from "../../../common/getters.js"; +import { pullUserFromUserList } from "@/services/user-list/pull.js"; export const meta = { tags: ["lists", "users"], @@ -56,7 +56,5 @@ export default define(meta, paramDef, async (ps, me) => { }); // Pull the user - await UserListJoinings.delete({ userListId: userList.id, userId: user.id }); - - publishUserListStream(userList.id, "userRemoved", await Users.pack(user)); + await pullUserFromUserList(user, userList); }); diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts index 0ac788fd3..50bb65ee8 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/update.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts @@ -1,6 +1,7 @@ -import { UserLists } from "@/models/index.js"; +import { UserListJoinings, UserLists, Users } from "@/models/index.js"; import define from "../../../define.js"; import { ApiError } from "../../../error.js"; +import { publishUserEvent } from "@/services/stream.js"; export const meta = { tags: ["lists"], @@ -32,8 +33,9 @@ export const paramDef = { properties: { listId: { type: "string", format: "misskey:id" }, name: { type: "string", minLength: 1, maxLength: 100 }, + hideFromHomeTl: { type: "boolean", nullable: true }, }, - required: ["listId", "name"], + required: ["listId"], } as const; export default define(meta, paramDef, async (ps, user) => { @@ -47,9 +49,20 @@ export default define(meta, paramDef, async (ps, user) => { throw new ApiError(meta.errors.noSuchList); } - await UserLists.update(userList.id, { - name: ps.name, - }); + const partial = { + name: ps.name ?? undefined, + hideFromHomeTl: ps.hideFromHomeTl ?? undefined + }; + if (Object.keys(partial).length > 0) await UserLists.update(userList.id, partial); + + if (ps.hideFromHomeTl != null) { + UserListJoinings.findBy({ userListId: ps.listId }) + .then(members => { + for (const member of members) { + publishUserEvent(userList.userId, ps.hideFromHomeTl ? "userHidden" : "userUnhidden", member.userId); + } + }); + } return await UserLists.pack(userList.id); }); diff --git a/packages/backend/src/server/api/mastodon/endpoints/list.ts b/packages/backend/src/server/api/mastodon/endpoints/list.ts index 1d7fb15d1..9b059a660 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/list.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/list.ts @@ -39,7 +39,8 @@ export function setupEndpointsList(router: Router): void { const body = ctx.request.body as any; const title = (body.title ?? '').trim(); - ctx.body = await ListHelpers.updateList(list, title, ctx); + const exclusive = body.exclusive ?? undefined as boolean | undefined; + ctx.body = await ListHelpers.updateList(list, title, exclusive, ctx); }, ); router.delete<{ Params: { id: string } }>( diff --git a/packages/backend/src/server/api/mastodon/entities/list.ts b/packages/backend/src/server/api/mastodon/entities/list.ts index c82765696..ae6b07c9c 100644 --- a/packages/backend/src/server/api/mastodon/entities/list.ts +++ b/packages/backend/src/server/api/mastodon/entities/list.ts @@ -2,5 +2,6 @@ namespace MastodonEntity { export type List = { id: string; title: string; + exclusive: boolean; }; } diff --git a/packages/backend/src/server/api/mastodon/helpers/list.ts b/packages/backend/src/server/api/mastodon/helpers/list.ts index 0fa607b3e..d2819d1cf 100644 --- a/packages/backend/src/server/api/mastodon/helpers/list.ts +++ b/packages/backend/src/server/api/mastodon/helpers/list.ts @@ -4,9 +4,10 @@ import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { UserList } from "@/models/entities/user-list.js"; import { pushUserToUserList } from "@/services/user-list/push.js"; import { genId } from "@/misc/gen-id.js"; -import { publishUserListStream } from "@/services/stream.js"; import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; import { MastoContext } from "@/server/api/mastodon/index.js"; +import { pullUserFromUserList } from "@/services/user-list/pull.js"; +import { publishUserEvent } from "@/services/stream.js"; export class ListHelpers { public static async getLists(ctx: MastoContext): Promise { @@ -15,7 +16,8 @@ export class ListHelpers { return UserLists.findBy({ userId: user.id }).then(p => p.map(list => { return { id: list.id, - title: list.name + title: list.name, + exclusive: list.hideFromHomeTl } })); } @@ -26,7 +28,8 @@ export class ListHelpers { return UserLists.findOneByOrFail({ userId: user.id, id: id }).then(list => { return { id: list.id, - title: list.name + title: list.name, + exclusive: list.hideFromHomeTl } }); } @@ -110,8 +113,7 @@ export class ListHelpers { }); if (!exist) continue; - await UserListJoinings.delete({ userListId: list.id, userId: user.id }); - publishUserListStream(list.id, "userRemoved", await Users.pack(user)); + await pullUserFromUserList(user, list); } } @@ -128,23 +130,35 @@ export class ListHelpers { return { id: list.id, - title: list.name + title: list.name, + exclusive: list.hideFromHomeTl }; } - public static async updateList(list: UserList, title: string, ctx: MastoContext) { - if (title.length < 1) throw new MastoApiError(400, "Title must not be empty"); + public static async updateList(list: UserList, title: string, exclusive: boolean | undefined, ctx: MastoContext): Promise { + if (title.length < 1 && exclusive === undefined) throw new MastoApiError(400, "Either title or exclusive must be set"); const user = ctx.user as ILocalUser; if (user.id != list.userId) throw new Error("List is not owned by user"); - const partial = { name: title }; + const name = title.length > 0 ? title : undefined; + const partial = { name: name, hideFromHomeTl: exclusive }; const result = await UserLists.update(list.id, partial) .then(async _ => await UserLists.findOneByOrFail({ id: list.id })); + if (exclusive !== undefined) { + UserListJoinings.findBy({ userListId: list.id }) + .then(members => { + for (const member of members) { + publishUserEvent(list.userId, exclusive ? "userHidden" : "userUnhidden", member.userId); + } + }); + } + return { id: result.id, - title: result.name + title: result.name, + exclusive: result.hideFromHomeTl }; } @@ -162,7 +176,8 @@ export class ListHelpers { .then(results => results.map(result => { return { id: result.id, - title: result.name + title: result.name, + exclusive: result.hideFromHomeTl } })); } diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index cc1f05a66..e4d1112d4 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -20,6 +20,7 @@ import { unique } from "@/prelude/array.js"; import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; import { generatePaginationData } from "@/server/api/mastodon/middleware/pagination.js"; import { MastoContext } from "@/server/api/mastodon/index.js"; +import { generateListQuery } from "@/server/api/common/generate-list-query.js"; export class TimelineHelpers { public static async getHomeTimeline(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise { @@ -43,6 +44,7 @@ export class TimelineHelpers { ) .leftJoinAndSelect("note.renote", "renote"); + generateListQuery(query, user); generateChannelQuery(query, user); generateRepliesQuery(query, true, user); generateVisibilityQuery(query, user); diff --git a/packages/backend/src/server/api/mastodon/streaming/channel.ts b/packages/backend/src/server/api/mastodon/streaming/channel.ts index 759533e3c..0aec8cd0f 100644 --- a/packages/backend/src/server/api/mastodon/streaming/channel.ts +++ b/packages/backend/src/server/api/mastodon/streaming/channel.ts @@ -31,6 +31,10 @@ export abstract class MastodonStream { return this.connection.blocking; } + protected get hidden() { + return this.connection.hidden; + } + protected get subscriber() { return this.connection.subscriber; } diff --git a/packages/backend/src/server/api/mastodon/streaming/channels/user.ts b/packages/backend/src/server/api/mastodon/streaming/channels/user.ts index 588f21761..a050546e8 100644 --- a/packages/backend/src/server/api/mastodon/streaming/channels/user.ts +++ b/packages/backend/src/server/api/mastodon/streaming/channels/user.ts @@ -90,12 +90,14 @@ export class MastodonStreamUser extends MastodonStream { private async shouldProcessNote(note: Note): Promise { if (note.visibility === "hidden") return false; - if (note.visibility === "specified") return note.userId === this.user.id || note.visibleUserIds?.includes(this.user.id); + if (note.userId === this.user.id) return true; + if (note.visibility === "specified") return note.visibleUserIds?.includes(this.user.id); if (note.channelId) return false; if (this.user!.id !== note.userId && !this.following.has(note.userId)) return false; if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return false; if (isUserRelated(note, this.muting)) return false; if (isUserRelated(note, this.blocking)) return false; + if (isUserRelated(note, this.hidden)) return false; if (note.renote && !isQuote(note) && this.renoteMuting.has(note.userId)) return false; if (this.userProfile && (await getWordHardMute(note, this.user, this.userProfile.mutedWords))) return false; diff --git a/packages/backend/src/server/api/mastodon/streaming/index.ts b/packages/backend/src/server/api/mastodon/streaming/index.ts index fdb2b98f4..ee546bc1a 100644 --- a/packages/backend/src/server/api/mastodon/streaming/index.ts +++ b/packages/backend/src/server/api/mastodon/streaming/index.ts @@ -2,7 +2,7 @@ import type { EventEmitter } from "events"; import type * as websocket from "websocket"; import type { ILocalUser, User } from "@/models/entities/user.js"; import type { MastodonStream } from "./channel.js"; -import { Blockings, Followings, Mutings, RenoteMutings, UserProfiles, } from "@/models/index.js"; +import { Blockings, Followings, Mutings, RenoteMutings, UserListJoinings, UserProfiles, } from "@/models/index.js"; import type { UserProfile } from "@/models/entities/user-profile.js"; import { StreamEventEmitter, StreamMessages } from "@/server/api/stream/types.js"; import { apiLogger } from "@/server/api/logger.js"; @@ -40,6 +40,7 @@ export class MastodonStreamingConnection { public muting: Set = new Set(); public renoteMuting: Set = new Set(); public blocking: Set = new Set(); + public hidden: Set = new Set(); public token?: OAuthToken; private wsConnection: websocket.connection; private channels: MastodonStream[] = []; @@ -69,6 +70,7 @@ export class MastodonStreamingConnection { this.updateMuting(); this.updateRenoteMuting(); this.updateBlocking(); + this.updateHidden(); this.updateUserProfile(); this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); @@ -98,6 +100,12 @@ export class MastodonStreamingConnection { case "unmute": this.muting.delete(data.body.id); break; + case "userHidden": + this.hidden.add(data.body); + break; + case "userUnhidden": + this.hidden.delete(data.body); + break; // TODO: renote mute events // TODO: block events @@ -247,6 +255,17 @@ export class MastodonStreamingConnection { this.blocking = new Set(blockings.map((x) => x.blockerId)); } + private async updateHidden() { + const hidden = await UserListJoinings.find({ + where: { + userList: { userId: this.user!.id, hideFromHomeTl: true }, + }, + select: ["userId"], + }); + + this.hidden = new Set(hidden.map((x) => x.userId)); + } + private async updateUserProfile() { this.userProfile = await UserProfiles.findOneBy({ userId: this.user!.id, diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index fc8e0ce35..2c471f636 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -38,6 +38,10 @@ export default abstract class Channel { return this.connection.blocking; } + protected get hidden() { + return this.connection.hidden; + } + protected get followingChannels() { return this.connection.followingChannels; } diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 47875aeda..4582b3183 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -57,6 +57,8 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + // Members of lists with hideFromHome set + if (note.userId !== this.user!.id && isUserRelated(note, this.hidden)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 1f1a9b831..fed204165 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -74,6 +74,8 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + // Members of lists with hideFromHome set + if (note.userId !== this.user!.id && isUserRelated(note, this.hidden)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 465c3f045..c1f0df77c 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -10,7 +10,7 @@ import { RenoteMutings, UserProfiles, ChannelFollowings, - Blockings, + Blockings, UserListJoinings, } from "@/models/index.js"; import type { AccessToken } from "@/models/entities/access-token.js"; import type { UserProfile } from "@/models/entities/user-profile.js"; @@ -35,7 +35,8 @@ export default class Connection { public following: Set = new Set(); public muting: Set = new Set(); public renoteMuting: Set = new Set(); - public blocking: Set = new Set(); // "被"blocking + public blocking: Set = new Set(); + public hidden: Set = new Set(); public followingChannels: Set = new Set(); public token?: AccessToken; private wsConnection: websocket.connection; @@ -79,6 +80,7 @@ export default class Connection { this.updateMuting(); this.updateRenoteMuting(); this.updateBlocking(); + this.updateHidden(); this.updateFollowingChannels(); this.updateUserProfile(); @@ -122,6 +124,14 @@ export default class Connection { this.followingChannels.delete(data.body.id); break; + case "userHidden": + this.hidden.add(data.body); + break; + + case "userUnhidden": + this.hidden.delete(data.body); + break; + case "updateUserProfile": this.userProfile = data.body; break; @@ -432,6 +442,17 @@ export default class Connection { this.blocking = new Set(blockings.map((x) => x.blockerId)); } + private async updateHidden() { + const hidden = await UserListJoinings.find({ + where: { + userList: { userId: this.user!.id, hideFromHomeTl: true }, + }, + select: ["userId"], + }); + + this.hidden = new Set(hidden.map((x) => x.userId)); + } + private async updateFollowingChannels() { const followings = await ChannelFollowings.find({ where: { diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 569d1ae90..a902ce882 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -74,6 +74,8 @@ export interface UserStreamTypes { follow: Packed<"UserDetailedNotMe">; unfollow: Packed<"User">; userAdded: Packed<"User">; + userHidden: User["id"]; + userUnhidden: User["id"]; } export interface MainStreamTypes { diff --git a/packages/backend/src/services/user-list/pull.ts b/packages/backend/src/services/user-list/pull.ts new file mode 100644 index 000000000..49b7cbf64 --- /dev/null +++ b/packages/backend/src/services/user-list/pull.ts @@ -0,0 +1,12 @@ +import { publishUserEvent, publishUserListStream } from "@/services/stream.js"; +import type { User } from "@/models/entities/user.js"; +import type { UserList } from "@/models/entities/user-list.js"; +import { UserListJoinings, Users } from "@/models/index.js"; + +export async function pullUserFromUserList(target: User, list: UserList) { + await UserListJoinings.delete({ userListId: list.id, userId: target.id }); + + const packed = await Users.pack(target); + publishUserListStream(list.id, "userRemoved", packed); + if (list.hideFromHomeTl) publishUserEvent(list.userId, "userUnhidden", target.id); +} \ No newline at end of file diff --git a/packages/backend/src/services/user-list/push.ts b/packages/backend/src/services/user-list/push.ts index 74827b46b..71b3c1ca7 100644 --- a/packages/backend/src/services/user-list/push.ts +++ b/packages/backend/src/services/user-list/push.ts @@ -1,10 +1,9 @@ -import { publishUserListStream } from "@/services/stream.js"; +import { publishUserEvent, publishUserListStream } from "@/services/stream.js"; import type { User } from "@/models/entities/user.js"; import type { UserList } from "@/models/entities/user-list.js"; -import { Followings, UserListJoinings, Users } from "@/models/index.js"; +import { UserListJoinings, Users } from "@/models/index.js"; import type { UserListJoining } from "@/models/entities/user-list-joining.js"; import { genId } from "@/misc/gen-id.js"; -import { ApiError } from "@/server/api/error.js"; export async function pushUserToUserList(target: User, list: UserList) { await UserListJoinings.insert({ @@ -14,5 +13,7 @@ export async function pushUserToUserList(target: User, list: UserList) { userListId: list.id, } as UserListJoining); - publishUserListStream(list.id, "userAdded", await Users.pack(target)); + const packed = await Users.pack(target); + publishUserListStream(list.id, "userAdded", packed); + if (list.hideFromHomeTl) publishUserEvent(list.userId, "userHidden", target.id); } diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue index fe02c2ddd..14223ffee 100644 --- a/packages/client/src/pages/my-lists/list.vue +++ b/packages/client/src/pages/my-lists/list.vue @@ -20,6 +20,11 @@ {{ i18n.ts.delete }} + + {{ + i18n.ts.hideFromHome + }} + @@ -72,12 +77,15 @@ import * as os from "@/os"; import { mainRouter } from "@/router"; import { definePageMetadata } from "@/scripts/page-metadata"; import { i18n } from "@/i18n"; +import FormSwitch from "@/components/form/switch.vue"; +import FormSection from "@/components/form/section.vue"; const props = defineProps<{ listId: string; }>(); let list = $ref(null); +let hideFromHomeTl = $ref(false); let users = $ref([]); function fetchList() { @@ -85,6 +93,7 @@ function fetchList() { listId: props.listId, }).then((_list) => { list = _list; + hideFromHomeTl = _list.hideFromHomeTl; os.api("users/show", { userIds: list.userIds, }).then((_users) => { @@ -142,7 +151,15 @@ async function deleteList() { mainRouter.push("/my/lists"); } +async function hideFromHome() { + await os.api("users/lists/update", { + listId: list.id, + hideFromHomeTl: hideFromHomeTl, + }); +} + watch(() => props.listId, fetchList, { immediate: true }); +watch(() => hideFromHomeTl, hideFromHome); const headerActions = $computed(() => []);