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
This commit is contained in:
@@ -151,6 +151,12 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
{ name: 'password_changed_at', type: 'INTEGER' },
|
{ 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
|
// 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
|
// or it will race with the tenor_api_key → gif_api_key rename migration
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const users = sqliteTable('users', {
|
|||||||
discoverable: integer('discoverable').default(1),
|
discoverable: integer('discoverable').default(1),
|
||||||
profileUpdatedAt: integer('profile_updated_at'),
|
profileUpdatedAt: integer('profile_updated_at'),
|
||||||
passwordChangedAt: integer('password_changed_at'),
|
passwordChangedAt: integer('password_changed_at'),
|
||||||
|
showActivity: integer('show_activity').notNull().default(1),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const response: AuthResponse = {
|
const response: AuthResponse = {
|
||||||
token,
|
token,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(201).send(response);
|
return reply.code(201).send(response);
|
||||||
@@ -207,7 +207,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const response: AuthResponse = {
|
const response: AuthResponse = {
|
||||||
token,
|
token,
|
||||||
user: sanitizeUser({ ...user, status: 'online' }),
|
user: sanitizeUser({ ...user, status: 'online' }, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(200).send(response);
|
return reply.code(200).send(response);
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs
|
|||||||
id: dmChannel.id,
|
id: dmChannel.id,
|
||||||
ownerId: dmChannel.ownerId ?? null,
|
ownerId: dmChannel.ownerId ?? null,
|
||||||
createdAt: dmChannel.createdAt,
|
createdAt: dmChannel.createdAt,
|
||||||
members: users.map(sanitizeUser),
|
members: users.map(u => sanitizeUser(u)),
|
||||||
lastMessage: message,
|
lastMessage: message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -290,7 +290,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const members = memberIds
|
const members = memberIds
|
||||||
.map(id => userMap.get(id))
|
.map(id => userMap.get(id))
|
||||||
.filter((u): u is NonNullable<typeof u> => u !== undefined)
|
.filter((u): u is NonNullable<typeof u> => u !== undefined)
|
||||||
.map(sanitizeUser);
|
.map(u => sanitizeUser(u));
|
||||||
|
|
||||||
const lastMsg = lastMessageMap.get(channelId) ?? null;
|
const lastMsg = lastMessageMap.get(channelId) ?? null;
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
id: dmChannel.id,
|
id: dmChannel.id,
|
||||||
ownerId: dmChannel.ownerId ?? null,
|
ownerId: dmChannel.ownerId ?? null,
|
||||||
createdAt: dmChannel.createdAt,
|
createdAt: dmChannel.createdAt,
|
||||||
members: users.map(sanitizeUser),
|
members: users.map(u => sanitizeUser(u)),
|
||||||
lastMessage: lastMsg ? {
|
lastMessage: lastMsg ? {
|
||||||
id: lastMsg.id,
|
id: lastMsg.id,
|
||||||
dmChannelId: lastMsg.dmChannelId,
|
dmChannelId: lastMsg.dmChannelId,
|
||||||
@@ -446,7 +446,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
const members = [currentUserRow, targetUser]
|
const members = [currentUserRow, targetUser]
|
||||||
.filter((u): u is NonNullable<typeof u> => u !== undefined)
|
.filter((u): u is NonNullable<typeof u> => u !== undefined)
|
||||||
.map(sanitizeUser);
|
.map(u => sanitizeUser(u));
|
||||||
|
|
||||||
const result: DmChannel = {
|
const result: DmChannel = {
|
||||||
id: dmChannelId,
|
id: dmChannelId,
|
||||||
@@ -600,7 +600,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
id: dmChannel.id,
|
id: dmChannel.id,
|
||||||
ownerId: dmChannel.ownerId ?? null,
|
ownerId: dmChannel.ownerId ?? null,
|
||||||
createdAt: dmChannel.createdAt,
|
createdAt: dmChannel.createdAt,
|
||||||
members: users.map(sanitizeUser),
|
members: users.map(u => sanitizeUser(u)),
|
||||||
lastMessage: lastMsg ? {
|
lastMessage: lastMsg ? {
|
||||||
id: lastMsg.id,
|
id: lastMsg.id,
|
||||||
dmChannelId: lastMsg.dmChannelId,
|
dmChannelId: lastMsg.dmChannelId,
|
||||||
|
|||||||
@@ -502,6 +502,6 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.limit(10)
|
.limit(10)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return reply.code(200).send(users.map(sanitizeUser));
|
return reply.code(200).send(users.map(u => sanitizeUser(u)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 });
|
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
|
// POST /api/users/@me/verify-password — verify password matches current account
|
||||||
@@ -328,7 +328,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const storedTs = currentUser.profileUpdatedAt ?? currentUser.createdAt;
|
const storedTs = currentUser.profileUpdatedAt ?? currentUser.createdAt;
|
||||||
if (profileUpdatedAt < storedTs) {
|
if (profileUpdatedAt < storedTs) {
|
||||||
// Incoming data is older — return current state without updating
|
// 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<string, unknown>).profileUpdatedAt = profileUpdatedAt;
|
(updateData as Record<string, unknown>).profileUpdatedAt = profileUpdatedAt;
|
||||||
@@ -373,7 +373,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
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
|
// Broadcast presence update if status changed
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
@@ -681,7 +681,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const mutualFriendIds = [...myFriendIds].filter((id) => targetFriendIds.has(id));
|
const mutualFriendIds = [...myFriendIds].filter((id) => targetFriendIds.has(id));
|
||||||
|
|
||||||
const mutualFriends = mutualFriendIds.length > 0
|
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
|
// Mutual spaces: spaces both me and the target are members of
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { User, ReplicatedInstance } from '@backspace/shared';
|
import type { User, ReplicatedInstance } from '@backspace/shared';
|
||||||
import { schema } from '../db/index.js';
|
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
|
// Tombstoned (deleted) users — return anonymized profile
|
||||||
if (row.isDeleted === 1) {
|
if (row.isDeleted === 1) {
|
||||||
return {
|
return {
|
||||||
@@ -23,6 +23,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
homeInstance: null,
|
homeInstance: null,
|
||||||
homeUserId: null,
|
homeUserId: null,
|
||||||
replicatedInstances: [],
|
replicatedInstances: [],
|
||||||
|
...(isSelf ? { showActivity: false } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,5 +54,6 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
homeInstance: row.homeInstance ?? null,
|
homeInstance: row.homeInstance ?? null,
|
||||||
homeUserId: row.homeUserId ?? null,
|
homeUserId: row.homeUserId ?? null,
|
||||||
replicatedInstances,
|
replicatedInstances,
|
||||||
|
...(isSelf ? { showActivity: row.showActivity !== 0 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -682,7 +682,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
if (!userRow) {
|
if (!userRow) {
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
}
|
}
|
||||||
const user = sanitizeUser(userRow);
|
const user = sanitizeUser(userRow, true);
|
||||||
|
|
||||||
// Get user's space memberships
|
// Get user's space memberships
|
||||||
const memberships = db.select()
|
const memberships = db.select()
|
||||||
@@ -931,7 +931,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
const members = memberRows
|
const members = memberRows
|
||||||
.map(m => dmUserMap.get(m.userId))
|
.map(m => dmUserMap.get(m.userId))
|
||||||
.filter((u): u is NonNullable<typeof u> => u != null)
|
.filter((u): u is NonNullable<typeof u> => u != null)
|
||||||
.map(sanitizeUser);
|
.map(u => sanitizeUser(u));
|
||||||
|
|
||||||
const last = dmLastMsgMap.get(dm.dmChannelId) ?? null;
|
const last = dmLastMsgMap.get(dm.dmChannelId) ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/types.ts",
|
".": "./src/types.ts",
|
||||||
"./src/permissions": "./src/permissions.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": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -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<ActivityType, number> = {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export interface User {
|
|||||||
homeInstance: string | null;
|
homeInstance: string | null;
|
||||||
homeUserId: string | null;
|
homeUserId: string | null;
|
||||||
replicatedInstances: ReplicatedInstance[];
|
replicatedInstances: ReplicatedInstance[];
|
||||||
|
showActivity?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReplicatedInstance {
|
export interface ReplicatedInstance {
|
||||||
@@ -281,6 +282,32 @@ export interface DmMessageWithUser extends DmMessage {
|
|||||||
replyTo?: DmMessageWithUser | null;
|
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 ──────────────────────────────────────────────────
|
// ─── WebSocket Event Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
// Client → Server Events
|
// Client → Server Events
|
||||||
@@ -310,16 +337,17 @@ export type ClientEvent =
|
|||||||
| { type: 'voice_space_deafen'; userId: string; deafened: boolean }
|
| { type: 'voice_space_deafen'; userId: string; deafened: boolean }
|
||||||
| { type: 'voice_move'; userId: string; targetChannelId: string }
|
| { type: 'voice_move'; userId: string; targetChannelId: string }
|
||||||
| { type: 'voice_disconnect'; userId: string }
|
| { type: 'voice_disconnect'; userId: string }
|
||||||
|
| { type: 'activity_update'; activities: Activity[] }
|
||||||
| { type: 'ping' };
|
| { type: 'ping' };
|
||||||
|
|
||||||
// Server → Client Events
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
export type ServerEvent =
|
||||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }> }
|
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]> }
|
||||||
| { type: 'message_created'; message: MessageWithUser }
|
| { type: 'message_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
| { type: 'typing'; channelId: string; userId: string; username: 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: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave' }
|
||||||
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
||||||
| { type: 'member_left'; spaceId: string; userId: string }
|
| { type: 'member_left'; spaceId: string; userId: string }
|
||||||
@@ -435,6 +463,7 @@ export interface UpdateUserRequest {
|
|||||||
homeUserId?: string;
|
homeUserId?: string;
|
||||||
profileUpdatedAt?: number;
|
profileUpdatedAt?: number;
|
||||||
discoverable?: boolean;
|
discoverable?: boolean;
|
||||||
|
showActivity?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemberRequest {
|
export interface UpdateMemberRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user