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