From bc408235c9d59575078776e051f0479bc41a2952 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:38:36 +0100 Subject: [PATCH] feat: add Activity type system, DB migration, and self-only showActivity in sanitizeUser - Add Activity, ActivityType, ActivityTimestamps, ActivityAssets types to shared types - Add activity_update client event and activities field on presence_update server event - Add userActivities to ready payload and showActivity to User/UpdateUserRequest - Create shared activities.ts with ACTIVITY_LIMITS, ACTIVITY_PRIORITY, getPrimaryActivity - Add show_activity column to users table (schema + migration) - Update sanitizeUser with isSelf parameter; only include showActivity for self - Fix .map(sanitizeUser) calls to use arrow wrapper to prevent index-as-boolean bug - Mark auth routes (register/login) as isSelf=true since they return own user data --- packages/server/src/db/migrate.ts | 6 +++++ packages/server/src/db/schema.ts | 1 + packages/server/src/routes/auth.ts | 4 ++-- packages/server/src/routes/dm.ts | 10 ++++---- packages/server/src/routes/social.ts | 2 +- packages/server/src/routes/users.ts | 8 +++---- packages/server/src/utils/sanitize.ts | 4 +++- packages/server/src/ws/handler.ts | 4 ++-- packages/shared/package.json | 4 +++- packages/shared/src/activities.ts | 25 ++++++++++++++++++++ packages/shared/src/types.ts | 33 +++++++++++++++++++++++++-- 11 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 packages/shared/src/activities.ts diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 798d1be5..4dafb1e6 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -151,6 +151,12 @@ export function runMigrations(db: Database.Database): void { { name: 'password_changed_at', type: 'INTEGER' }, ] }, + { + name: 'users', + columns: [ + { name: 'show_activity', type: 'INTEGER NOT NULL DEFAULT 1' }, + ] + }, // gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here // or it will race with the tenor_api_key → gif_api_key rename migration ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 6abb68c3..946c0344 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -20,6 +20,7 @@ export const users = sqliteTable('users', { discoverable: integer('discoverable').default(1), profileUpdatedAt: integer('profile_updated_at'), passwordChangedAt: integer('password_changed_at'), + showActivity: integer('show_activity').notNull().default(1), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index f9a7ab36..861dfd18 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -122,7 +122,7 @@ export async function authRoutes(app: FastifyInstance): Promise { const response: AuthResponse = { token, - user: sanitizeUser(user), + user: sanitizeUser(user, true), }; return reply.code(201).send(response); @@ -207,7 +207,7 @@ export async function authRoutes(app: FastifyInstance): Promise { const response: AuthResponse = { token, - user: sanitizeUser({ ...user, status: 'online' }), + user: sanitizeUser({ ...user, status: 'online' }, true), }; return reply.code(200).send(response); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index ef6a440d..7aef41bb 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -192,7 +192,7 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs id: dmChannel.id, ownerId: dmChannel.ownerId ?? null, createdAt: dmChannel.createdAt, - members: users.map(sanitizeUser), + members: users.map(u => sanitizeUser(u)), lastMessage: message, }, }); @@ -290,7 +290,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { const members = memberIds .map(id => userMap.get(id)) .filter((u): u is NonNullable => u !== undefined) - .map(sanitizeUser); + .map(u => sanitizeUser(u)); const lastMsg = lastMessageMap.get(channelId) ?? null; @@ -407,7 +407,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { id: dmChannel.id, ownerId: dmChannel.ownerId ?? null, createdAt: dmChannel.createdAt, - members: users.map(sanitizeUser), + members: users.map(u => sanitizeUser(u)), lastMessage: lastMsg ? { id: lastMsg.id, dmChannelId: lastMsg.dmChannelId, @@ -446,7 +446,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); const members = [currentUserRow, targetUser] .filter((u): u is NonNullable => u !== undefined) - .map(sanitizeUser); + .map(u => sanitizeUser(u)); const result: DmChannel = { id: dmChannelId, @@ -600,7 +600,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { id: dmChannel.id, ownerId: dmChannel.ownerId ?? null, createdAt: dmChannel.createdAt, - members: users.map(sanitizeUser), + members: users.map(u => sanitizeUser(u)), lastMessage: lastMsg ? { id: lastMsg.id, dmChannelId: lastMsg.dmChannelId, diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 43ede34f..fadd4006 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -502,6 +502,6 @@ export async function socialRoutes(app: FastifyInstance): Promise { .limit(10) .all(); - return reply.code(200).send(users.map(sanitizeUser)); + return reply.code(200).send(users.map(u => sanitizeUser(u))); }); } diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 261b0b75..29ad1d72 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -33,7 +33,7 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 }); } - return reply.code(200).send(sanitizeUser(user)); + return reply.code(200).send(sanitizeUser(user, true)); }); // POST /api/users/@me/verify-password — verify password matches current account @@ -328,7 +328,7 @@ export async function userRoutes(app: FastifyInstance): Promise { const storedTs = currentUser.profileUpdatedAt ?? currentUser.createdAt; if (profileUpdatedAt < storedTs) { // Incoming data is older — return current state without updating - return reply.code(200).send(sanitizeUser(currentUser)); + return reply.code(200).send(sanitizeUser(currentUser, true)); } } (updateData as Record).profileUpdatedAt = profileUpdatedAt; @@ -373,7 +373,7 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } - const sanitized = sanitizeUser(updatedUser); + const sanitized = sanitizeUser(updatedUser, true); // Broadcast presence update if status changed if (status !== undefined) { @@ -681,7 +681,7 @@ export async function userRoutes(app: FastifyInstance): Promise { const mutualFriendIds = [...myFriendIds].filter((id) => targetFriendIds.has(id)); const mutualFriends = mutualFriendIds.length > 0 - ? db.select().from(schema.users).where(inArray(schema.users.id, mutualFriendIds)).all().map(sanitizeUser) + ? db.select().from(schema.users).where(inArray(schema.users.id, mutualFriendIds)).all().map(u => sanitizeUser(u)) : []; // Mutual spaces: spaces both me and the target are members of diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts index b64918fb..7340fd55 100644 --- a/packages/server/src/utils/sanitize.ts +++ b/packages/server/src/utils/sanitize.ts @@ -1,7 +1,7 @@ import type { User, ReplicatedInstance } from '@backspace/shared'; import { schema } from '../db/index.js'; -export function sanitizeUser(row: typeof schema.users.$inferSelect): User { +export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = false): User { // Tombstoned (deleted) users — return anonymized profile if (row.isDeleted === 1) { return { @@ -23,6 +23,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User { homeInstance: null, homeUserId: null, replicatedInstances: [], + ...(isSelf ? { showActivity: false } : {}), }; } @@ -53,5 +54,6 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User { homeInstance: row.homeInstance ?? null, homeUserId: row.homeUserId ?? null, replicatedInstances, + ...(isSelf ? { showActivity: row.showActivity !== 0 } : {}), }; } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 3b096da0..bef9ead7 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -682,7 +682,7 @@ function buildReadyPayload(userId: string): { if (!userRow) { throw new Error('User not found'); } - const user = sanitizeUser(userRow); + const user = sanitizeUser(userRow, true); // Get user's space memberships const memberships = db.select() @@ -931,7 +931,7 @@ function buildReadyPayload(userId: string): { const members = memberRows .map(m => dmUserMap.get(m.userId)) .filter((u): u is NonNullable => u != null) - .map(sanitizeUser); + .map(u => sanitizeUser(u)); const last = dmLastMsgMap.get(dm.dmChannelId) ?? null; diff --git a/packages/shared/package.json b/packages/shared/package.json index ec32f943..89a22f6b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -8,7 +8,9 @@ "exports": { ".": "./src/types.ts", "./src/permissions": "./src/permissions.ts", - "./src/permissions.js": "./src/permissions.ts" + "./src/permissions.js": "./src/permissions.ts", + "./src/activities": "./src/activities.ts", + "./src/activities.js": "./src/activities.ts" }, "scripts": { "build": "tsc", diff --git a/packages/shared/src/activities.ts b/packages/shared/src/activities.ts new file mode 100644 index 00000000..8d013681 --- /dev/null +++ b/packages/shared/src/activities.ts @@ -0,0 +1,25 @@ +import type { Activity, ActivityType } from './types.js'; + +export const ACTIVITY_LIMITS = { + MAX_ACTIVITIES_PER_USER: 5, + MAX_NAME_LENGTH: 128, + MAX_DETAILS_LENGTH: 128, + MAX_STATE_LENGTH: 128, + MAX_ASSET_TEXT_LENGTH: 128, + MAX_URL_LENGTH: 512, +} as const; + +export const ACTIVITY_PRIORITY: Record = { + streaming: 5, + playing: 4, + listening: 3, + watching: 2, + custom: 1, +}; + +export function getPrimaryActivity(activities: Activity[]): Activity | null { + if (!activities.length) return null; + return activities.reduce((best, current) => + ACTIVITY_PRIORITY[current.type] > ACTIVITY_PRIORITY[best.type] ? current : best + ); +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 3ca6a327..229969d3 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -26,6 +26,7 @@ export interface User { homeInstance: string | null; homeUserId: string | null; replicatedInstances: ReplicatedInstance[]; + showActivity?: boolean; } export interface ReplicatedInstance { @@ -281,6 +282,32 @@ export interface DmMessageWithUser extends DmMessage { replyTo?: DmMessageWithUser | null; } +// ─── Activity Types ──────────────────────────────────────────────────────── + +export type ActivityType = 'custom' | 'playing' | 'listening' | 'watching' | 'streaming'; + +export interface ActivityTimestamps { + start?: number; + end?: number; +} + +export interface ActivityAssets { + largeImage?: string; + largeText?: string; + smallImage?: string; + smallText?: string; +} + +export interface Activity { + type: ActivityType; + name: string; + details?: string; + state?: string; + timestamps?: ActivityTimestamps; + assets?: ActivityAssets; + url?: string; +} + // ─── WebSocket Event Types ────────────────────────────────────────────────── // Client → Server Events @@ -310,16 +337,17 @@ export type ClientEvent = | { type: 'voice_space_deafen'; userId: string; deafened: boolean } | { type: 'voice_move'; userId: string; targetChannelId: string } | { type: 'voice_disconnect'; userId: string } + | { type: 'activity_update'; activities: Activity[] } | { type: 'ping' }; // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record } + | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record; userActivities?: Record } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } | { type: 'typing'; channelId: string; userId: string; username: string } - | { type: 'presence_update'; userId: string; status: string } + | { type: 'presence_update'; userId: string; status: string; activities?: Activity[] } | { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave' } | { type: 'member_joined'; spaceId: string; member: MemberWithUser } | { type: 'member_left'; spaceId: string; userId: string } @@ -435,6 +463,7 @@ export interface UpdateUserRequest { homeUserId?: string; profileUpdatedAt?: number; discoverable?: boolean; + showActivity?: boolean; } export interface UpdateMemberRequest {