From 5e3080d72fe5a388c53698c732b855b98ca92d6e Mon Sep 17 00:00:00 2001 From: Rory& Date: Fri, 11 Jul 2025 12:55:38 +0200 Subject: [PATCH] Setting protos support --- assets/schemas.json | Bin 33022638 -> 34416894 bytes package-lock.json | Bin 399401 -> 400135 bytes package.json | 1 + scripts/schema.js | 22 +++ src/api/routes/users/@me/settings-proto/1.ts | 187 ++++++++++++++++++ src/api/routes/users/@me/settings-proto/2.ts | 187 ++++++++++++++++++ src/util/entities/UserSettingsProtos.ts | 170 ++++++++++++++++ src/util/entities/index.ts | 1 + .../1752157979333-UserSettingsProtos.ts | 17 ++ src/util/schemas/SettingsProtoUpdateSchema.ts | 47 +++++ src/util/schemas/index.ts | 1 + .../responses/SettingsProtoUpdateResponse.ts | 53 +++++ src/util/schemas/responses/index.ts | 1 + 13 files changed, 687 insertions(+) create mode 100644 src/api/routes/users/@me/settings-proto/1.ts create mode 100644 src/api/routes/users/@me/settings-proto/2.ts create mode 100644 src/util/entities/UserSettingsProtos.ts create mode 100644 src/util/migration/postgres/1752157979333-UserSettingsProtos.ts create mode 100644 src/util/schemas/SettingsProtoUpdateSchema.ts create mode 100644 src/util/schemas/responses/SettingsProtoUpdateResponse.ts diff --git a/assets/schemas.json b/assets/schemas.json index 4efe4d5cf3858a770f731ae9cd76d66f91c02090..84fc55adc34d842a94d3b5782cb3f8fb3ebdb2e5 100755 GIT binary patch delta 91945 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 delta 9542 zcmb_idr*|u6`zCbF7WY2kmtI*3?k1M#h6GjDpXC7#0aS2VT>RsxG|*@Dk8oH3sIM# z9%97L7;B89z*Na?O2QRWF%3~b5K%!KlKglL6~7orp5E5uKTzmNbSfvmW0Ld8b}I^pPZ z`iXB+EEA9)vqIu%v0)9%!%K*eCn+yJXeQZ$;Y+4zaQ$N+59}-QkCOFycVGh6G){Ca zk5Prn%}4ftro8W{vz+F!1iDzw`=P4=oM5)c(D@=2UVq{?tx9881Ynv8QQ zHp}6ivJ15I-DH#2*+znxsO&WFzCQ{TQQ&MbB312Do3O%-c+`zHdJJY}j)Is?ew2Me zB{%9V>Kx zCvh~2H$2LDi|Fa1DUY1kh5PzYn!)V&oex58lY+{M`+uKIOr7ly^Q&nGLcSJlX`yC) zy#+7OxJ*D4K(&0~pNC)`?jMfu#FoE1_@cva$Bjo_Ds2N#0W^8TW9ysA~4*uErO`n)y~qV*J&(U zZtPe^w59S&nM8{-OKjh@fylD?9KJ|{$OacR*fD+M-sv)bYaslukV;vGSu|uB6P_LlQf72pco%)Sl^cV8APygAm~}&kVPwhd-Bd|*vN-} zdz+TDU*(-`r@37|UUmhnSV_(gQ8de#=*|5!?Tm8-Hr?~{G9?z)>pYB&*Vv@vxM5=u zY_!0_SM0Us>AWVRCB+8y7k4F=7lt~?=S449W6%7%4!GYf#09_YRcqxn#X;bRMcX86 zK2jP4ch8f`qiMFW&2EdiJPO>iRUM5qx8bvtE;Y+vak-NgCnj*$Xb_WWhQ#Y%6IE2E zF7%mu+nT8oZmt*wmRV z>HqqxWsz7D;qGnxN}gt!2_s`L*Cxc{zwa|JI1Y<4oLqms)bO36Atq7k;gG~}^CVi{ z2+YcLVQ{~bP-z54y*?6>DKQclhrE{{oruxpL_rz_Ga$+7d&HUKkt?Rdbc*-PYhWIb zIRkQ=$Tlluv*P*+f7WZXg z3t%ygmam%d78#SLT?83Ko{!v;VH#zdE8Ljpx|FQj2D?;PMAU7oY8fOEG24fiLldPf z3wx}GGGLDVFEcal+*+@}h{NFg|DT=(+k21=3yD^(e6pJC@wJcEz)~VlVeeXU2JOC) z2a_rDjPOBz*N4=D$V=PT!7PeBqEi=$+fM{-gfv&e)W(faN$HKxYp5Lk>Gz=FC;1jw zOItgcnB@ZvC1A~OoGk|8a|N^J3jQI{=Kb?Wz>3fLW;;xx+03=I1|aTyxKC9%RM9l# z`QjPXF`{m5pM3%kDcwwDOy~X2Acm-0qgb|5r}M=Ql5Y5tmd3KF>>l0R16?#WGxF7fTM$mCHdSeF(7>%%H?8G9SYW zG~0Mi+P|Tg!XxtkVDcbD(lFTAD!H2$0=(tTr=X)%Gp?jg2NB~J3l{b?HM#OIvYY6_ YtSF5|8xixba2pHKGq*m;!e;${03Qs5!2kdN diff --git a/package-lock.json b/package-lock.json index af6d8af72ec3364937eef4c2c8d98ead70b01b0d..573811e6657660346cb1afdadf3848d993994739 100644 GIT binary patch delta 468 zcmZ3vL85(|#D+#r$&}3Eus6XWJe&h3?)j2|Mo!BR=3 zX}TrFllOGUOux^<#yb7-NhW<|2P7%|qSCyQ%-mFcT^LK(NYBu4`a~@zrI1pW#1KQz zG>`1kbYG)_$|`NQ^2|sJgVe0>qHGVt0>=`6C;uq7(jc?o2v@g|;=CmNtjxghK*J(u z&!S}S3L~eI%8I;-a5Jay^5U>yM}5y!?eOUbnV3{%H5>~PlQU9*b{Qx*`nf1L1-s}P z>pJHomKLXKPEK4eF_51 zt4#Lx^a(dMbaJ!sNiuPBHVQWKa<$BKFUTHF-M*qT>xZePL4_$YGv>`X?^_J=8q+aIPddA79Ae8mLB%s|Yt Keda6HJOco&2N<;g diff --git a/package.json b/package.json index f862ba8a..0609112f 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.35.1", "@typescript-eslint/parser": "^8.35.1", + "discord-protos": "^1.2.43", "eslint": "^9.30.1", "express": "^4.21.2", "globals": "^15.15.0", diff --git a/scripts/schema.js b/scripts/schema.js index fd5b09f5..b39948e7 100644 --- a/scripts/schema.js +++ b/scripts/schema.js @@ -112,7 +112,29 @@ function main() { definitions = { ...definitions, [name]: { ...part } }; } + deleteOneOfKindUndefinedRecursive(definitions, "$"); + fs.writeFileSync(schemaPath, JSON.stringify(definitions, null, 4)); } +function deleteOneOfKindUndefinedRecursive(obj, path) { + if ( + obj?.type === "object" && + obj?.properties?.oneofKind?.type === "undefined" + ) + return true; + + for (const key in obj) { + if ( + typeof obj[key] === "object" && + deleteOneOfKindUndefinedRecursive(obj[key], path + "." + key) + ) { + console.log("Deleting", path, key); + delete obj[key]; + } + } + + return false; +} + main(); diff --git a/src/api/routes/users/@me/settings-proto/1.ts b/src/api/routes/users/@me/settings-proto/1.ts new file mode 100644 index 00000000..3ac64292 --- /dev/null +++ b/src/api/routes/users/@me/settings-proto/1.ts @@ -0,0 +1,187 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +import { + OrmUtils, + SettingsProtoJsonResponse, + SettingsProtoResponse, + SettingsProtoUpdateJsonSchema, + SettingsProtoUpdateSchema, + UserSettingsProtos, +} from "@spacebar/util"; +import { PreloadedUserSettings } from "discord-protos"; + +const router: Router = Router(); + +//#region Protobuf +router.get( + "/", + route({ + responses: { + 200: { + body: "SettingsProtoResponse", + }, + }, + query: { + atomic: { + type: "boolean", + description: + "Whether to try to apply the settings update atomically (default false)", + }, + }, + }), + async (req: Request, res: Response) => { + const userSettings = await UserSettingsProtos.getOrCreate(req.user_id); + + res.json({ + settings: PreloadedUserSettings.toBase64( + userSettings.userSettings!, + ), + } as SettingsProtoResponse); + }, +); + +router.patch( + "/", + route({ + requestBody: "SettingsProtoUpdateSchema", + responses: { + 200: { + body: "SettingsProtoUpdateResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { settings, required_data_version } = + req.body as SettingsProtoUpdateSchema; + const { atomic } = req.query; + const updatedSettings = PreloadedUserSettings.fromBase64(settings); + + const resultObj = await patchUserSettings( + req.user_id, + updatedSettings, + required_data_version, + atomic == "true", + ); + + res.json({ + settings: PreloadedUserSettings.toBase64(resultObj.settings), + out_of_date: resultObj.out_of_date, + }); + }, +); + +//#endregion +//#region JSON +router.get( + "/json", + route({ + responses: { + 200: { + body: "SettingsProtoJsonResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const userSettings = await UserSettingsProtos.getOrCreate(req.user_id); + + res.json({ + settings: PreloadedUserSettings.toJson(userSettings.userSettings!), + } as SettingsProtoJsonResponse); + }, +); + +router.patch( + "/json", + route({ + requestBody: "SettingsProtoUpdateJsonSchema", + responses: { + 200: { + body: "SettingsProtoUpdateJsonResponse", + }, + }, + query: { + atomic: { + type: "boolean", + description: + "Whether to try to apply the settings update atomically (default false)", + }, + }, + }), + async (req: Request, res: Response) => { + const { settings, required_data_version } = + req.body as SettingsProtoUpdateJsonSchema; + const { atomic } = req.query; + const updatedSettings = PreloadedUserSettings.fromJson(settings); + + const resultObj = await patchUserSettings( + req.user_id, + updatedSettings, + required_data_version, + atomic == "true", + ); + + res.json({ + settings: PreloadedUserSettings.toJson(resultObj.settings), + out_of_date: resultObj.out_of_date, + }); + }, +); + +//#endregion + +async function patchUserSettings( + userId: string, + updatedSettings: PreloadedUserSettings, + required_data_version: number | undefined, + atomic: boolean = false, +) { + const userSettings = await UserSettingsProtos.getOrCreate(userId); + let settings = userSettings.userSettings!; + + if ( + required_data_version && + settings.versions && + settings.versions.dataVersion > required_data_version + ) { + return { + settings: settings, + out_of_date: true, + }; + } + + console.log(`Updating user settings for user ${userId} with atomic=${atomic}:`, updatedSettings); + + if (!atomic) { + settings = Object.assign(settings, updatedSettings); + } else { + settings = OrmUtils.mergeDeep(settings, updatedSettings); + } + + userSettings.userSettings = settings; + await userSettings.save(); + + return { + settings: settings, + out_of_date: false, + }; +} + +export default router; diff --git a/src/api/routes/users/@me/settings-proto/2.ts b/src/api/routes/users/@me/settings-proto/2.ts new file mode 100644 index 00000000..e0cf786f --- /dev/null +++ b/src/api/routes/users/@me/settings-proto/2.ts @@ -0,0 +1,187 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +import { + OrmUtils, + SettingsProtoJsonResponse, + SettingsProtoResponse, + SettingsProtoUpdateJsonSchema, + SettingsProtoUpdateSchema, + UserSettingsProtos, +} from "@spacebar/util"; +import { FrecencyUserSettings } from "discord-protos"; + +const router: Router = Router(); + +//#region Protobuf +router.get( + "/", + route({ + responses: { + 200: { + body: "SettingsProtoResponse", + }, + }, + query: { + atomic: { + type: "boolean", + description: + "Whether to try to apply the settings update atomically (default false)", + }, + }, + }), + async (req: Request, res: Response) => { + const userSettings = await UserSettingsProtos.getOrCreate(req.user_id); + + res.json({ + settings: FrecencyUserSettings.toBase64( + userSettings.frecencySettings!, + ), + } as SettingsProtoResponse); + }, +); + +router.patch( + "/", + route({ + requestBody: "SettingsProtoUpdateSchema", + responses: { + 200: { + body: "SettingsProtoUpdateResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { settings, required_data_version } = + req.body as SettingsProtoUpdateSchema; + const { atomic } = req.query; + const updatedSettings = FrecencyUserSettings.fromBase64(settings); + + const resultObj = await patchUserSettings( + req.user_id, + updatedSettings, + required_data_version, + atomic == "true", + ); + + res.json({ + settings: FrecencyUserSettings.toBase64(resultObj.settings), + out_of_date: resultObj.out_of_date, + }); + }, +); + +//#endregion +//#region JSON +router.get( + "/json", + route({ + responses: { + 200: { + body: "SettingsProtoJsonResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const userSettings = await UserSettingsProtos.getOrCreate(req.user_id); + + res.json({ + settings: FrecencyUserSettings.toJson(userSettings.frecencySettings!), + } as SettingsProtoJsonResponse); + }, +); + +router.patch( + "/json", + route({ + requestBody: "SettingsProtoUpdateJsonSchema", + responses: { + 200: { + body: "SettingsProtoUpdateJsonResponse", + }, + }, + query: { + atomic: { + type: "boolean", + description: + "Whether to try to apply the settings update atomically (default false)", + }, + }, + }), + async (req: Request, res: Response) => { + const { settings, required_data_version } = + req.body as SettingsProtoUpdateJsonSchema; + const { atomic } = req.query; + const updatedSettings = FrecencyUserSettings.fromJson(settings); + + const resultObj = await patchUserSettings( + req.user_id, + updatedSettings, + required_data_version, + atomic == "true", + ); + + res.json({ + settings: FrecencyUserSettings.toJson(resultObj.settings), + out_of_date: resultObj.out_of_date, + }); + }, +); + +//#endregion + +async function patchUserSettings( + userId: string, + updatedSettings: FrecencyUserSettings, + required_data_version: number | undefined, + atomic: boolean = false, +) { + const userSettings = await UserSettingsProtos.getOrCreate(userId); + let settings = userSettings.frecencySettings!; + + if ( + required_data_version && + settings.versions && + settings.versions.dataVersion > required_data_version + ) { + return { + settings: settings, + out_of_date: true, + }; + } + + console.log(`Updating frecency settings for user ${userId} with atomic=${atomic}:`, updatedSettings); + + if (!atomic) { + settings = Object.assign(settings, updatedSettings); + } else { + settings = OrmUtils.mergeDeep(settings, updatedSettings); + } + + userSettings.frecencySettings = settings; + await userSettings.save(); + + return { + settings: settings, + out_of_date: false, + }; +} + +export default router; diff --git a/src/util/entities/UserSettingsProtos.ts b/src/util/entities/UserSettingsProtos.ts new file mode 100644 index 00000000..b7b8f607 --- /dev/null +++ b/src/util/entities/UserSettingsProtos.ts @@ -0,0 +1,170 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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, JoinColumn, OneToOne, RelationId } from "typeorm"; +import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass"; +import { dbEngine } from "@spacebar/util"; +import { User } from "./User"; +import { + FrecencyUserSettings, + PreloadedUserSettings, + PreloadedUserSettings_LaunchPadMode, + PreloadedUserSettings_SwipeRightToLeftMode, + PreloadedUserSettings_Theme, + PreloadedUserSettings_TimestampHourCycle, + PreloadedUserSettings_UIDensity, +} from "discord-protos"; + +@Entity({ + name: "user_settings_protos", + engine: dbEngine, +}) +export class UserSettingsProtos extends BaseClassWithoutId { + @OneToOne(() => User, { + cascade: true, + orphanedRowAction: "delete", + eager: false, + }) + @JoinColumn({ name: "user_id" }) + user: User; + + @PrimaryIdColumn({ type: "text" }) + user_id: string; + + @Column({ nullable: true, type: String, name: "userSettings" }) + _userSettings: string | undefined; + + @Column({ nullable: true, type: String, name: "frecencySettings" }) + _frecencySettings: string | undefined; + + // @Column({nullable: true, type: "simple-json"}) + // testSettings: {}; + + bigintReplacer(_key: string, value: any): any { + if (typeof value === "bigint") { + return (value as bigint).toString(); + } else if (value instanceof Uint8Array) { + return { + __type: "Uint8Array", + data: Array.from(value as Uint8Array) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + }; + } else { + return value; + } + } + + bigintReviver(_key: string, value: any): any { + if (typeof value === "string" && /^\d+n$/.test(value)) { + return BigInt((value as string).slice(0, -1)); + } else if ( + typeof value === "object" && + value !== null && + "__type" in value + ) { + if (value.__type === "Uint8Array" && "data" in value) { + return new Uint8Array( + value.data + .match(/.{1,2}/g)! + .map((byte: string) => parseInt(byte, 16)), + ); + } + } + return value; + } + + get userSettings(): PreloadedUserSettings | undefined { + if (!this._userSettings) return undefined; + return PreloadedUserSettings.fromJson( + JSON.parse(this._userSettings, this.bigintReviver), + ); + } + + set userSettings(value: PreloadedUserSettings | undefined) { + if (value) { + // this._userSettings = JSON.stringify(value, this.bigintReplacer); + this._userSettings = PreloadedUserSettings.toJsonString(value); + } else { + this._userSettings = undefined; + } + } + + get frecencySettings(): FrecencyUserSettings | undefined { + if (!this._frecencySettings) return undefined; + return FrecencyUserSettings.fromJson( + JSON.parse(this._frecencySettings, this.bigintReviver), + ); + } + + set frecencySettings(value: FrecencyUserSettings | undefined) { + if (value) { + this._frecencySettings = JSON.stringify(value, this.bigintReplacer); + } else { + this._frecencySettings = undefined; + } + } + + static async getOrCreate(user_id: string): Promise { + const user = await User.findOneOrFail({ + where: { id: user_id }, + select: { settings: true }, + }); + let userSettings = await UserSettingsProtos.findOne({ + where: { user_id }, + }); + let modified = false; + if (!userSettings) { + userSettings = UserSettingsProtos.create({ + user_id, + }); + modified = true; + } + + if (!userSettings.userSettings) { + userSettings.userSettings = PreloadedUserSettings.create({ + ads: { + alwaysDeliver: false, + }, + appearance: { + developerMode: user.settings?.developer_mode ?? true, + theme: PreloadedUserSettings_Theme.DARK, + mobileRedesignDisabled: true, + launchPadMode: + PreloadedUserSettings_LaunchPadMode.LAUNCH_PAD_DISABLED, + swipeRightToLeftMode: + PreloadedUserSettings_SwipeRightToLeftMode.SWIPE_RIGHT_TO_LEFT_REPLY, + timestampHourCycle: + PreloadedUserSettings_TimestampHourCycle.AUTO, + uiDensity: + PreloadedUserSettings_UIDensity.UI_DENSITY_COMPACT, + }, + }); + modified = true; + } + + if (!userSettings.frecencySettings) { + userSettings.frecencySettings = FrecencyUserSettings.create({}); + modified = true; + } + + if (modified) userSettings = await userSettings.save(); + + return userSettings; + } +} diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts index dd967ce5..4c95e83a 100644 --- a/src/util/entities/index.ts +++ b/src/util/entities/index.ts @@ -55,6 +55,7 @@ export * from "./TeamMember"; export * from "./Template"; export * from "./User"; export * from "./UserSettings"; +export * from "./UserSettingsProtos"; export * from "./ValidRegistrationTokens"; export * from "./VoiceState"; export * from "./Webhook"; diff --git a/src/util/migration/postgres/1752157979333-UserSettingsProtos.ts b/src/util/migration/postgres/1752157979333-UserSettingsProtos.ts new file mode 100644 index 00000000..0633e144 --- /dev/null +++ b/src/util/migration/postgres/1752157979333-UserSettingsProtos.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserSettingsProtos1752157979333 implements MigrationInterface { + name = 'UserSettingsProtos1752157979333' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "user_settings_protos"`); + await queryRunner.query(`CREATE TABLE "user_settings_protos" ("user_id" character varying NOT NULL, "userSettings" text, "frecencySettings" text, CONSTRAINT "PK_8ff3d1961a48b693810c9f99853" PRIMARY KEY ("user_id"))`); + await queryRunner.query(`ALTER TABLE "user_settings_protos" ADD CONSTRAINT "FK_8ff3d1961a48b693810c9f99853" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_settings_protos" DROP CONSTRAINT "FK_8ff3d1961a48b693810c9f99853"`); + await queryRunner.query(`DROP TABLE "user_settings_protos"`); + } + +} diff --git a/src/util/schemas/SettingsProtoUpdateSchema.ts b/src/util/schemas/SettingsProtoUpdateSchema.ts new file mode 100644 index 00000000..2b930bc0 --- /dev/null +++ b/src/util/schemas/SettingsProtoUpdateSchema.ts @@ -0,0 +1,47 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 { JsonValue } from "@protobuf-ts/runtime"; + +export interface SettingsProtoUpdateSchema { + settings: string; + required_data_version?: number; +} + +export interface SettingsProtoUpdateJsonSchema { + settings: JsonValue; + required_data_version?: number; +} + +// TODO: these dont work with schema validation +// typed JSON schemas: +// export interface SettingsProtoUpdatePreloadedUserSettingsSchema { +// settings: PreloadedUserSettings; +// required_data_version?: number; +// } +// +// export interface SettingsProtoUpdateFrecencyUserSettingsSchema { +// settings: FrecencyUserSettings; +// required_data_version?: number; +// } + +// TODO: what is this? +// export interface SettingsProtoUpdateTestSettingsSchema { +// settings: {}; +// required_data_version?: number; +// } \ No newline at end of file diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index a7aaa1fa..4088c158 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -69,6 +69,7 @@ export * from "./responses"; export * from "./RoleModifySchema"; export * from "./RolePositionUpdateSchema"; export * from "./SelectProtocolSchema"; +export * from "./SettingsProtoUpdateSchema"; export * from "./StreamCreateSchema"; export * from "./StreamDeleteSchema"; export * from "./StreamWatchSchema"; diff --git a/src/util/schemas/responses/SettingsProtoUpdateResponse.ts b/src/util/schemas/responses/SettingsProtoUpdateResponse.ts new file mode 100644 index 00000000..42f8621a --- /dev/null +++ b/src/util/schemas/responses/SettingsProtoUpdateResponse.ts @@ -0,0 +1,53 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 { JsonValue } from "@protobuf-ts/runtime"; + +export interface SettingsProtoResponse { + settings: string; +} + +export interface SettingsProtoUpdateResponse extends SettingsProtoResponse { + out_of_date?: boolean; +} + +export interface SettingsProtoJsonResponse { + settings: JsonValue; +} + +export interface SettingsProtoUpdateJsonResponse extends SettingsProtoJsonResponse { + out_of_date?: boolean; +} + +// TODO: these dont work with schemas validation +// Typed JSON schemas: +// export interface SettingsProtoUpdatePreloadedUserSettingsJsonResponse { +// settings: PreloadedUserSettings; +// out_of_date?: boolean; +// } +// +// export interface SettingsProtoUpdateFrecencyUserSettingsJsonResponse { +// settings: FrecencyUserSettings; +// out_of_date?: boolean; +// } + +// TODO: what is this? +// export interface SettingsProtoUpdateTestSettingsJsonResponse { +// settings: {}; +// out_of_date?: boolean; +// } diff --git a/src/util/schemas/responses/index.ts b/src/util/schemas/responses/index.ts index 29537cf7..8949924b 100644 --- a/src/util/schemas/responses/index.ts +++ b/src/util/schemas/responses/index.ts @@ -47,6 +47,7 @@ export * from "./LocationMetadataResponse"; export * from "./MemberJoinGuildResponse"; export * from "./OAuthAuthorizeResponse"; export * from "./RefreshUrlsResponse"; +export * from "./SettingsProtoUpdateResponse"; export * from "./TeamListResponse"; export * from "./Tenor"; export * from "./TokenResponse";