From 0edceb80591a65e9d94f7ebae6ebd6b2f032bb3c Mon Sep 17 00:00:00 2001 From: Rory& Date: Thu, 29 Jan 2026 20:32:34 +0100 Subject: [PATCH] Thread members ported from #876 --- src/util/entities/ThreadMember.ts | 195 ++++++++++++++++++ src/util/entities/index.ts | 1 + .../postgres/1769653303971-ThreadMembers.ts | 25 +++ .../1769653303972-ThreadMembersIdx.ts | 13 ++ 4 files changed, 234 insertions(+) create mode 100644 src/util/entities/ThreadMember.ts create mode 100644 src/util/migration/postgres/1769653303971-ThreadMembers.ts create mode 100644 src/util/migration/postgres/1769653303972-ThreadMembersIdx.ts diff --git a/src/util/entities/ThreadMember.ts b/src/util/entities/ThreadMember.ts new file mode 100644 index 000000000..cedf1f7bc --- /dev/null +++ b/src/util/entities/ThreadMember.ts @@ -0,0 +1,195 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, RelationId } from "typeorm"; +import { ThreadMembersUpdateEvent } from "../interfaces"; +import { emitEvent } from "../util"; +import { BaseClassWithoutId } from "./BaseClass"; +import { Channel } from "./Channel"; +import { HTTPError } from "lambert-server"; +import { Member } from "./Member"; + +// TODO: move +interface ThreadMemberMuteConfig { + end_time?: Date; + selected_time_window?: number; +} + +// TODO: move +export enum ThreadMemberFlags { + NONE = 0, + HAS_INTERACTED = 1 << 0, + ALL_MESSAGES = 1 << 1, + ONLY_MENTIONS = 1 << 2, + NO_MESSAGES = 1 << 3, +} + +@Entity("thread_members") +@Index(["id", "member_idx"], { unique: true }) +export class ThreadMember extends BaseClassWithoutId { + @PrimaryGeneratedColumn() + index: string; + + @Column() + @RelationId((member: ThreadMember) => member.channel) + id: string; + + @JoinColumn({ name: "id" }) + @ManyToOne(() => Channel, { + onDelete: "CASCADE", + }) + channel: Channel; + + @Column() + @RelationId((member: ThreadMember) => member.member) + member_idx: string; + + @JoinColumn({ name: "member_idx" }) + @ManyToOne(() => Member, { + onDelete: "CASCADE", + }) + member: Member; + + @Column() + join_timestamp: Date; + + @Column() + muted: boolean; + + @Column({ nullable: true, type: "simple-json" }) + mute_config?: ThreadMemberMuteConfig; + + @Column() + flags: ThreadMemberFlags; + + static async IsInThreadOrFail(member_id: string, thread_id: string) { + if (await ThreadMember.count({ where: { id: thread_id, member_idx: member_id } })) return true; + throw new HTTPError("You are not member of this thread", 403); + } + + static async removeFromThread(member_id: string, thread_id: string) { + const channel = await Channel.findOneOrFail({ where: { id: thread_id } }); + if ( + !(await ThreadMember.count({ + where: { + id: thread_id, + member_idx: member_id, + }, + })) + ) + throw new HTTPError("You are not member of this thread", 403); + // // use promise all to execute all promises at the same time -> save time + // TODO: check for bugs + if (channel.member_count) channel.member_count--; + return Promise.all([ + ThreadMember.delete({ + id: thread_id, + member_idx: member_id, + }), + // //Guild.decrement({ id: guild_id }, "member_count", -1), + + emitEvent({ + event: "THREAD_MEMBERS_UPDATE", + data: { + guild_id: channel.guild_id, + id: channel.id, + member_count: channel.member_count, + removed_member_ids: [member_id], + }, + channel_id: thread_id, + } as ThreadMembersUpdateEvent), + ]); + } + + // static async addRole(user_id: string, guild_id: string, role_id: string) { + // const [member, role] = await Promise.all([ + // // @ts-ignore + // Member.findOneOrFail({ + // where: { id: user_id, guild_id }, + // relations: ["user", "roles"], // we don't want to load the role objects just the ids + // select: ["index"] + // }), + // Role.findOneOrFail({ where: { id: role_id, guild_id }, select: ["id"] }) + // ]); + // member.roles.push(OrmUtils.mergeDeep(new Role(), { id: role_id })); + + // await Promise.all([ + // member.save(), + // emitEvent({ + // event: "GUILD_MEMBER_UPDATE", + // data: { + // guild_id, + // user: member.user, + // roles: member.roles.map((x) => x.id) + // }, + // guild_id + // } as GuildMemberUpdateEvent) + // ]); + // } + + // static async removeRole(user_id: string, guild_id: string, role_id: string) { + // const [member] = await Promise.all([ + // // @ts-ignore + // Member.findOneOrFail({ + // where: { id: user_id, guild_id }, + // relations: ["user", "roles"], // we don't want to load the role objects just the ids + // select: ["index"] + // }), + // await Role.findOneOrFail({ where: { id: role_id, guild_id } }) + // ]); + // member.roles = member.roles.filter((x) => x.id == role_id); + + // await Promise.all([ + // member.save(), + // emitEvent({ + // event: "GUILD_MEMBER_UPDATE", + // data: { + // guild_id, + // user: member.user, + // roles: member.roles.map((x) => x.id) + // }, + // guild_id + // } as GuildMemberUpdateEvent) + // ]); + // } + + // static async changeNickname(user_id: string, guild_id: string, nickname: string) { + // const member = await Member.findOneOrFail({ + // where: { + // id: user_id, + // guild_id + // }, + // relations: ["user"] + // }); + // member.nick = nickname; + + // await Promise.all([ + // member.save(), + + // emitEvent({ + // event: "GUILD_MEMBER_UPDATE", + // data: { + // guild_id, + // user: member.user, + // nick: nickname + // }, + // guild_id + // } as GuildMemberUpdateEvent) + // ]); + // } +} diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts index 0e67675a8..61542148a 100644 --- a/src/util/entities/index.ts +++ b/src/util/entities/index.ts @@ -56,6 +56,7 @@ export * from "./StreamSession"; export * from "./Team"; export * from "./TeamMember"; export * from "./Template"; +export * from "./ThreadMember"; export * from "./User"; export * from "./UserSettings"; export * from "./UserSettingsProtos"; diff --git a/src/util/migration/postgres/1769653303971-ThreadMembers.ts b/src/util/migration/postgres/1769653303971-ThreadMembers.ts new file mode 100644 index 000000000..45f231b7c --- /dev/null +++ b/src/util/migration/postgres/1769653303971-ThreadMembers.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ThreadMembers1769653303971 implements MigrationInterface { + name = "ThreadMembers1769653303971"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "thread_members" ("index" SERIAL NOT NULL, "id" character varying NOT NULL, "member_id" integer NOT NULL, "join_timestamp" TIMESTAMP NOT NULL, "muted" boolean NOT NULL, "mute_config" text, "flags" integer NOT NULL, CONSTRAINT "PK_22232a9f7a08fb9967a9c78da53" PRIMARY KEY ("index"))`, + ); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_bde0970b6a26bdbd83508addd2" ON "thread_members" ("id", "member_id") `); + await queryRunner.query( + `ALTER TABLE "thread_members" ADD CONSTRAINT "FK_cf20e37d71b0e1bf1ab633861c8" FOREIGN KEY ("id") REFERENCES "channels"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "thread_members" ADD CONSTRAINT "FK_606ac45e8756d3440c584477f4e" FOREIGN KEY ("member_id") REFERENCES "members"("index") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "thread_members" DROP CONSTRAINT "FK_606ac45e8756d3440c584477f4e"`); + await queryRunner.query(`ALTER TABLE "thread_members" DROP CONSTRAINT "FK_cf20e37d71b0e1bf1ab633861c8"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bde0970b6a26bdbd83508addd2"`); + await queryRunner.query(`DROP TABLE "thread_members"`); + } +} diff --git a/src/util/migration/postgres/1769653303972-ThreadMembersIdx.ts b/src/util/migration/postgres/1769653303972-ThreadMembersIdx.ts new file mode 100644 index 000000000..40dc952a4 --- /dev/null +++ b/src/util/migration/postgres/1769653303972-ThreadMembersIdx.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ThreadMembersIdx1769653303972 implements MigrationInterface { + name = "ThreadMembersIdx1769653303972"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE public.thread_members RENAME COLUMN member_id TO member_idx;`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE public.thread_members RENAME COLUMN member_idx TO member_id;`); + } +}