feat(aim): add Convex AIM backend, sounds, window manager and XP window shell
- commit
- 2a6dd11d638caca76be26713564ba51f378fbc26
- author
- Kit · · 2026-09-27 12:39 UTC
- parent
- 66b04ed
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
31 files · +1436 -21
M src/convex/_generated/api.d.ts +2 -0
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | * @module | |
| 9 | 9 | */ | |
| 10 | 10 | ||
| 11 | + | import type * as aim from "../aim.js"; | |
| 11 | 12 | import type * as auth from "../auth.js"; | |
| 12 | 13 | import type * as chatRoomsInternal from "../chatRoomsInternal.js"; | |
| 13 | 14 | import type * as crons from "../crons.js"; | |
| @@ -26,6 +27,7 @@ import type { | |||
| 26 | 27 | } from "convex/server"; | |
| 27 | 28 | ||
| 28 | 29 | declare const fullApi: ApiFromModules<{ | |
| 30 | + | aim: typeof aim; | |
| 29 | 31 | auth: typeof auth; | |
| 30 | 32 | chatRoomsInternal: typeof chatRoomsInternal; | |
| 31 | 33 | crons: typeof crons; | |
| @@ -8,6 +8,7 @@ | |||||
| 8 | * @module | 8 | * @module | ||
| 9 | */ | 9 | */ | ||
| 10 | 10 | ||||
| 11 | + | import type * as aim from "../aim.js"; | |||
| 11 | import type * as auth from "../auth.js"; | 12 | import type * as auth from "../auth.js"; | ||
| 12 | import type * as chatRoomsInternal from "../chatRoomsInternal.js"; | 13 | import type * as chatRoomsInternal from "../chatRoomsInternal.js"; | ||
| 13 | import type * as crons from "../crons.js"; | 14 | import type * as crons from "../crons.js"; | ||
| @@ -26,6 +27,7 @@ import type { | |||||
| 26 | } from "convex/server"; | 27 | } from "convex/server"; | ||
| 27 | 28 | ||||
| 28 | declare const fullApi: ApiFromModules<{ | 29 | declare const fullApi: ApiFromModules<{ | ||
| 30 | + | aim: typeof aim; | |||
| 29 | auth: typeof auth; | 31 | auth: typeof auth; | ||
| 30 | chatRoomsInternal: typeof chatRoomsInternal; | 32 | chatRoomsInternal: typeof chatRoomsInternal; | ||
| 31 | crons: typeof crons; | 33 | crons: typeof crons; | ||
M src/convex/_generated/server.d.ts +19 -0
| @@ -21,6 +21,17 @@ import { | |||
| 21 | 21 | } from "convex/server"; | |
| 22 | 22 | import type { DataModel } from "./dataModel.js"; | |
| 23 | 23 | ||
| 24 | + | /** | |
| 25 | + | * Typesafe environment variables. | |
| 26 | + | * | |
| 27 | + | * This includes platform-provided env vars and any variables declared in | |
| 28 | + | * `convex.config.ts`. | |
| 29 | + | */ | |
| 30 | + | type Env = { | |
| 31 | + | readonly CONVEX_CLOUD_URL: string; | |
| 32 | + | readonly CONVEX_SITE_URL: string; | |
| 33 | + | }; | |
| 34 | + | ||
| 24 | 35 | /** | |
| 25 | 36 | * Define a query in this Convex app's public API. | |
| 26 | 37 | * | |
| @@ -95,6 +106,14 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">; | |||
| 95 | 106 | */ | |
| 96 | 107 | export declare const httpAction: HttpActionBuilder; | |
| 97 | 108 | ||
| 109 | + | /** | |
| 110 | + | * Typesafe environment variables. | |
| 111 | + | * | |
| 112 | + | * This includes platform-provided env vars and any variables declared in | |
| 113 | + | * `convex.config.ts`. | |
| 114 | + | */ | |
| 115 | + | export declare const env: Env; | |
| 116 | + | ||
| 98 | 117 | /** | |
| 99 | 118 | * A set of services for use within Convex query functions. | |
| 100 | 119 | * | |
| @@ -21,6 +21,17 @@ import { | |||||
| 21 | } from "convex/server"; | 21 | } from "convex/server"; | ||
| 22 | import type { DataModel } from "./dataModel.js"; | 22 | import type { DataModel } from "./dataModel.js"; | ||
| 23 | 23 | ||||
| 24 | + | /** | |||
| 25 | + | * Typesafe environment variables. | |||
| 26 | + | * | |||
| 27 | + | * This includes platform-provided env vars and any variables declared in | |||
| 28 | + | * `convex.config.ts`. | |||
| 29 | + | */ | |||
| 30 | + | type Env = { | |||
| 31 | + | readonly CONVEX_CLOUD_URL: string; | |||
| 32 | + | readonly CONVEX_SITE_URL: string; | |||
| 33 | + | }; | |||
| 34 | + | ||||
| 24 | /** | 35 | /** | ||
| 25 | * Define a query in this Convex app's public API. | 36 | * Define a query in this Convex app's public API. | ||
| 26 | * | 37 | * | ||
| @@ -95,6 +106,14 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">; | |||||
| 95 | */ | 106 | */ | ||
| 96 | export declare const httpAction: HttpActionBuilder; | 107 | export declare const httpAction: HttpActionBuilder; | ||
| 97 | 108 | ||||
| 109 | + | /** | |||
| 110 | + | * Typesafe environment variables. | |||
| 111 | + | * | |||
| 112 | + | * This includes platform-provided env vars and any variables declared in | |||
| 113 | + | * `convex.config.ts`. | |||
| 114 | + | */ | |||
| 115 | + | export declare const env: Env; | |||
| 116 | + | ||||
| 98 | /** | 117 | /** | ||
| 99 | * A set of services for use within Convex query functions. | 118 | * A set of services for use within Convex query functions. | ||
| 100 | * | 119 | * | ||
M src/convex/_generated/server.js +8 -0
| @@ -91,3 +91,11 @@ export const internalAction = internalActionGeneric; | |||
| 91 | 91 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 92 | 92 | */ | |
| 93 | 93 | export const httpAction = httpActionGeneric; | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * Typesafe environment variables. | |
| 97 | + | * | |
| 98 | + | * This includes platform-provided env vars and any variables declared in | |
| 99 | + | * `convex.config.ts`. | |
| 100 | + | */ | |
| 101 | + | export const env = process.env; | |
| @@ -91,3 +91,11 @@ export const internalAction = internalActionGeneric; | |||||
| 91 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | 91 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | ||
| 92 | */ | 92 | */ | ||
| 93 | export const httpAction = httpActionGeneric; | 93 | export const httpAction = httpActionGeneric; | ||
| 94 | + | ||||
| 95 | + | /** | |||
| 96 | + | * Typesafe environment variables. | |||
| 97 | + | * | |||
| 98 | + | * This includes platform-provided env vars and any variables declared in | |||
| 99 | + | * `convex.config.ts`. | |||
| 100 | + | */ | |||
| 101 | + | export const env = process.env; | |||
A src/convex/aim.ts +385 -0
| @@ -0,0 +1,385 @@ | |||
| 1 | + | /** | |
| 2 | + | * AIM features: buddy list, direct (IM) rooms, away messages, profiles, | |
| 3 | + | * typing indicators and read cursors. | |
| 4 | + | * | |
| 5 | + | * Client-side access uses authQuery/authMutation with the session token hash | |
| 6 | + | * (already exposed to the client as `session.id`). | |
| 7 | + | */ | |
| 8 | + | import { query } from './_generated/server'; | |
| 9 | + | import { authQuery, authMutation } from './auth'; | |
| 10 | + | import { v } from 'convex/values'; | |
| 11 | + | import type { Doc, Id } from './_generated/dataModel'; | |
| 12 | + | import type { QueryCtx } from './_generated/server'; | |
| 13 | + | ||
| 14 | + | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; | |
| 15 | + | const TYPING_TTL_MS = 6 * 1000; | |
| 16 | + | const MAX_AWAY_LENGTH = 300; | |
| 17 | + | const MAX_PROFILE_LENGTH = 1000; | |
| 18 | + | const MAX_ROOM_NAME_LENGTH = 40; | |
| 19 | + | const MAX_TOPIC_LENGTH = 120; | |
| 20 | + | const DEFAULT_GROUP = 'Buddies'; | |
| 21 | + | ||
| 22 | + | export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline'; | |
| 23 | + | ||
| 24 | + | export function computeEffectiveStatus( | |
| 25 | + | status: string, | |
| 26 | + | lastSeen: number | undefined | |
| 27 | + | ): EffectiveStatus { | |
| 28 | + | if (status === 'offline') return 'offline'; | |
| 29 | + | if ((lastSeen ?? 0) < Date.now() - ONLINE_TIMEOUT_MS) return 'offline'; | |
| 30 | + | return status as EffectiveStatus; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export function toPublicUser(user: Doc<'users'>) { | |
| 34 | + | const status = computeEffectiveStatus(user.status, user.lastSeen); | |
| 35 | + | return { | |
| 36 | + | id: user._id, | |
| 37 | + | nickname: user.nickname, | |
| 38 | + | status, | |
| 39 | + | avatarUrl: user.avatarUrl, | |
| 40 | + | lastSeen: user.lastSeen, | |
| 41 | + | awayMessage: status === 'away' ? user.awayMessage : undefined, | |
| 42 | + | buddyIcon: user.buddyIcon, | |
| 43 | + | createdAt: user.createdAt | |
| 44 | + | }; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export function directKeyFor(a: Id<'users'>, b: Id<'users'>) { | |
| 48 | + | return [a, b].sort().join(':'); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | export async function assertRoomAccess( | |
| 52 | + | ctx: QueryCtx, | |
| 53 | + | room: Doc<'chatRooms'>, | |
| 54 | + | userId: Id<'users'> | |
| 55 | + | ) { | |
| 56 | + | if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) { | |
| 57 | + | throw new Error('Not a member of this conversation'); | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // ============ PUBLIC ============ | |
| 62 | + | ||
| 63 | + | export const getUserProfile = query({ | |
| 64 | + | args: { userId: v.id('users') }, | |
| 65 | + | handler: async (ctx, args) => { | |
| 66 | + | const user = await ctx.db.get(args.userId); | |
| 67 | + | if (!user) return null; | |
| 68 | + | return { ...toPublicUser(user), profile: user.profile }; | |
| 69 | + | } | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | export const getGroupRooms = query({ | |
| 73 | + | args: {}, | |
| 74 | + | handler: async (ctx) => { | |
| 75 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 76 | + | return rooms | |
| 77 | + | .filter((r) => r.type === 'group') | |
| 78 | + | .sort((a, b) => a.createdAt - b.createdAt) | |
| 79 | + | .map((r) => ({ | |
| 80 | + | id: r._id, | |
| 81 | + | name: r.name ?? 'Sans nom', | |
| 82 | + | topic: r.topic, | |
| 83 | + | createdAt: r.createdAt, | |
| 84 | + | isDefault: r.name === 'General' | |
| 85 | + | })); | |
| 86 | + | } | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | // ============ AUTHENTICATED QUERIES ============ | |
| 90 | + | ||
| 91 | + | export const getMyBuddies = authQuery({ | |
| 92 | + | args: {}, | |
| 93 | + | handler: async (ctx) => { | |
| 94 | + | const rows = await ctx.db | |
| 95 | + | .query('buddies') | |
| 96 | + | .withIndex('by_user', (q) => q.eq('userId', ctx.user._id)) | |
| 97 | + | .collect(); | |
| 98 | + | const buddies = await Promise.all( | |
| 99 | + | rows.map(async (row) => { | |
| 100 | + | const user = await ctx.db.get(row.buddyId); | |
| 101 | + | if (!user) return null; | |
| 102 | + | return { ...toPublicUser(user), group: row.group, addedAt: row.createdAt }; | |
| 103 | + | }) | |
| 104 | + | ); | |
| 105 | + | return buddies.filter((b) => b !== null); | |
| 106 | + | } | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | export const getMyDirectRooms = authQuery({ | |
| 110 | + | args: {}, | |
| 111 | + | handler: async (ctx) => { | |
| 112 | + | const me = ctx.user._id; | |
| 113 | + | const rooms = (await ctx.db.query('chatRooms').collect()).filter( | |
| 114 | + | (r) => r.type === 'direct' && (r.memberIds ?? []).includes(me) | |
| 115 | + | ); | |
| 116 | + | const reads = await ctx.db | |
| 117 | + | .query('roomReads') | |
| 118 | + | .withIndex('by_user', (q) => q.eq('userId', me)) | |
| 119 | + | .collect(); | |
| 120 | + | const readMap = new Map(reads.map((r) => [r.roomId, r.lastReadTimestamp])); | |
| 121 | + | ||
| 122 | + | const result = await Promise.all( | |
| 123 | + | rooms.map(async (room) => { | |
| 124 | + | const otherId = (room.memberIds ?? []).find((id) => id !== me); | |
| 125 | + | const other = otherId ? await ctx.db.get(otherId) : null; | |
| 126 | + | const lastRead = readMap.get(room._id) ?? 0; | |
| 127 | + | const recent = await ctx.db | |
| 128 | + | .query('messages') | |
| 129 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id)) | |
| 130 | + | .order('desc') | |
| 131 | + | .take(50); | |
| 132 | + | const lastMessage = recent[0] ?? null; | |
| 133 | + | const unreadCount = recent.filter( | |
| 134 | + | (m) => m.timestamp > lastRead && m.senderId !== me | |
| 135 | + | ).length; | |
| 136 | + | return { | |
| 137 | + | id: room._id, | |
| 138 | + | other: other ? toPublicUser(other) : null, | |
| 139 | + | lastMessage: lastMessage | |
| 140 | + | ? { | |
| 141 | + | content: lastMessage.content, | |
| 142 | + | timestamp: lastMessage.timestamp, | |
| 143 | + | senderId: lastMessage.senderId | |
| 144 | + | } | |
| 145 | + | : null, | |
| 146 | + | unreadCount | |
| 147 | + | }; | |
| 148 | + | }) | |
| 149 | + | ); | |
| 150 | + | return result.sort( | |
| 151 | + | (a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0) | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | }); | |
| 155 | + | ||
| 156 | + | export const getRoomMessages = authQuery({ | |
| 157 | + | args: { roomId: v.id('chatRooms'), limit: v.optional(v.number()) }, | |
| 158 | + | handler: async (ctx, args) => { | |
| 159 | + | const room = await ctx.db.get(args.roomId); | |
| 160 | + | if (!room) return []; | |
| 161 | + | await assertRoomAccess(ctx, room, ctx.user._id); | |
| 162 | + | const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 100))); | |
| 163 | + | const messages = await ctx.db | |
| 164 | + | .query('messages') | |
| 165 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 166 | + | .order('desc') | |
| 167 | + | .take(limit); | |
| 168 | + | const enriched = await Promise.all( | |
| 169 | + | messages.map(async (msg) => { | |
| 170 | + | const sender = await ctx.db.get(msg.senderId); | |
| 171 | + | return { | |
| 172 | + | id: msg._id, | |
| 173 | + | chatRoomId: msg.chatRoomId, | |
| 174 | + | senderId: msg.senderId, | |
| 175 | + | content: msg.content, | |
| 176 | + | type: msg.type, | |
| 177 | + | timestamp: msg.timestamp, | |
| 178 | + | styleData: msg.styleData, | |
| 179 | + | hasFormatting: msg.hasFormatting, | |
| 180 | + | sender: sender | |
| 181 | + | ? { | |
| 182 | + | id: sender._id, | |
| 183 | + | nickname: sender.nickname, | |
| 184 | + | status: computeEffectiveStatus(sender.status, sender.lastSeen), | |
| 185 | + | avatarUrl: sender.avatarUrl, | |
| 186 | + | buddyIcon: sender.buddyIcon | |
| 187 | + | } | |
| 188 | + | : null | |
| 189 | + | }; | |
| 190 | + | }) | |
| 191 | + | ); | |
| 192 | + | return enriched.reverse(); | |
| 193 | + | } | |
| 194 | + | }); | |
| 195 | + | ||
| 196 | + | export const getRoomTyping = authQuery({ | |
| 197 | + | args: { roomId: v.id('chatRooms') }, | |
| 198 | + | handler: async (ctx, args) => { | |
| 199 | + | const since = Date.now() - TYPING_TTL_MS; | |
| 200 | + | const rows = await ctx.db | |
| 201 | + | .query('typing') | |
| 202 | + | .withIndex('by_room', (q) => q.eq('roomId', args.roomId)) | |
| 203 | + | .collect(); | |
| 204 | + | const active = rows.filter((r) => r.updatedAt > since && r.userId !== ctx.user._id); | |
| 205 | + | const users = await Promise.all(active.map((r) => ctx.db.get(r.userId))); | |
| 206 | + | return users.filter((u) => u !== null).map((u) => ({ id: u._id, nickname: u.nickname })); | |
| 207 | + | } | |
| 208 | + | }); | |
| 209 | + | ||
| 210 | + | export const getMe = authQuery({ | |
| 211 | + | args: {}, | |
| 212 | + | handler: async (ctx) => { | |
| 213 | + | const u = ctx.user; | |
| 214 | + | return { | |
| 215 | + | id: u._id, | |
| 216 | + | nickname: u.nickname, | |
| 217 | + | status: u.status, | |
| 218 | + | awayMessage: u.awayMessage, | |
| 219 | + | profile: u.profile, | |
| 220 | + | buddyIcon: u.buddyIcon, | |
| 221 | + | createdAt: u.createdAt | |
| 222 | + | }; | |
| 223 | + | } | |
| 224 | + | }); | |
| 225 | + | ||
| 226 | + | // ============ AUTHENTICATED MUTATIONS ============ | |
| 227 | + | ||
| 228 | + | export const setAwayMessage = authMutation({ | |
| 229 | + | args: { awayMessage: v.optional(v.string()) }, | |
| 230 | + | handler: async (ctx, args) => { | |
| 231 | + | const text = args.awayMessage?.trim().slice(0, MAX_AWAY_LENGTH); | |
| 232 | + | if (text) { | |
| 233 | + | await ctx.db.patch(ctx.user._id, { awayMessage: text, status: 'away', lastSeen: Date.now() }); | |
| 234 | + | } else { | |
| 235 | + | await ctx.db.patch(ctx.user._id, { | |
| 236 | + | awayMessage: undefined, | |
| 237 | + | status: 'online', | |
| 238 | + | lastSeen: Date.now() | |
| 239 | + | }); | |
| 240 | + | } | |
| 241 | + | return { success: true }; | |
| 242 | + | } | |
| 243 | + | }); | |
| 244 | + | ||
| 245 | + | export const updateProfile = authMutation({ | |
| 246 | + | args: { profile: v.optional(v.string()), buddyIcon: v.optional(v.string()) }, | |
| 247 | + | handler: async (ctx, args) => { | |
| 248 | + | const patch: Partial<Doc<'users'>> = {}; | |
| 249 | + | if (args.profile !== undefined) patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH); | |
| 250 | + | if (args.buddyIcon !== undefined) { | |
| 251 | + | if (!/^[a-z0-9-]{1,32}$/.test(args.buddyIcon)) throw new Error('Invalid buddy icon'); | |
| 252 | + | patch.buddyIcon = args.buddyIcon; | |
| 253 | + | } | |
| 254 | + | await ctx.db.patch(ctx.user._id, patch); | |
| 255 | + | return { success: true }; | |
| 256 | + | } | |
| 257 | + | }); | |
| 258 | + | ||
| 259 | + | export const addBuddy = authMutation({ | |
| 260 | + | args: { buddyId: v.id('users'), group: v.optional(v.string()) }, | |
| 261 | + | handler: async (ctx, args) => { | |
| 262 | + | if (args.buddyId === ctx.user._id) throw new Error('Impossible de s’ajouter soi-même'); | |
| 263 | + | const buddy = await ctx.db.get(args.buddyId); | |
| 264 | + | if (!buddy) throw new Error('User not found'); | |
| 265 | + | const group = (args.group?.trim() || DEFAULT_GROUP).slice(0, 30); | |
| 266 | + | const existing = await ctx.db | |
| 267 | + | .query('buddies') | |
| 268 | + | .withIndex('by_user_buddy', (q) => q.eq('userId', ctx.user._id).eq('buddyId', args.buddyId)) | |
| 269 | + | .first(); | |
| 270 | + | if (existing) { | |
| 271 | + | await ctx.db.patch(existing._id, { group }); | |
| 272 | + | return { success: true, id: existing._id }; | |
| 273 | + | } | |
| 274 | + | const id = await ctx.db.insert('buddies', { | |
| 275 | + | userId: ctx.user._id, | |
| 276 | + | buddyId: args.buddyId, | |
| 277 | + | group, | |
| 278 | + | createdAt: Date.now() | |
| 279 | + | }); | |
| 280 | + | return { success: true, id }; | |
| 281 | + | } | |
| 282 | + | }); | |
| 283 | + | ||
| 284 | + | export const removeBuddy = authMutation({ | |
| 285 | + | args: { buddyId: v.id('users') }, | |
| 286 | + | handler: async (ctx, args) => { | |
| 287 | + | const existing = await ctx.db | |
| 288 | + | .query('buddies') | |
| 289 | + | .withIndex('by_user_buddy', (q) => q.eq('userId', ctx.user._id).eq('buddyId', args.buddyId)) | |
| 290 | + | .first(); | |
| 291 | + | if (existing) await ctx.db.delete(existing._id); | |
| 292 | + | return { success: true }; | |
| 293 | + | } | |
| 294 | + | }); | |
| 295 | + | ||
| 296 | + | export const getOrCreateDirectRoom = authMutation({ | |
| 297 | + | args: { otherUserId: v.id('users') }, | |
| 298 | + | handler: async (ctx, args) => { | |
| 299 | + | if (args.otherUserId === ctx.user._id) throw new Error('Cannot IM yourself'); | |
| 300 | + | const other = await ctx.db.get(args.otherUserId); | |
| 301 | + | if (!other) throw new Error('User not found'); | |
| 302 | + | const key = directKeyFor(ctx.user._id, args.otherUserId); | |
| 303 | + | const existing = await ctx.db | |
| 304 | + | .query('chatRooms') | |
| 305 | + | .withIndex('by_directKey', (q) => q.eq('directKey', key)) | |
| 306 | + | .first(); | |
| 307 | + | if (existing) return { id: existing._id }; | |
| 308 | + | const id = await ctx.db.insert('chatRooms', { | |
| 309 | + | type: 'direct', | |
| 310 | + | createdAt: Date.now(), | |
| 311 | + | createdBy: ctx.user._id, | |
| 312 | + | memberIds: [ctx.user._id, args.otherUserId], | |
| 313 | + | directKey: key | |
| 314 | + | }); | |
| 315 | + | return { id }; | |
| 316 | + | } | |
| 317 | + | }); | |
| 318 | + | ||
| 319 | + | export const createGroupRoom = authMutation({ | |
| 320 | + | args: { name: v.string(), topic: v.optional(v.string()) }, | |
| 321 | + | handler: async (ctx, args) => { | |
| 322 | + | const name = args.name.trim().slice(0, MAX_ROOM_NAME_LENGTH); | |
| 323 | + | if (name.length < 2) throw new Error('Nom de salon trop court'); | |
| 324 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 325 | + | const clash = rooms.find( | |
| 326 | + | (r) => r.type === 'group' && (r.name ?? '').toLowerCase() === name.toLowerCase() | |
| 327 | + | ); | |
| 328 | + | if (clash) return { id: clash._id, existed: true }; | |
| 329 | + | const id = await ctx.db.insert('chatRooms', { | |
| 330 | + | name, | |
| 331 | + | type: 'group', | |
| 332 | + | topic: args.topic?.trim().slice(0, MAX_TOPIC_LENGTH) || undefined, | |
| 333 | + | createdAt: Date.now(), | |
| 334 | + | createdBy: ctx.user._id | |
| 335 | + | }); | |
| 336 | + | return { id, existed: false }; | |
| 337 | + | } | |
| 338 | + | }); | |
| 339 | + | ||
| 340 | + | export const markRoomRead = authMutation({ | |
| 341 | + | args: { roomId: v.id('chatRooms') }, | |
| 342 | + | handler: async (ctx, args) => { | |
| 343 | + | const now = Date.now(); | |
| 344 | + | const existing = await ctx.db | |
| 345 | + | .query('roomReads') | |
| 346 | + | .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId)) | |
| 347 | + | .first(); | |
| 348 | + | if (existing) { | |
| 349 | + | if (existing.lastReadTimestamp < now) { | |
| 350 | + | await ctx.db.patch(existing._id, { lastReadTimestamp: now }); | |
| 351 | + | } | |
| 352 | + | } else { | |
| 353 | + | await ctx.db.insert('roomReads', { | |
| 354 | + | userId: ctx.user._id, | |
| 355 | + | roomId: args.roomId, | |
| 356 | + | lastReadTimestamp: now | |
| 357 | + | }); | |
| 358 | + | } | |
| 359 | + | return { success: true }; | |
| 360 | + | } | |
| 361 | + | }); | |
| 362 | + | ||
| 363 | + | export const setTyping = authMutation({ | |
| 364 | + | args: { roomId: v.id('chatRooms'), typing: v.boolean() }, | |
| 365 | + | handler: async (ctx, args) => { | |
| 366 | + | const existing = await ctx.db | |
| 367 | + | .query('typing') | |
| 368 | + | .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId)) | |
| 369 | + | .first(); | |
| 370 | + | if (!args.typing) { | |
| 371 | + | if (existing) await ctx.db.delete(existing._id); | |
| 372 | + | return { success: true }; | |
| 373 | + | } | |
| 374 | + | if (existing) { | |
| 375 | + | await ctx.db.patch(existing._id, { updatedAt: Date.now() }); | |
| 376 | + | } else { | |
| 377 | + | await ctx.db.insert('typing', { | |
| 378 | + | userId: ctx.user._id, | |
| 379 | + | roomId: args.roomId, | |
| 380 | + | updatedAt: Date.now() | |
| 381 | + | }); | |
| 382 | + | } | |
| 383 | + | return { success: true }; | |
| 384 | + | } | |
| 385 | + | }); | |
| @@ -0,0 +1,385 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * AIM features: buddy list, direct (IM) rooms, away messages, profiles, | |||
| 3 | + | * typing indicators and read cursors. | |||
| 4 | + | * | |||
| 5 | + | * Client-side access uses authQuery/authMutation with the session token hash | |||
| 6 | + | * (already exposed to the client as `session.id`). | |||
| 7 | + | */ | |||
| 8 | + | import { query } from './_generated/server'; | |||
| 9 | + | import { authQuery, authMutation } from './auth'; | |||
| 10 | + | import { v } from 'convex/values'; | |||
| 11 | + | import type { Doc, Id } from './_generated/dataModel'; | |||
| 12 | + | import type { QueryCtx } from './_generated/server'; | |||
| 13 | + | ||||
| 14 | + | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; | |||
| 15 | + | const TYPING_TTL_MS = 6 * 1000; | |||
| 16 | + | const MAX_AWAY_LENGTH = 300; | |||
| 17 | + | const MAX_PROFILE_LENGTH = 1000; | |||
| 18 | + | const MAX_ROOM_NAME_LENGTH = 40; | |||
| 19 | + | const MAX_TOPIC_LENGTH = 120; | |||
| 20 | + | const DEFAULT_GROUP = 'Buddies'; | |||
| 21 | + | ||||
| 22 | + | export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline'; | |||
| 23 | + | ||||
| 24 | + | export function computeEffectiveStatus( | |||
| 25 | + | status: string, | |||
| 26 | + | lastSeen: number | undefined | |||
| 27 | + | ): EffectiveStatus { | |||
| 28 | + | if (status === 'offline') return 'offline'; | |||
| 29 | + | if ((lastSeen ?? 0) < Date.now() - ONLINE_TIMEOUT_MS) return 'offline'; | |||
| 30 | + | return status as EffectiveStatus; | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | export function toPublicUser(user: Doc<'users'>) { | |||
| 34 | + | const status = computeEffectiveStatus(user.status, user.lastSeen); | |||
| 35 | + | return { | |||
| 36 | + | id: user._id, | |||
| 37 | + | nickname: user.nickname, | |||
| 38 | + | status, | |||
| 39 | + | avatarUrl: user.avatarUrl, | |||
| 40 | + | lastSeen: user.lastSeen, | |||
| 41 | + | awayMessage: status === 'away' ? user.awayMessage : undefined, | |||
| 42 | + | buddyIcon: user.buddyIcon, | |||
| 43 | + | createdAt: user.createdAt | |||
| 44 | + | }; | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | export function directKeyFor(a: Id<'users'>, b: Id<'users'>) { | |||
| 48 | + | return [a, b].sort().join(':'); | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | export async function assertRoomAccess( | |||
| 52 | + | ctx: QueryCtx, | |||
| 53 | + | room: Doc<'chatRooms'>, | |||
| 54 | + | userId: Id<'users'> | |||
| 55 | + | ) { | |||
| 56 | + | if (room.type === 'direct' && !(room.memberIds ?? []).includes(userId)) { | |||
| 57 | + | throw new Error('Not a member of this conversation'); | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // ============ PUBLIC ============ | |||
| 62 | + | ||||
| 63 | + | export const getUserProfile = query({ | |||
| 64 | + | args: { userId: v.id('users') }, | |||
| 65 | + | handler: async (ctx, args) => { | |||
| 66 | + | const user = await ctx.db.get(args.userId); | |||
| 67 | + | if (!user) return null; | |||
| 68 | + | return { ...toPublicUser(user), profile: user.profile }; | |||
| 69 | + | } | |||
| 70 | + | }); | |||
| 71 | + | ||||
| 72 | + | export const getGroupRooms = query({ | |||
| 73 | + | args: {}, | |||
| 74 | + | handler: async (ctx) => { | |||
| 75 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |||
| 76 | + | return rooms | |||
| 77 | + | .filter((r) => r.type === 'group') | |||
| 78 | + | .sort((a, b) => a.createdAt - b.createdAt) | |||
| 79 | + | .map((r) => ({ | |||
| 80 | + | id: r._id, | |||
| 81 | + | name: r.name ?? 'Sans nom', | |||
| 82 | + | topic: r.topic, | |||
| 83 | + | createdAt: r.createdAt, | |||
| 84 | + | isDefault: r.name === 'General' | |||
| 85 | + | })); | |||
| 86 | + | } | |||
| 87 | + | }); | |||
| 88 | + | ||||
| 89 | + | // ============ AUTHENTICATED QUERIES ============ | |||
| 90 | + | ||||
| 91 | + | export const getMyBuddies = authQuery({ | |||
| 92 | + | args: {}, | |||
| 93 | + | handler: async (ctx) => { | |||
| 94 | + | const rows = await ctx.db | |||
| 95 | + | .query('buddies') | |||
| 96 | + | .withIndex('by_user', (q) => q.eq('userId', ctx.user._id)) | |||
| 97 | + | .collect(); | |||
| 98 | + | const buddies = await Promise.all( | |||
| 99 | + | rows.map(async (row) => { | |||
| 100 | + | const user = await ctx.db.get(row.buddyId); | |||
| 101 | + | if (!user) return null; | |||
| 102 | + | return { ...toPublicUser(user), group: row.group, addedAt: row.createdAt }; | |||
| 103 | + | }) | |||
| 104 | + | ); | |||
| 105 | + | return buddies.filter((b) => b !== null); | |||
| 106 | + | } | |||
| 107 | + | }); | |||
| 108 | + | ||||
| 109 | + | export const getMyDirectRooms = authQuery({ | |||
| 110 | + | args: {}, | |||
| 111 | + | handler: async (ctx) => { | |||
| 112 | + | const me = ctx.user._id; | |||
| 113 | + | const rooms = (await ctx.db.query('chatRooms').collect()).filter( | |||
| 114 | + | (r) => r.type === 'direct' && (r.memberIds ?? []).includes(me) | |||
| 115 | + | ); | |||
| 116 | + | const reads = await ctx.db | |||
| 117 | + | .query('roomReads') | |||
| 118 | + | .withIndex('by_user', (q) => q.eq('userId', me)) | |||
| 119 | + | .collect(); | |||
| 120 | + | const readMap = new Map(reads.map((r) => [r.roomId, r.lastReadTimestamp])); | |||
| 121 | + | ||||
| 122 | + | const result = await Promise.all( | |||
| 123 | + | rooms.map(async (room) => { | |||
| 124 | + | const otherId = (room.memberIds ?? []).find((id) => id !== me); | |||
| 125 | + | const other = otherId ? await ctx.db.get(otherId) : null; | |||
| 126 | + | const lastRead = readMap.get(room._id) ?? 0; | |||
| 127 | + | const recent = await ctx.db | |||
| 128 | + | .query('messages') | |||
| 129 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id)) | |||
| 130 | + | .order('desc') | |||
| 131 | + | .take(50); | |||
| 132 | + | const lastMessage = recent[0] ?? null; | |||
| 133 | + | const unreadCount = recent.filter( | |||
| 134 | + | (m) => m.timestamp > lastRead && m.senderId !== me | |||
| 135 | + | ).length; | |||
| 136 | + | return { | |||
| 137 | + | id: room._id, | |||
| 138 | + | other: other ? toPublicUser(other) : null, | |||
| 139 | + | lastMessage: lastMessage | |||
| 140 | + | ? { | |||
| 141 | + | content: lastMessage.content, | |||
| 142 | + | timestamp: lastMessage.timestamp, | |||
| 143 | + | senderId: lastMessage.senderId | |||
| 144 | + | } | |||
| 145 | + | : null, | |||
| 146 | + | unreadCount | |||
| 147 | + | }; | |||
| 148 | + | }) | |||
| 149 | + | ); | |||
| 150 | + | return result.sort( | |||
| 151 | + | (a, b) => (b.lastMessage?.timestamp ?? 0) - (a.lastMessage?.timestamp ?? 0) | |||
| 152 | + | ); | |||
| 153 | + | } | |||
| 154 | + | }); | |||
| 155 | + | ||||
| 156 | + | export const getRoomMessages = authQuery({ | |||
| 157 | + | args: { roomId: v.id('chatRooms'), limit: v.optional(v.number()) }, | |||
| 158 | + | handler: async (ctx, args) => { | |||
| 159 | + | const room = await ctx.db.get(args.roomId); | |||
| 160 | + | if (!room) return []; | |||
| 161 | + | await assertRoomAccess(ctx, room, ctx.user._id); | |||
| 162 | + | const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 100))); | |||
| 163 | + | const messages = await ctx.db | |||
| 164 | + | .query('messages') | |||
| 165 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |||
| 166 | + | .order('desc') | |||
| 167 | + | .take(limit); | |||
| 168 | + | const enriched = await Promise.all( | |||
| 169 | + | messages.map(async (msg) => { | |||
| 170 | + | const sender = await ctx.db.get(msg.senderId); | |||
| 171 | + | return { | |||
| 172 | + | id: msg._id, | |||
| 173 | + | chatRoomId: msg.chatRoomId, | |||
| 174 | + | senderId: msg.senderId, | |||
| 175 | + | content: msg.content, | |||
| 176 | + | type: msg.type, | |||
| 177 | + | timestamp: msg.timestamp, | |||
| 178 | + | styleData: msg.styleData, | |||
| 179 | + | hasFormatting: msg.hasFormatting, | |||
| 180 | + | sender: sender | |||
| 181 | + | ? { | |||
| 182 | + | id: sender._id, | |||
| 183 | + | nickname: sender.nickname, | |||
| 184 | + | status: computeEffectiveStatus(sender.status, sender.lastSeen), | |||
| 185 | + | avatarUrl: sender.avatarUrl, | |||
| 186 | + | buddyIcon: sender.buddyIcon | |||
| 187 | + | } | |||
| 188 | + | : null | |||
| 189 | + | }; | |||
| 190 | + | }) | |||
| 191 | + | ); | |||
| 192 | + | return enriched.reverse(); | |||
| 193 | + | } | |||
| 194 | + | }); | |||
| 195 | + | ||||
| 196 | + | export const getRoomTyping = authQuery({ | |||
| 197 | + | args: { roomId: v.id('chatRooms') }, | |||
| 198 | + | handler: async (ctx, args) => { | |||
| 199 | + | const since = Date.now() - TYPING_TTL_MS; | |||
| 200 | + | const rows = await ctx.db | |||
| 201 | + | .query('typing') | |||
| 202 | + | .withIndex('by_room', (q) => q.eq('roomId', args.roomId)) | |||
| 203 | + | .collect(); | |||
| 204 | + | const active = rows.filter((r) => r.updatedAt > since && r.userId !== ctx.user._id); | |||
| 205 | + | const users = await Promise.all(active.map((r) => ctx.db.get(r.userId))); | |||
| 206 | + | return users.filter((u) => u !== null).map((u) => ({ id: u._id, nickname: u.nickname })); | |||
| 207 | + | } | |||
| 208 | + | }); | |||
| 209 | + | ||||
| 210 | + | export const getMe = authQuery({ | |||
| 211 | + | args: {}, | |||
| 212 | + | handler: async (ctx) => { | |||
| 213 | + | const u = ctx.user; | |||
| 214 | + | return { | |||
| 215 | + | id: u._id, | |||
| 216 | + | nickname: u.nickname, | |||
| 217 | + | status: u.status, | |||
| 218 | + | awayMessage: u.awayMessage, | |||
| 219 | + | profile: u.profile, | |||
| 220 | + | buddyIcon: u.buddyIcon, | |||
| 221 | + | createdAt: u.createdAt | |||
| 222 | + | }; | |||
| 223 | + | } | |||
| 224 | + | }); | |||
| 225 | + | ||||
| 226 | + | // ============ AUTHENTICATED MUTATIONS ============ | |||
| 227 | + | ||||
| 228 | + | export const setAwayMessage = authMutation({ | |||
| 229 | + | args: { awayMessage: v.optional(v.string()) }, | |||
| 230 | + | handler: async (ctx, args) => { | |||
| 231 | + | const text = args.awayMessage?.trim().slice(0, MAX_AWAY_LENGTH); | |||
| 232 | + | if (text) { | |||
| 233 | + | await ctx.db.patch(ctx.user._id, { awayMessage: text, status: 'away', lastSeen: Date.now() }); | |||
| 234 | + | } else { | |||
| 235 | + | await ctx.db.patch(ctx.user._id, { | |||
| 236 | + | awayMessage: undefined, | |||
| 237 | + | status: 'online', | |||
| 238 | + | lastSeen: Date.now() | |||
| 239 | + | }); | |||
| 240 | + | } | |||
| 241 | + | return { success: true }; | |||
| 242 | + | } | |||
| 243 | + | }); | |||
| 244 | + | ||||
| 245 | + | export const updateProfile = authMutation({ | |||
| 246 | + | args: { profile: v.optional(v.string()), buddyIcon: v.optional(v.string()) }, | |||
| 247 | + | handler: async (ctx, args) => { | |||
| 248 | + | const patch: Partial<Doc<'users'>> = {}; | |||
| 249 | + | if (args.profile !== undefined) patch.profile = args.profile.trim().slice(0, MAX_PROFILE_LENGTH); | |||
| 250 | + | if (args.buddyIcon !== undefined) { | |||
| 251 | + | if (!/^[a-z0-9-]{1,32}$/.test(args.buddyIcon)) throw new Error('Invalid buddy icon'); | |||
| 252 | + | patch.buddyIcon = args.buddyIcon; | |||
| 253 | + | } | |||
| 254 | + | await ctx.db.patch(ctx.user._id, patch); | |||
| 255 | + | return { success: true }; | |||
| 256 | + | } | |||
| 257 | + | }); | |||
| 258 | + | ||||
| 259 | + | export const addBuddy = authMutation({ | |||
| 260 | + | args: { buddyId: v.id('users'), group: v.optional(v.string()) }, | |||
| 261 | + | handler: async (ctx, args) => { | |||
| 262 | + | if (args.buddyId === ctx.user._id) throw new Error('Impossible de s’ajouter soi-même'); | |||
| 263 | + | const buddy = await ctx.db.get(args.buddyId); | |||
| 264 | + | if (!buddy) throw new Error('User not found'); | |||
| 265 | + | const group = (args.group?.trim() || DEFAULT_GROUP).slice(0, 30); | |||
| 266 | + | const existing = await ctx.db | |||
| 267 | + | .query('buddies') | |||
| 268 | + | .withIndex('by_user_buddy', (q) => q.eq('userId', ctx.user._id).eq('buddyId', args.buddyId)) | |||
| 269 | + | .first(); | |||
| 270 | + | if (existing) { | |||
| 271 | + | await ctx.db.patch(existing._id, { group }); | |||
| 272 | + | return { success: true, id: existing._id }; | |||
| 273 | + | } | |||
| 274 | + | const id = await ctx.db.insert('buddies', { | |||
| 275 | + | userId: ctx.user._id, | |||
| 276 | + | buddyId: args.buddyId, | |||
| 277 | + | group, | |||
| 278 | + | createdAt: Date.now() | |||
| 279 | + | }); | |||
| 280 | + | return { success: true, id }; | |||
| 281 | + | } | |||
| 282 | + | }); | |||
| 283 | + | ||||
| 284 | + | export const removeBuddy = authMutation({ | |||
| 285 | + | args: { buddyId: v.id('users') }, | |||
| 286 | + | handler: async (ctx, args) => { | |||
| 287 | + | const existing = await ctx.db | |||
| 288 | + | .query('buddies') | |||
| 289 | + | .withIndex('by_user_buddy', (q) => q.eq('userId', ctx.user._id).eq('buddyId', args.buddyId)) | |||
| 290 | + | .first(); | |||
| 291 | + | if (existing) await ctx.db.delete(existing._id); | |||
| 292 | + | return { success: true }; | |||
| 293 | + | } | |||
| 294 | + | }); | |||
| 295 | + | ||||
| 296 | + | export const getOrCreateDirectRoom = authMutation({ | |||
| 297 | + | args: { otherUserId: v.id('users') }, | |||
| 298 | + | handler: async (ctx, args) => { | |||
| 299 | + | if (args.otherUserId === ctx.user._id) throw new Error('Cannot IM yourself'); | |||
| 300 | + | const other = await ctx.db.get(args.otherUserId); | |||
| 301 | + | if (!other) throw new Error('User not found'); | |||
| 302 | + | const key = directKeyFor(ctx.user._id, args.otherUserId); | |||
| 303 | + | const existing = await ctx.db | |||
| 304 | + | .query('chatRooms') | |||
| 305 | + | .withIndex('by_directKey', (q) => q.eq('directKey', key)) | |||
| 306 | + | .first(); | |||
| 307 | + | if (existing) return { id: existing._id }; | |||
| 308 | + | const id = await ctx.db.insert('chatRooms', { | |||
| 309 | + | type: 'direct', | |||
| 310 | + | createdAt: Date.now(), | |||
| 311 | + | createdBy: ctx.user._id, | |||
| 312 | + | memberIds: [ctx.user._id, args.otherUserId], | |||
| 313 | + | directKey: key | |||
| 314 | + | }); | |||
| 315 | + | return { id }; | |||
| 316 | + | } | |||
| 317 | + | }); | |||
| 318 | + | ||||
| 319 | + | export const createGroupRoom = authMutation({ | |||
| 320 | + | args: { name: v.string(), topic: v.optional(v.string()) }, | |||
| 321 | + | handler: async (ctx, args) => { | |||
| 322 | + | const name = args.name.trim().slice(0, MAX_ROOM_NAME_LENGTH); | |||
| 323 | + | if (name.length < 2) throw new Error('Nom de salon trop court'); | |||
| 324 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |||
| 325 | + | const clash = rooms.find( | |||
| 326 | + | (r) => r.type === 'group' && (r.name ?? '').toLowerCase() === name.toLowerCase() | |||
| 327 | + | ); | |||
| 328 | + | if (clash) return { id: clash._id, existed: true }; | |||
| 329 | + | const id = await ctx.db.insert('chatRooms', { | |||
| 330 | + | name, | |||
| 331 | + | type: 'group', | |||
| 332 | + | topic: args.topic?.trim().slice(0, MAX_TOPIC_LENGTH) || undefined, | |||
| 333 | + | createdAt: Date.now(), | |||
| 334 | + | createdBy: ctx.user._id | |||
| 335 | + | }); | |||
| 336 | + | return { id, existed: false }; | |||
| 337 | + | } | |||
| 338 | + | }); | |||
| 339 | + | ||||
| 340 | + | export const markRoomRead = authMutation({ | |||
| 341 | + | args: { roomId: v.id('chatRooms') }, | |||
| 342 | + | handler: async (ctx, args) => { | |||
| 343 | + | const now = Date.now(); | |||
| 344 | + | const existing = await ctx.db | |||
| 345 | + | .query('roomReads') | |||
| 346 | + | .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId)) | |||
| 347 | + | .first(); | |||
| 348 | + | if (existing) { | |||
| 349 | + | if (existing.lastReadTimestamp < now) { | |||
| 350 | + | await ctx.db.patch(existing._id, { lastReadTimestamp: now }); | |||
| 351 | + | } | |||
| 352 | + | } else { | |||
| 353 | + | await ctx.db.insert('roomReads', { | |||
| 354 | + | userId: ctx.user._id, | |||
| 355 | + | roomId: args.roomId, | |||
| 356 | + | lastReadTimestamp: now | |||
| 357 | + | }); | |||
| 358 | + | } | |||
| 359 | + | return { success: true }; | |||
| 360 | + | } | |||
| 361 | + | }); | |||
| 362 | + | ||||
| 363 | + | export const setTyping = authMutation({ | |||
| 364 | + | args: { roomId: v.id('chatRooms'), typing: v.boolean() }, | |||
| 365 | + | handler: async (ctx, args) => { | |||
| 366 | + | const existing = await ctx.db | |||
| 367 | + | .query('typing') | |||
| 368 | + | .withIndex('by_user_room', (q) => q.eq('userId', ctx.user._id).eq('roomId', args.roomId)) | |||
| 369 | + | .first(); | |||
| 370 | + | if (!args.typing) { | |||
| 371 | + | if (existing) await ctx.db.delete(existing._id); | |||
| 372 | + | return { success: true }; | |||
| 373 | + | } | |||
| 374 | + | if (existing) { | |||
| 375 | + | await ctx.db.patch(existing._id, { updatedAt: Date.now() }); | |||
| 376 | + | } else { | |||
| 377 | + | await ctx.db.insert('typing', { | |||
| 378 | + | userId: ctx.user._id, | |||
| 379 | + | roomId: args.roomId, | |||
| 380 | + | updatedAt: Date.now() | |||
| 381 | + | }); | |||
| 382 | + | } | |||
| 383 | + | return { success: true }; | |||
| 384 | + | } | |||
| 385 | + | }); | |||
M src/convex/messagesInternal.ts +5 -0
| @@ -57,6 +57,11 @@ export const send = internalMutation({ | |||
| 57 | 57 | throw new Error('User not found'); | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | // Direct rooms are restricted to their two members | |
| 61 | + | if (room.type === 'direct' && !(room.memberIds ?? []).includes(args.senderId)) { | |
| 62 | + | throw new Error('Not a member of this conversation'); | |
| 63 | + | } | |
| 64 | + | ||
| 60 | 65 | const messageId = await ctx.db.insert('messages', { | |
| 61 | 66 | chatRoomId: args.chatRoomId, | |
| 62 | 67 | senderId: args.senderId, | |
| @@ -57,6 +57,11 @@ export const send = internalMutation({ | |||||
| 57 | throw new Error('User not found'); | 57 | throw new Error('User not found'); | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | + | // Direct rooms are restricted to their two members | |||
| 61 | + | if (room.type === 'direct' && !(room.memberIds ?? []).includes(args.senderId)) { | |||
| 62 | + | throw new Error('Not a member of this conversation'); | |||
| 63 | + | } | |||
| 64 | + | ||||
| 60 | const messageId = await ctx.db.insert('messages', { | 65 | const messageId = await ctx.db.insert('messages', { | ||
| 61 | chatRoomId: args.chatRoomId, | 66 | chatRoomId: args.chatRoomId, | ||
| 62 | senderId: args.senderId, | 67 | senderId: args.senderId, | ||
M src/convex/mutations.ts +3 -0
| @@ -20,6 +20,9 @@ export const sendMessage = authMutation({ | |||
| 20 | 20 | if (!room) { | |
| 21 | 21 | throw new Error('Chat room not found'); | |
| 22 | 22 | } | |
| 23 | + | if (room.type === 'direct' && !(room.memberIds ?? []).includes(user._id)) { | |
| 24 | + | throw new Error('Not a member of this conversation'); | |
| 25 | + | } | |
| 23 | 26 | ||
| 24 | 27 | const messageId = await ctx.db.insert('messages', { | |
| 25 | 28 | chatRoomId: args.chatRoomId, | |
| @@ -20,6 +20,9 @@ export const sendMessage = authMutation({ | |||||
| 20 | if (!room) { | 20 | if (!room) { | ||
| 21 | throw new Error('Chat room not found'); | 21 | throw new Error('Chat room not found'); | ||
| 22 | } | 22 | } | ||
| 23 | + | if (room.type === 'direct' && !(room.memberIds ?? []).includes(user._id)) { | |||
| 24 | + | throw new Error('Not a member of this conversation'); | |||
| 25 | + | } | |||
| 23 | 26 | ||||
| 24 | const messageId = await ctx.db.insert('messages', { | 27 | const messageId = await ctx.db.insert('messages', { | ||
| 25 | chatRoomId: args.chatRoomId, | 28 | chatRoomId: args.chatRoomId, | ||
M src/convex/queries.ts +3 -14
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | import { query } from './_generated/server'; | |
| 2 | 2 | import { authQuery } from './auth'; | |
| 3 | 3 | import { v } from 'convex/values'; | |
| 4 | + | import { toPublicUser } from './aim'; | |
| 4 | 5 | ||
| 5 | 6 | // Timeout threshold for marking users as offline (2 minutes) | |
| 6 | 7 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; | |
| @@ -122,13 +123,7 @@ export const getUsersPublic = query({ | |||
| 122 | 123 | const users = await ctx.db.query('users').collect(); | |
| 123 | 124 | ||
| 124 | 125 | // Return safe user data (no passwords) with computed status | |
| 125 | - | return users.map((user) => ({ | |
| 126 | - | id: user._id, | |
| 127 | - | nickname: user.nickname, | |
| 128 | - | status: computeEffectiveStatus(user.status, user.lastSeen), | |
| 129 | - | avatarUrl: user.avatarUrl, | |
| 130 | - | lastSeen: user.lastSeen | |
| 131 | - | })); | |
| 126 | + | return users.map(toPublicUser); | |
| 132 | 127 | } | |
| 133 | 128 | }); | |
| 134 | 129 | ||
| @@ -219,13 +214,7 @@ export const getUsers = authQuery({ | |||
| 219 | 214 | const users = await ctx.db.query('users').collect(); | |
| 220 | 215 | ||
| 221 | 216 | // Return safe user data (no passwords) with computed status | |
| 222 | - | return users.map((user) => ({ | |
| 223 | - | id: user._id, | |
| 224 | - | nickname: user.nickname, | |
| 225 | - | status: computeEffectiveStatus(user.status, user.lastSeen), | |
| 226 | - | avatarUrl: user.avatarUrl, | |
| 227 | - | lastSeen: user.lastSeen | |
| 228 | - | })); | |
| 217 | + | return users.map(toPublicUser); | |
| 229 | 218 | } | |
| 230 | 219 | }); | |
| 231 | 220 | ||
| @@ -1,6 +1,7 @@ | |||||
| 1 | import { query } from './_generated/server'; | 1 | import { query } from './_generated/server'; | ||
| 2 | import { authQuery } from './auth'; | 2 | import { authQuery } from './auth'; | ||
| 3 | import { v } from 'convex/values'; | 3 | import { v } from 'convex/values'; | ||
| 4 | + | import { toPublicUser } from './aim'; | |||
| 4 | 5 | ||||
| 5 | // Timeout threshold for marking users as offline (2 minutes) | 6 | // Timeout threshold for marking users as offline (2 minutes) | ||
| 6 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; | 7 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; | ||
| @@ -122,13 +123,7 @@ export const getUsersPublic = query({ | |||||
| 122 | const users = await ctx.db.query('users').collect(); | 123 | const users = await ctx.db.query('users').collect(); | ||
| 123 | 124 | ||||
| 124 | // Return safe user data (no passwords) with computed status | 125 | // Return safe user data (no passwords) with computed status | ||
| 125 | - | return users.map((user) => ({ | 126 | + | return users.map(toPublicUser); |
| 126 | - | id: user._id, | |||
| 127 | - | nickname: user.nickname, | |||
| 128 | - | status: computeEffectiveStatus(user.status, user.lastSeen), | |||
| 129 | - | avatarUrl: user.avatarUrl, | |||
| 130 | - | lastSeen: user.lastSeen | |||
| 131 | - | })); | |||
| 132 | } | 127 | } | ||
| 133 | }); | 128 | }); | ||
| 134 | 129 | ||||
| @@ -219,13 +214,7 @@ export const getUsers = authQuery({ | |||||
| 219 | const users = await ctx.db.query('users').collect(); | 214 | const users = await ctx.db.query('users').collect(); | ||
| 220 | 215 | ||||
| 221 | // Return safe user data (no passwords) with computed status | 216 | // Return safe user data (no passwords) with computed status | ||
| 222 | - | return users.map((user) => ({ | 217 | + | return users.map(toPublicUser); |
| 223 | - | id: user._id, | |||
| 224 | - | nickname: user.nickname, | |||
| 225 | - | status: computeEffectiveStatus(user.status, user.lastSeen), | |||
| 226 | - | avatarUrl: user.avatarUrl, | |||
| 227 | - | lastSeen: user.lastSeen | |||
| 228 | - | })); | |||
| 229 | } | 218 | } | ||
| 230 | }); | 219 | }); | ||
| 231 | 220 | ||||
M src/convex/schema.ts +43 -5
| @@ -5,11 +5,15 @@ export default defineSchema({ | |||
| 5 | 5 | users: defineTable({ | |
| 6 | 6 | password: v.string(), | |
| 7 | 7 | nickname: v.string(), | |
| 8 | - | status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | |
| 8 | + | status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | 'idle' | |
| 9 | 9 | avatarUrl: v.optional(v.string()), | |
| 10 | 10 | createdAt: v.number(), | |
| 11 | 11 | lastSeen: v.optional(v.number()), | |
| 12 | - | lastReadMentionTimestamp: v.optional(v.number()) // Timestamp of last read mention | |
| 12 | + | lastReadMentionTimestamp: v.optional(v.number()), // Timestamp of last read mention | |
| 13 | + | // AIM profile | |
| 14 | + | awayMessage: v.optional(v.string()), // Shown while status === 'away' | |
| 15 | + | profile: v.optional(v.string()), // Free text "Get Info" profile | |
| 16 | + | buddyIcon: v.optional(v.string()) // Preset icon id (see $lib/aim/buddy-icons) | |
| 13 | 17 | }) | |
| 14 | 18 | .index('by_nickname', ['nickname']) | |
| 15 | 19 | .index('by_status', ['status']), | |
| @@ -26,14 +30,20 @@ export default defineSchema({ | |||
| 26 | 30 | chatRooms: defineTable({ | |
| 27 | 31 | name: v.optional(v.string()), | |
| 28 | 32 | type: v.string(), // 'direct' | 'group' | |
| 29 | - | createdAt: v.number() | |
| 30 | - | }), | |
| 33 | + | createdAt: v.number(), | |
| 34 | + | createdBy: v.optional(v.id('users')), | |
| 35 | + | topic: v.optional(v.string()), | |
| 36 | + | // Direct rooms: exactly two members. Group rooms: undefined (open to all). | |
| 37 | + | memberIds: v.optional(v.array(v.id('users'))), | |
| 38 | + | // Stable key for direct rooms: sorted user ids joined by ':' | |
| 39 | + | directKey: v.optional(v.string()) | |
| 40 | + | }).index('by_directKey', ['directKey']), | |
| 31 | 41 | ||
| 32 | 42 | messages: defineTable({ | |
| 33 | 43 | chatRoomId: v.id('chatRooms'), | |
| 34 | 44 | senderId: v.id('users'), | |
| 35 | 45 | content: v.string(), | |
| 36 | - | type: v.string(), // 'chat' | 'emote' | 'system' | |
| 46 | + | type: v.string(), // 'chat' | 'emote' | 'system' | 'auto-response' | |
| 37 | 47 | timestamp: v.number(), | |
| 38 | 48 | styleData: v.optional(v.string()), | |
| 39 | 49 | hasFormatting: v.optional(v.boolean()) | |
| @@ -41,6 +51,34 @@ export default defineSchema({ | |||
| 41 | 51 | .index('by_chatRoom', ['chatRoomId']) | |
| 42 | 52 | .index('by_timestamp', ['timestamp']), | |
| 43 | 53 | ||
| 54 | + | // AIM buddy list: one row per (owner, buddy) | |
| 55 | + | buddies: defineTable({ | |
| 56 | + | userId: v.id('users'), | |
| 57 | + | buddyId: v.id('users'), | |
| 58 | + | group: v.string(), // 'Buddies' | 'Family' | 'Co-Workers' | custom | |
| 59 | + | createdAt: v.number() | |
| 60 | + | }) | |
| 61 | + | .index('by_user', ['userId']) | |
| 62 | + | .index('by_user_buddy', ['userId', 'buddyId']), | |
| 63 | + | ||
| 64 | + | // Per-user read cursor per room (unread badges) | |
| 65 | + | roomReads: defineTable({ | |
| 66 | + | userId: v.id('users'), | |
| 67 | + | roomId: v.id('chatRooms'), | |
| 68 | + | lastReadTimestamp: v.number() | |
| 69 | + | }) | |
| 70 | + | .index('by_user', ['userId']) | |
| 71 | + | .index('by_user_room', ['userId', 'roomId']), | |
| 72 | + | ||
| 73 | + | // Typing indicators (short-lived, filtered by timestamp at read time) | |
| 74 | + | typing: defineTable({ | |
| 75 | + | userId: v.id('users'), | |
| 76 | + | roomId: v.id('chatRooms'), | |
| 77 | + | updatedAt: v.number() | |
| 78 | + | }) | |
| 79 | + | .index('by_room', ['roomId']) | |
| 80 | + | .index('by_user_room', ['userId', 'roomId']), | |
| 81 | + | ||
| 44 | 82 | userTextPreferences: defineTable({ | |
| 45 | 83 | userId: v.id('users'), | |
| 46 | 84 | defaultStyle: v.object({ | |
| @@ -5,11 +5,15 @@ export default defineSchema({ | |||||
| 5 | users: defineTable({ | 5 | users: defineTable({ | ||
| 6 | password: v.string(), | 6 | password: v.string(), | ||
| 7 | nickname: v.string(), | 7 | nickname: v.string(), | ||
| 8 | - | status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | 8 | + | status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | 'idle' |
| 9 | avatarUrl: v.optional(v.string()), | 9 | avatarUrl: v.optional(v.string()), | ||
| 10 | createdAt: v.number(), | 10 | createdAt: v.number(), | ||
| 11 | lastSeen: v.optional(v.number()), | 11 | lastSeen: v.optional(v.number()), | ||
| 12 | - | lastReadMentionTimestamp: v.optional(v.number()) // Timestamp of last read mention | 12 | + | lastReadMentionTimestamp: v.optional(v.number()), // Timestamp of last read mention |
| 13 | + | // AIM profile | |||
| 14 | + | awayMessage: v.optional(v.string()), // Shown while status === 'away' | |||
| 15 | + | profile: v.optional(v.string()), // Free text "Get Info" profile | |||
| 16 | + | buddyIcon: v.optional(v.string()) // Preset icon id (see $lib/aim/buddy-icons) | |||
| 13 | }) | 17 | }) | ||
| 14 | .index('by_nickname', ['nickname']) | 18 | .index('by_nickname', ['nickname']) | ||
| 15 | .index('by_status', ['status']), | 19 | .index('by_status', ['status']), | ||
| @@ -26,14 +30,20 @@ export default defineSchema({ | |||||
| 26 | chatRooms: defineTable({ | 30 | chatRooms: defineTable({ | ||
| 27 | name: v.optional(v.string()), | 31 | name: v.optional(v.string()), | ||
| 28 | type: v.string(), // 'direct' | 'group' | 32 | type: v.string(), // 'direct' | 'group' | ||
| 29 | - | createdAt: v.number() | 33 | + | createdAt: v.number(), |
| 30 | - | }), | 34 | + | createdBy: v.optional(v.id('users')), |
| 35 | + | topic: v.optional(v.string()), | |||
| 36 | + | // Direct rooms: exactly two members. Group rooms: undefined (open to all). | |||
| 37 | + | memberIds: v.optional(v.array(v.id('users'))), | |||
| 38 | + | // Stable key for direct rooms: sorted user ids joined by ':' | |||
| 39 | + | directKey: v.optional(v.string()) | |||
| 40 | + | }).index('by_directKey', ['directKey']), | |||
| 31 | 41 | ||||
| 32 | messages: defineTable({ | 42 | messages: defineTable({ | ||
| 33 | chatRoomId: v.id('chatRooms'), | 43 | chatRoomId: v.id('chatRooms'), | ||
| 34 | senderId: v.id('users'), | 44 | senderId: v.id('users'), | ||
| 35 | content: v.string(), | 45 | content: v.string(), | ||
| 36 | - | type: v.string(), // 'chat' | 'emote' | 'system' | 46 | + | type: v.string(), // 'chat' | 'emote' | 'system' | 'auto-response' |
| 37 | timestamp: v.number(), | 47 | timestamp: v.number(), | ||
| 38 | styleData: v.optional(v.string()), | 48 | styleData: v.optional(v.string()), | ||
| 39 | hasFormatting: v.optional(v.boolean()) | 49 | hasFormatting: v.optional(v.boolean()) | ||
| @@ -41,6 +51,34 @@ export default defineSchema({ | |||||
| 41 | .index('by_chatRoom', ['chatRoomId']) | 51 | .index('by_chatRoom', ['chatRoomId']) | ||
| 42 | .index('by_timestamp', ['timestamp']), | 52 | .index('by_timestamp', ['timestamp']), | ||
| 43 | 53 | ||||
| 54 | + | // AIM buddy list: one row per (owner, buddy) | |||
| 55 | + | buddies: defineTable({ | |||
| 56 | + | userId: v.id('users'), | |||
| 57 | + | buddyId: v.id('users'), | |||
| 58 | + | group: v.string(), // 'Buddies' | 'Family' | 'Co-Workers' | custom | |||
| 59 | + | createdAt: v.number() | |||
| 60 | + | }) | |||
| 61 | + | .index('by_user', ['userId']) | |||
| 62 | + | .index('by_user_buddy', ['userId', 'buddyId']), | |||
| 63 | + | ||||
| 64 | + | // Per-user read cursor per room (unread badges) | |||
| 65 | + | roomReads: defineTable({ | |||
| 66 | + | userId: v.id('users'), | |||
| 67 | + | roomId: v.id('chatRooms'), | |||
| 68 | + | lastReadTimestamp: v.number() | |||
| 69 | + | }) | |||
| 70 | + | .index('by_user', ['userId']) | |||
| 71 | + | .index('by_user_room', ['userId', 'roomId']), | |||
| 72 | + | ||||
| 73 | + | // Typing indicators (short-lived, filtered by timestamp at read time) | |||
| 74 | + | typing: defineTable({ | |||
| 75 | + | userId: v.id('users'), | |||
| 76 | + | roomId: v.id('chatRooms'), | |||
| 77 | + | updatedAt: v.number() | |||
| 78 | + | }) | |||
| 79 | + | .index('by_room', ['roomId']) | |||
| 80 | + | .index('by_user_room', ['userId', 'roomId']), | |||
| 81 | + | ||||
| 44 | userTextPreferences: defineTable({ | 82 | userTextPreferences: defineTable({ | ||
| 45 | userId: v.id('users'), | 83 | userId: v.id('users'), | ||
| 46 | defaultStyle: v.object({ | 84 | defaultStyle: v.object({ | ||
A src/lib/aim/buddy-icons.ts +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | /** | |
| 2 | + | * Preset buddy icons (48x48 in the original AIM). Rendered as emoji on a | |
| 3 | + | * coloured tile so no image assets are needed. | |
| 4 | + | */ | |
| 5 | + | export interface BuddyIconPreset { | |
| 6 | + | id: string; | |
| 7 | + | emoji: string; | |
| 8 | + | label: string; | |
| 9 | + | bg: string; | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | export const BUDDY_ICONS: BuddyIconPreset[] = [ | |
| 13 | + | { id: 'runner', emoji: '🏃', label: 'Running man', bg: '#f6c700' }, | |
| 14 | + | { id: 'smiley', emoji: '🙂', label: 'Smiley', bg: '#ffe680' }, | |
| 15 | + | { id: 'cool', emoji: '😎', label: 'Cool', bg: '#8fd3ff' }, | |
| 16 | + | { id: 'cat', emoji: '🐱', label: 'Chat', bg: '#ffd1dc' }, | |
| 17 | + | { id: 'dog', emoji: '🐶', label: 'Chien', bg: '#d9c3a5' }, | |
| 18 | + | { id: 'alien', emoji: '👽', label: 'Alien', bg: '#b6ffb0' }, | |
| 19 | + | { id: 'skull', emoji: '💀', label: 'Crâne', bg: '#dcdcdc' }, | |
| 20 | + | { id: 'fire', emoji: '🔥', label: 'Feu', bg: '#ffb27a' }, | |
| 21 | + | { id: 'star', emoji: '⭐', label: 'Étoile', bg: '#3a4fbf' }, | |
| 22 | + | { id: 'heart', emoji: '💜', label: 'Cœur', bg: '#f7c6ff' }, | |
| 23 | + | { id: 'music', emoji: '🎵', label: 'Musique', bg: '#c4f0ff' }, | |
| 24 | + | { id: 'game', emoji: '🎮', label: 'Jeux', bg: '#c9ffd6' }, | |
| 25 | + | { id: 'pizza', emoji: '🍕', label: 'Pizza', bg: '#ffe0b3' }, | |
| 26 | + | { id: 'rocket', emoji: '🚀', label: 'Fusée', bg: '#1f2a5a' }, | |
| 27 | + | { id: 'ghost', emoji: '👻', label: 'Fantôme', bg: '#eaeaff' }, | |
| 28 | + | { id: 'robot', emoji: '🤖', label: 'Robot', bg: '#cfd8dc' } | |
| 29 | + | ]; | |
| 30 | + | ||
| 31 | + | export const DEFAULT_BUDDY_ICON = BUDDY_ICONS[0]; | |
| 32 | + | ||
| 33 | + | export function getBuddyIcon(id: string | null | undefined): BuddyIconPreset { | |
| 34 | + | return BUDDY_ICONS.find((i) => i.id === id) ?? DEFAULT_BUDDY_ICON; | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Preset buddy icons (48x48 in the original AIM). Rendered as emoji on a | |||
| 3 | + | * coloured tile so no image assets are needed. | |||
| 4 | + | */ | |||
| 5 | + | export interface BuddyIconPreset { | |||
| 6 | + | id: string; | |||
| 7 | + | emoji: string; | |||
| 8 | + | label: string; | |||
| 9 | + | bg: string; | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | export const BUDDY_ICONS: BuddyIconPreset[] = [ | |||
| 13 | + | { id: 'runner', emoji: '🏃', label: 'Running man', bg: '#f6c700' }, | |||
| 14 | + | { id: 'smiley', emoji: '🙂', label: 'Smiley', bg: '#ffe680' }, | |||
| 15 | + | { id: 'cool', emoji: '😎', label: 'Cool', bg: '#8fd3ff' }, | |||
| 16 | + | { id: 'cat', emoji: '🐱', label: 'Chat', bg: '#ffd1dc' }, | |||
| 17 | + | { id: 'dog', emoji: '🐶', label: 'Chien', bg: '#d9c3a5' }, | |||
| 18 | + | { id: 'alien', emoji: '👽', label: 'Alien', bg: '#b6ffb0' }, | |||
| 19 | + | { id: 'skull', emoji: '💀', label: 'Crâne', bg: '#dcdcdc' }, | |||
| 20 | + | { id: 'fire', emoji: '🔥', label: 'Feu', bg: '#ffb27a' }, | |||
| 21 | + | { id: 'star', emoji: '⭐', label: 'Étoile', bg: '#3a4fbf' }, | |||
| 22 | + | { id: 'heart', emoji: '💜', label: 'Cœur', bg: '#f7c6ff' }, | |||
| 23 | + | { id: 'music', emoji: '🎵', label: 'Musique', bg: '#c4f0ff' }, | |||
| 24 | + | { id: 'game', emoji: '🎮', label: 'Jeux', bg: '#c9ffd6' }, | |||
| 25 | + | { id: 'pizza', emoji: '🍕', label: 'Pizza', bg: '#ffe0b3' }, | |||
| 26 | + | { id: 'rocket', emoji: '🚀', label: 'Fusée', bg: '#1f2a5a' }, | |||
| 27 | + | { id: 'ghost', emoji: '👻', label: 'Fantôme', bg: '#eaeaff' }, | |||
| 28 | + | { id: 'robot', emoji: '🤖', label: 'Robot', bg: '#cfd8dc' } | |||
| 29 | + | ]; | |||
| 30 | + | ||||
| 31 | + | export const DEFAULT_BUDDY_ICON = BUDDY_ICONS[0]; | |||
| 32 | + | ||||
| 33 | + | export function getBuddyIcon(id: string | null | undefined): BuddyIconPreset { | |||
| 34 | + | return BUDDY_ICONS.find((i) => i.id === id) ?? DEFAULT_BUDDY_ICON; | |||
| 35 | + | } | |||
A src/lib/aim/client.ts +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | /** | |
| 2 | + | * Client-side helpers for authenticated Convex calls (AIM features). | |
| 3 | + | * | |
| 4 | + | * Every authQuery/authMutation needs `tokenHash`; this module reads it from | |
| 5 | + | * chatState so components only pass their own args. | |
| 6 | + | */ | |
| 7 | + | import { useConvexClient, useQuery } from 'convex-svelte'; | |
| 8 | + | import { api } from '../../convex/_generated/api'; | |
| 9 | + | import type { FunctionReference, FunctionArgs } from 'convex/server'; | |
| 10 | + | import type { Id } from '../../convex/_generated/dataModel'; | |
| 11 | + | import { chatState } from '$lib/states/chat.svelte'; | |
| 12 | + | import { browser } from '$app/environment'; | |
| 13 | + | import { env } from '$env/dynamic/public'; | |
| 14 | + | ||
| 15 | + | export const convexAvailable = browser && Boolean(env.PUBLIC_CONVEX_URL); | |
| 16 | + | ||
| 17 | + | /** Returns the current tokenHash or null when signed out. Reactive when read inside $derived. */ | |
| 18 | + | export function tokenHash() { | |
| 19 | + | return chatState.getSessionTokenHash(); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * Subscribe to an authenticated query. Must be called at component init | |
| 24 | + | * (top level of <script>), like useQuery. The subscription is skipped while | |
| 25 | + | * signed out and resumes automatically after login (data is undefined meanwhile). | |
| 26 | + | * | |
| 27 | + | * const buddies = authQuery(api.aim.getMyBuddies, () => ({})); | |
| 28 | + | * buddies?.data | |
| 29 | + | */ | |
| 30 | + | export function authQuery<Q extends FunctionReference<'query'>>( | |
| 31 | + | fn: Q, | |
| 32 | + | args: () => Omit<FunctionArgs<Q>, 'tokenHash'> | 'skip' = () => ({}) as Omit< | |
| 33 | + | FunctionArgs<Q>, | |
| 34 | + | 'tokenHash' | |
| 35 | + | > | |
| 36 | + | ) { | |
| 37 | + | if (!convexAvailable) return null; | |
| 38 | + | return useQuery(fn, () => { | |
| 39 | + | const hash = tokenHash(); | |
| 40 | + | const a = args(); | |
| 41 | + | if (!hash || a === 'skip') return 'skip'; | |
| 42 | + | return { tokenHash: hash, ...a } as FunctionArgs<Q>; | |
| 43 | + | }); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | export function useAimClient() { | |
| 47 | + | const client = convexAvailable ? useConvexClient() : null; | |
| 48 | + | ||
| 49 | + | function requireAuth() { | |
| 50 | + | const hash = tokenHash(); | |
| 51 | + | if (!client || !hash) throw new Error('Non connecté'); | |
| 52 | + | return { client, hash }; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | return { | |
| 56 | + | client, | |
| 57 | + | async addBuddy(buddyId: Id<'users'>, group?: string) { | |
| 58 | + | const { client, hash } = requireAuth(); | |
| 59 | + | return client.mutation(api.aim.addBuddy, { tokenHash: hash, buddyId, group }); | |
| 60 | + | }, | |
| 61 | + | async removeBuddy(buddyId: Id<'users'>) { | |
| 62 | + | const { client, hash } = requireAuth(); | |
| 63 | + | return client.mutation(api.aim.removeBuddy, { tokenHash: hash, buddyId }); | |
| 64 | + | }, | |
| 65 | + | async getOrCreateDirectRoom(otherUserId: Id<'users'>) { | |
| 66 | + | const { client, hash } = requireAuth(); | |
| 67 | + | return client.mutation(api.aim.getOrCreateDirectRoom, { tokenHash: hash, otherUserId }); | |
| 68 | + | }, | |
| 69 | + | async createGroupRoom(name: string, topic?: string) { | |
| 70 | + | const { client, hash } = requireAuth(); | |
| 71 | + | return client.mutation(api.aim.createGroupRoom, { tokenHash: hash, name, topic }); | |
| 72 | + | }, | |
| 73 | + | async setAwayMessage(awayMessage?: string) { | |
| 74 | + | const { client, hash } = requireAuth(); | |
| 75 | + | return client.mutation(api.aim.setAwayMessage, { tokenHash: hash, awayMessage }); | |
| 76 | + | }, | |
| 77 | + | async updateProfile(patch: { profile?: string; buddyIcon?: string }) { | |
| 78 | + | const { client, hash } = requireAuth(); | |
| 79 | + | return client.mutation(api.aim.updateProfile, { tokenHash: hash, ...patch }); | |
| 80 | + | }, | |
| 81 | + | async markRoomRead(roomId: Id<'chatRooms'>) { | |
| 82 | + | const { client, hash } = requireAuth(); | |
| 83 | + | return client.mutation(api.aim.markRoomRead, { tokenHash: hash, roomId }); | |
| 84 | + | }, | |
| 85 | + | async setTyping(roomId: Id<'chatRooms'>, typing: boolean) { | |
| 86 | + | const { client, hash } = requireAuth(); | |
| 87 | + | return client.mutation(api.aim.setTyping, { tokenHash: hash, roomId, typing }); | |
| 88 | + | } | |
| 89 | + | }; | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Client-side helpers for authenticated Convex calls (AIM features). | |||
| 3 | + | * | |||
| 4 | + | * Every authQuery/authMutation needs `tokenHash`; this module reads it from | |||
| 5 | + | * chatState so components only pass their own args. | |||
| 6 | + | */ | |||
| 7 | + | import { useConvexClient, useQuery } from 'convex-svelte'; | |||
| 8 | + | import { api } from '../../convex/_generated/api'; | |||
| 9 | + | import type { FunctionReference, FunctionArgs } from 'convex/server'; | |||
| 10 | + | import type { Id } from '../../convex/_generated/dataModel'; | |||
| 11 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 12 | + | import { browser } from '$app/environment'; | |||
| 13 | + | import { env } from '$env/dynamic/public'; | |||
| 14 | + | ||||
| 15 | + | export const convexAvailable = browser && Boolean(env.PUBLIC_CONVEX_URL); | |||
| 16 | + | ||||
| 17 | + | /** Returns the current tokenHash or null when signed out. Reactive when read inside $derived. */ | |||
| 18 | + | export function tokenHash() { | |||
| 19 | + | return chatState.getSessionTokenHash(); | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | /** | |||
| 23 | + | * Subscribe to an authenticated query. Must be called at component init | |||
| 24 | + | * (top level of <script>), like useQuery. The subscription is skipped while | |||
| 25 | + | * signed out and resumes automatically after login (data is undefined meanwhile). | |||
| 26 | + | * | |||
| 27 | + | * const buddies = authQuery(api.aim.getMyBuddies, () => ({})); | |||
| 28 | + | * buddies?.data | |||
| 29 | + | */ | |||
| 30 | + | export function authQuery<Q extends FunctionReference<'query'>>( | |||
| 31 | + | fn: Q, | |||
| 32 | + | args: () => Omit<FunctionArgs<Q>, 'tokenHash'> | 'skip' = () => ({}) as Omit< | |||
| 33 | + | FunctionArgs<Q>, | |||
| 34 | + | 'tokenHash' | |||
| 35 | + | > | |||
| 36 | + | ) { | |||
| 37 | + | if (!convexAvailable) return null; | |||
| 38 | + | return useQuery(fn, () => { | |||
| 39 | + | const hash = tokenHash(); | |||
| 40 | + | const a = args(); | |||
| 41 | + | if (!hash || a === 'skip') return 'skip'; | |||
| 42 | + | return { tokenHash: hash, ...a } as FunctionArgs<Q>; | |||
| 43 | + | }); | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | export function useAimClient() { | |||
| 47 | + | const client = convexAvailable ? useConvexClient() : null; | |||
| 48 | + | ||||
| 49 | + | function requireAuth() { | |||
| 50 | + | const hash = tokenHash(); | |||
| 51 | + | if (!client || !hash) throw new Error('Non connecté'); | |||
| 52 | + | return { client, hash }; | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | return { | |||
| 56 | + | client, | |||
| 57 | + | async addBuddy(buddyId: Id<'users'>, group?: string) { | |||
| 58 | + | const { client, hash } = requireAuth(); | |||
| 59 | + | return client.mutation(api.aim.addBuddy, { tokenHash: hash, buddyId, group }); | |||
| 60 | + | }, | |||
| 61 | + | async removeBuddy(buddyId: Id<'users'>) { | |||
| 62 | + | const { client, hash } = requireAuth(); | |||
| 63 | + | return client.mutation(api.aim.removeBuddy, { tokenHash: hash, buddyId }); | |||
| 64 | + | }, | |||
| 65 | + | async getOrCreateDirectRoom(otherUserId: Id<'users'>) { | |||
| 66 | + | const { client, hash } = requireAuth(); | |||
| 67 | + | return client.mutation(api.aim.getOrCreateDirectRoom, { tokenHash: hash, otherUserId }); | |||
| 68 | + | }, | |||
| 69 | + | async createGroupRoom(name: string, topic?: string) { | |||
| 70 | + | const { client, hash } = requireAuth(); | |||
| 71 | + | return client.mutation(api.aim.createGroupRoom, { tokenHash: hash, name, topic }); | |||
| 72 | + | }, | |||
| 73 | + | async setAwayMessage(awayMessage?: string) { | |||
| 74 | + | const { client, hash } = requireAuth(); | |||
| 75 | + | return client.mutation(api.aim.setAwayMessage, { tokenHash: hash, awayMessage }); | |||
| 76 | + | }, | |||
| 77 | + | async updateProfile(patch: { profile?: string; buddyIcon?: string }) { | |||
| 78 | + | const { client, hash } = requireAuth(); | |||
| 79 | + | return client.mutation(api.aim.updateProfile, { tokenHash: hash, ...patch }); | |||
| 80 | + | }, | |||
| 81 | + | async markRoomRead(roomId: Id<'chatRooms'>) { | |||
| 82 | + | const { client, hash } = requireAuth(); | |||
| 83 | + | return client.mutation(api.aim.markRoomRead, { tokenHash: hash, roomId }); | |||
| 84 | + | }, | |||
| 85 | + | async setTyping(roomId: Id<'chatRooms'>, typing: boolean) { | |||
| 86 | + | const { client, hash } = requireAuth(); | |||
| 87 | + | return client.mutation(api.aim.setTyping, { tokenHash: hash, roomId, typing }); | |||
| 88 | + | } | |||
| 89 | + | }; | |||
| 90 | + | } | |||
A src/lib/aim/sounds.ts +227 -0
| @@ -0,0 +1,227 @@ | |||
| 1 | + | /** | |
| 2 | + | * AIM sound effects synthesized with the Web Audio API. | |
| 3 | + | * | |
| 4 | + | * No audio files are shipped: everything is generated procedurally so the | |
| 5 | + | * project stays free of copyrighted samples. Each sound is a small sketch | |
| 6 | + | * evoking the original: | |
| 7 | + | * - doorOpen : "buddy in" — creak + soft thud | |
| 8 | + | * - doorSlam : "buddy out" — sharp slam | |
| 9 | + | * - imReceive : incoming IM "ding" | |
| 10 | + | * - imSend : outgoing IM "tick" | |
| 11 | + | * - welcome : sign-on chime | |
| 12 | + | * - modem : dial-up handshake (long, for the sign-on screen) | |
| 13 | + | */ | |
| 14 | + | import { browser } from '$app/environment'; | |
| 15 | + | ||
| 16 | + | export type AimSound = 'doorOpen' | 'doorSlam' | 'imReceive' | 'imSend' | 'welcome' | 'modem'; | |
| 17 | + | ||
| 18 | + | const STORAGE_KEY = 'pdraim-sounds-enabled'; | |
| 19 | + | ||
| 20 | + | let ctx: AudioContext | null = null; | |
| 21 | + | let enabled = true; | |
| 22 | + | ||
| 23 | + | if (browser) { | |
| 24 | + | const stored = localStorage.getItem(STORAGE_KEY); | |
| 25 | + | enabled = stored === null ? true : stored === 'true'; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export function soundsEnabled() { | |
| 29 | + | return enabled; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | export function setSoundsEnabled(value: boolean) { | |
| 33 | + | enabled = value; | |
| 34 | + | if (browser) localStorage.setItem(STORAGE_KEY, String(value)); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | function getContext(): AudioContext | null { | |
| 38 | + | if (!browser) return null; | |
| 39 | + | if (!ctx) { | |
| 40 | + | try { | |
| 41 | + | ctx = new AudioContext(); | |
| 42 | + | } catch { | |
| 43 | + | return null; | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | if (ctx.state === 'suspended') void ctx.resume(); | |
| 47 | + | return ctx; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** Call once from a user gesture so the browser allows playback. */ | |
| 51 | + | export function unlockAudio() { | |
| 52 | + | getContext(); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | function noiseBuffer(ac: AudioContext, seconds: number) { | |
| 56 | + | const buffer = ac.createBuffer(1, Math.floor(ac.sampleRate * seconds), ac.sampleRate); | |
| 57 | + | const data = buffer.getChannelData(0); | |
| 58 | + | for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; | |
| 59 | + | return buffer; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | function tone( | |
| 63 | + | ac: AudioContext, | |
| 64 | + | dest: AudioNode, | |
| 65 | + | opts: { | |
| 66 | + | type?: OscillatorType; | |
| 67 | + | freq: number; | |
| 68 | + | freqEnd?: number; | |
| 69 | + | start: number; | |
| 70 | + | duration: number; | |
| 71 | + | gain?: number; | |
| 72 | + | attack?: number; | |
| 73 | + | } | |
| 74 | + | ) { | |
| 75 | + | const osc = ac.createOscillator(); | |
| 76 | + | const g = ac.createGain(); | |
| 77 | + | osc.type = opts.type ?? 'sine'; | |
| 78 | + | osc.frequency.setValueAtTime(opts.freq, opts.start); | |
| 79 | + | if (opts.freqEnd) { | |
| 80 | + | osc.frequency.exponentialRampToValueAtTime(opts.freqEnd, opts.start + opts.duration); | |
| 81 | + | } | |
| 82 | + | const peak = opts.gain ?? 0.3; | |
| 83 | + | const attack = opts.attack ?? 0.005; | |
| 84 | + | g.gain.setValueAtTime(0.0001, opts.start); | |
| 85 | + | g.gain.exponentialRampToValueAtTime(peak, opts.start + attack); | |
| 86 | + | g.gain.exponentialRampToValueAtTime(0.0001, opts.start + opts.duration); | |
| 87 | + | osc.connect(g).connect(dest); | |
| 88 | + | osc.start(opts.start); | |
| 89 | + | osc.stop(opts.start + opts.duration + 0.05); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | function thud(ac: AudioContext, dest: AudioNode, start: number, gain = 0.8, duration = 0.18) { | |
| 93 | + | // Low body knock: filtered noise burst + pitched sine drop | |
| 94 | + | const src = ac.createBufferSource(); | |
| 95 | + | src.buffer = noiseBuffer(ac, duration); | |
| 96 | + | const filter = ac.createBiquadFilter(); | |
| 97 | + | filter.type = 'lowpass'; | |
| 98 | + | filter.frequency.setValueAtTime(900, start); | |
| 99 | + | filter.frequency.exponentialRampToValueAtTime(120, start + duration); | |
| 100 | + | const g = ac.createGain(); | |
| 101 | + | g.gain.setValueAtTime(gain, start); | |
| 102 | + | g.gain.exponentialRampToValueAtTime(0.0001, start + duration); | |
| 103 | + | src.connect(filter).connect(g).connect(dest); | |
| 104 | + | src.start(start); | |
| 105 | + | tone(ac, dest, { | |
| 106 | + | freq: 160, | |
| 107 | + | freqEnd: 45, | |
| 108 | + | start, | |
| 109 | + | duration: duration * 0.9, | |
| 110 | + | gain: gain * 0.7, | |
| 111 | + | attack: 0.002 | |
| 112 | + | }); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | function creak(ac: AudioContext, dest: AudioNode, start: number, duration = 0.35) { | |
| 116 | + | // Hinge creak: sawtooth wobble with resonant bandpass | |
| 117 | + | const osc = ac.createOscillator(); | |
| 118 | + | osc.type = 'sawtooth'; | |
| 119 | + | osc.frequency.setValueAtTime(420, start); | |
| 120 | + | osc.frequency.linearRampToValueAtTime(560, start + duration * 0.6); | |
| 121 | + | osc.frequency.linearRampToValueAtTime(380, start + duration); | |
| 122 | + | const lfo = ac.createOscillator(); | |
| 123 | + | lfo.frequency.value = 28; | |
| 124 | + | const lfoGain = ac.createGain(); | |
| 125 | + | lfoGain.gain.value = 60; | |
| 126 | + | lfo.connect(lfoGain).connect(osc.frequency); | |
| 127 | + | const filter = ac.createBiquadFilter(); | |
| 128 | + | filter.type = 'bandpass'; | |
| 129 | + | filter.frequency.value = 1400; | |
| 130 | + | filter.Q.value = 6; | |
| 131 | + | const g = ac.createGain(); | |
| 132 | + | g.gain.setValueAtTime(0.0001, start); | |
| 133 | + | g.gain.exponentialRampToValueAtTime(0.12, start + 0.04); | |
| 134 | + | g.gain.exponentialRampToValueAtTime(0.0001, start + duration); | |
| 135 | + | osc.connect(filter).connect(g).connect(dest); | |
| 136 | + | osc.start(start); | |
| 137 | + | lfo.start(start); | |
| 138 | + | osc.stop(start + duration + 0.05); | |
| 139 | + | lfo.stop(start + duration + 0.05); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) => void> = { | |
| 143 | + | doorOpen(ac, dest, t) { | |
| 144 | + | creak(ac, dest, t, 0.32); | |
| 145 | + | thud(ac, dest, t + 0.3, 0.5, 0.14); | |
| 146 | + | }, | |
| 147 | + | doorSlam(ac, dest, t) { | |
| 148 | + | thud(ac, dest, t, 1.0, 0.22); | |
| 149 | + | // door frame rattle | |
| 150 | + | tone(ac, dest, { type: 'square', freq: 90, freqEnd: 60, start: t + 0.02, duration: 0.12, gain: 0.12 }); | |
| 151 | + | }, | |
| 152 | + | imReceive(ac, dest, t) { | |
| 153 | + | // Two-note ding, bright | |
| 154 | + | tone(ac, dest, { type: 'triangle', freq: 1046, start: t, duration: 0.18, gain: 0.25 }); | |
| 155 | + | tone(ac, dest, { type: 'sine', freq: 1568, start: t + 0.06, duration: 0.32, gain: 0.2 }); | |
| 156 | + | }, | |
| 157 | + | imSend(ac, dest, t) { | |
| 158 | + | tone(ac, dest, { type: 'triangle', freq: 880, start: t, duration: 0.09, gain: 0.18 }); | |
| 159 | + | }, | |
| 160 | + | welcome(ac, dest, t) { | |
| 161 | + | // Ascending three-note chime | |
| 162 | + | tone(ac, dest, { freq: 523, start: t, duration: 0.25, gain: 0.22 }); | |
| 163 | + | tone(ac, dest, { freq: 659, start: t + 0.14, duration: 0.25, gain: 0.22 }); | |
| 164 | + | tone(ac, dest, { freq: 784, start: t + 0.28, duration: 0.5, gain: 0.25 }); | |
| 165 | + | }, | |
| 166 | + | modem(ac, dest, t) { | |
| 167 | + | // Dial tone | |
| 168 | + | tone(ac, dest, { freq: 350, start: t, duration: 0.5, gain: 0.08 }); | |
| 169 | + | tone(ac, dest, { freq: 440, start: t, duration: 0.5, gain: 0.08 }); | |
| 170 | + | // DTMF-ish dialing | |
| 171 | + | const digits = [ | |
| 172 | + | [697, 1209], | |
| 173 | + | [770, 1336], | |
| 174 | + | [852, 1477], | |
| 175 | + | [941, 1336], | |
| 176 | + | [697, 1477], | |
| 177 | + | [770, 1209], | |
| 178 | + | [852, 1336] | |
| 179 | + | ]; | |
| 180 | + | digits.forEach(([lo, hi], i) => { | |
| 181 | + | const s = t + 0.6 + i * 0.13; | |
| 182 | + | tone(ac, dest, { freq: lo, start: s, duration: 0.09, gain: 0.08 }); | |
| 183 | + | tone(ac, dest, { freq: hi, start: s, duration: 0.09, gain: 0.08 }); | |
| 184 | + | }); | |
| 185 | + | // Handshake: answer tone, then chirps | |
| 186 | + | const h = t + 1.7; | |
| 187 | + | tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 }); | |
| 188 | + | tone(ac, dest, { type: 'square', freq: 1200, freqEnd: 2400, start: h + 0.7, duration: 0.35, gain: 0.05 }); | |
| 189 | + | tone(ac, dest, { type: 'square', freq: 2400, freqEnd: 1200, start: h + 1.1, duration: 0.35, gain: 0.05 }); | |
| 190 | + | // Noise burst (training) | |
| 191 | + | const src = ac.createBufferSource(); | |
| 192 | + | src.buffer = noiseBuffer(ac, 1.2); | |
| 193 | + | const filter = ac.createBiquadFilter(); | |
| 194 | + | filter.type = 'bandpass'; | |
| 195 | + | filter.frequency.value = 1800; | |
| 196 | + | filter.Q.value = 0.7; | |
| 197 | + | const g = ac.createGain(); | |
| 198 | + | g.gain.setValueAtTime(0.0001, h + 1.5); | |
| 199 | + | g.gain.exponentialRampToValueAtTime(0.09, h + 1.6); | |
| 200 | + | g.gain.exponentialRampToValueAtTime(0.0001, h + 2.7); | |
| 201 | + | src.connect(filter).connect(g).connect(dest); | |
| 202 | + | src.start(h + 1.5); | |
| 203 | + | } | |
| 204 | + | }; | |
| 205 | + | ||
| 206 | + | export const SOUND_DURATIONS_MS: Record<AimSound, number> = { | |
| 207 | + | doorOpen: 500, | |
| 208 | + | doorSlam: 300, | |
| 209 | + | imReceive: 400, | |
| 210 | + | imSend: 120, | |
| 211 | + | welcome: 800, | |
| 212 | + | modem: 4500 | |
| 213 | + | }; | |
| 214 | + | ||
| 215 | + | export function playSound(name: AimSound) { | |
| 216 | + | if (!enabled) return; | |
| 217 | + | const ac = getContext(); | |
| 218 | + | if (!ac) return; | |
| 219 | + | const master = ac.createGain(); | |
| 220 | + | master.gain.value = 0.9; | |
| 221 | + | master.connect(ac.destination); | |
| 222 | + | try { | |
| 223 | + | players[name](ac, master, ac.currentTime); | |
| 224 | + | } catch (err) { | |
| 225 | + | console.warn('Sound playback failed', err); | |
| 226 | + | } | |
| 227 | + | } | |
| @@ -0,0 +1,227 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * AIM sound effects synthesized with the Web Audio API. | |||
| 3 | + | * | |||
| 4 | + | * No audio files are shipped: everything is generated procedurally so the | |||
| 5 | + | * project stays free of copyrighted samples. Each sound is a small sketch | |||
| 6 | + | * evoking the original: | |||
| 7 | + | * - doorOpen : "buddy in" — creak + soft thud | |||
| 8 | + | * - doorSlam : "buddy out" — sharp slam | |||
| 9 | + | * - imReceive : incoming IM "ding" | |||
| 10 | + | * - imSend : outgoing IM "tick" | |||
| 11 | + | * - welcome : sign-on chime | |||
| 12 | + | * - modem : dial-up handshake (long, for the sign-on screen) | |||
| 13 | + | */ | |||
| 14 | + | import { browser } from '$app/environment'; | |||
| 15 | + | ||||
| 16 | + | export type AimSound = 'doorOpen' | 'doorSlam' | 'imReceive' | 'imSend' | 'welcome' | 'modem'; | |||
| 17 | + | ||||
| 18 | + | const STORAGE_KEY = 'pdraim-sounds-enabled'; | |||
| 19 | + | ||||
| 20 | + | let ctx: AudioContext | null = null; | |||
| 21 | + | let enabled = true; | |||
| 22 | + | ||||
| 23 | + | if (browser) { | |||
| 24 | + | const stored = localStorage.getItem(STORAGE_KEY); | |||
| 25 | + | enabled = stored === null ? true : stored === 'true'; | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | export function soundsEnabled() { | |||
| 29 | + | return enabled; | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | export function setSoundsEnabled(value: boolean) { | |||
| 33 | + | enabled = value; | |||
| 34 | + | if (browser) localStorage.setItem(STORAGE_KEY, String(value)); | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | function getContext(): AudioContext | null { | |||
| 38 | + | if (!browser) return null; | |||
| 39 | + | if (!ctx) { | |||
| 40 | + | try { | |||
| 41 | + | ctx = new AudioContext(); | |||
| 42 | + | } catch { | |||
| 43 | + | return null; | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | if (ctx.state === 'suspended') void ctx.resume(); | |||
| 47 | + | return ctx; | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | /** Call once from a user gesture so the browser allows playback. */ | |||
| 51 | + | export function unlockAudio() { | |||
| 52 | + | getContext(); | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | function noiseBuffer(ac: AudioContext, seconds: number) { | |||
| 56 | + | const buffer = ac.createBuffer(1, Math.floor(ac.sampleRate * seconds), ac.sampleRate); | |||
| 57 | + | const data = buffer.getChannelData(0); | |||
| 58 | + | for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; | |||
| 59 | + | return buffer; | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | function tone( | |||
| 63 | + | ac: AudioContext, | |||
| 64 | + | dest: AudioNode, | |||
| 65 | + | opts: { | |||
| 66 | + | type?: OscillatorType; | |||
| 67 | + | freq: number; | |||
| 68 | + | freqEnd?: number; | |||
| 69 | + | start: number; | |||
| 70 | + | duration: number; | |||
| 71 | + | gain?: number; | |||
| 72 | + | attack?: number; | |||
| 73 | + | } | |||
| 74 | + | ) { | |||
| 75 | + | const osc = ac.createOscillator(); | |||
| 76 | + | const g = ac.createGain(); | |||
| 77 | + | osc.type = opts.type ?? 'sine'; | |||
| 78 | + | osc.frequency.setValueAtTime(opts.freq, opts.start); | |||
| 79 | + | if (opts.freqEnd) { | |||
| 80 | + | osc.frequency.exponentialRampToValueAtTime(opts.freqEnd, opts.start + opts.duration); | |||
| 81 | + | } | |||
| 82 | + | const peak = opts.gain ?? 0.3; | |||
| 83 | + | const attack = opts.attack ?? 0.005; | |||
| 84 | + | g.gain.setValueAtTime(0.0001, opts.start); | |||
| 85 | + | g.gain.exponentialRampToValueAtTime(peak, opts.start + attack); | |||
| 86 | + | g.gain.exponentialRampToValueAtTime(0.0001, opts.start + opts.duration); | |||
| 87 | + | osc.connect(g).connect(dest); | |||
| 88 | + | osc.start(opts.start); | |||
| 89 | + | osc.stop(opts.start + opts.duration + 0.05); | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | function thud(ac: AudioContext, dest: AudioNode, start: number, gain = 0.8, duration = 0.18) { | |||
| 93 | + | // Low body knock: filtered noise burst + pitched sine drop | |||
| 94 | + | const src = ac.createBufferSource(); | |||
| 95 | + | src.buffer = noiseBuffer(ac, duration); | |||
| 96 | + | const filter = ac.createBiquadFilter(); | |||
| 97 | + | filter.type = 'lowpass'; | |||
| 98 | + | filter.frequency.setValueAtTime(900, start); | |||
| 99 | + | filter.frequency.exponentialRampToValueAtTime(120, start + duration); | |||
| 100 | + | const g = ac.createGain(); | |||
| 101 | + | g.gain.setValueAtTime(gain, start); | |||
| 102 | + | g.gain.exponentialRampToValueAtTime(0.0001, start + duration); | |||
| 103 | + | src.connect(filter).connect(g).connect(dest); | |||
| 104 | + | src.start(start); | |||
| 105 | + | tone(ac, dest, { | |||
| 106 | + | freq: 160, | |||
| 107 | + | freqEnd: 45, | |||
| 108 | + | start, | |||
| 109 | + | duration: duration * 0.9, | |||
| 110 | + | gain: gain * 0.7, | |||
| 111 | + | attack: 0.002 | |||
| 112 | + | }); | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | function creak(ac: AudioContext, dest: AudioNode, start: number, duration = 0.35) { | |||
| 116 | + | // Hinge creak: sawtooth wobble with resonant bandpass | |||
| 117 | + | const osc = ac.createOscillator(); | |||
| 118 | + | osc.type = 'sawtooth'; | |||
| 119 | + | osc.frequency.setValueAtTime(420, start); | |||
| 120 | + | osc.frequency.linearRampToValueAtTime(560, start + duration * 0.6); | |||
| 121 | + | osc.frequency.linearRampToValueAtTime(380, start + duration); | |||
| 122 | + | const lfo = ac.createOscillator(); | |||
| 123 | + | lfo.frequency.value = 28; | |||
| 124 | + | const lfoGain = ac.createGain(); | |||
| 125 | + | lfoGain.gain.value = 60; | |||
| 126 | + | lfo.connect(lfoGain).connect(osc.frequency); | |||
| 127 | + | const filter = ac.createBiquadFilter(); | |||
| 128 | + | filter.type = 'bandpass'; | |||
| 129 | + | filter.frequency.value = 1400; | |||
| 130 | + | filter.Q.value = 6; | |||
| 131 | + | const g = ac.createGain(); | |||
| 132 | + | g.gain.setValueAtTime(0.0001, start); | |||
| 133 | + | g.gain.exponentialRampToValueAtTime(0.12, start + 0.04); | |||
| 134 | + | g.gain.exponentialRampToValueAtTime(0.0001, start + duration); | |||
| 135 | + | osc.connect(filter).connect(g).connect(dest); | |||
| 136 | + | osc.start(start); | |||
| 137 | + | lfo.start(start); | |||
| 138 | + | osc.stop(start + duration + 0.05); | |||
| 139 | + | lfo.stop(start + duration + 0.05); | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) => void> = { | |||
| 143 | + | doorOpen(ac, dest, t) { | |||
| 144 | + | creak(ac, dest, t, 0.32); | |||
| 145 | + | thud(ac, dest, t + 0.3, 0.5, 0.14); | |||
| 146 | + | }, | |||
| 147 | + | doorSlam(ac, dest, t) { | |||
| 148 | + | thud(ac, dest, t, 1.0, 0.22); | |||
| 149 | + | // door frame rattle | |||
| 150 | + | tone(ac, dest, { type: 'square', freq: 90, freqEnd: 60, start: t + 0.02, duration: 0.12, gain: 0.12 }); | |||
| 151 | + | }, | |||
| 152 | + | imReceive(ac, dest, t) { | |||
| 153 | + | // Two-note ding, bright | |||
| 154 | + | tone(ac, dest, { type: 'triangle', freq: 1046, start: t, duration: 0.18, gain: 0.25 }); | |||
| 155 | + | tone(ac, dest, { type: 'sine', freq: 1568, start: t + 0.06, duration: 0.32, gain: 0.2 }); | |||
| 156 | + | }, | |||
| 157 | + | imSend(ac, dest, t) { | |||
| 158 | + | tone(ac, dest, { type: 'triangle', freq: 880, start: t, duration: 0.09, gain: 0.18 }); | |||
| 159 | + | }, | |||
| 160 | + | welcome(ac, dest, t) { | |||
| 161 | + | // Ascending three-note chime | |||
| 162 | + | tone(ac, dest, { freq: 523, start: t, duration: 0.25, gain: 0.22 }); | |||
| 163 | + | tone(ac, dest, { freq: 659, start: t + 0.14, duration: 0.25, gain: 0.22 }); | |||
| 164 | + | tone(ac, dest, { freq: 784, start: t + 0.28, duration: 0.5, gain: 0.25 }); | |||
| 165 | + | }, | |||
| 166 | + | modem(ac, dest, t) { | |||
| 167 | + | // Dial tone | |||
| 168 | + | tone(ac, dest, { freq: 350, start: t, duration: 0.5, gain: 0.08 }); | |||
| 169 | + | tone(ac, dest, { freq: 440, start: t, duration: 0.5, gain: 0.08 }); | |||
| 170 | + | // DTMF-ish dialing | |||
| 171 | + | const digits = [ | |||
| 172 | + | [697, 1209], | |||
| 173 | + | [770, 1336], | |||
| 174 | + | [852, 1477], | |||
| 175 | + | [941, 1336], | |||
| 176 | + | [697, 1477], | |||
| 177 | + | [770, 1209], | |||
| 178 | + | [852, 1336] | |||
| 179 | + | ]; | |||
| 180 | + | digits.forEach(([lo, hi], i) => { | |||
| 181 | + | const s = t + 0.6 + i * 0.13; | |||
| 182 | + | tone(ac, dest, { freq: lo, start: s, duration: 0.09, gain: 0.08 }); | |||
| 183 | + | tone(ac, dest, { freq: hi, start: s, duration: 0.09, gain: 0.08 }); | |||
| 184 | + | }); | |||
| 185 | + | // Handshake: answer tone, then chirps | |||
| 186 | + | const h = t + 1.7; | |||
| 187 | + | tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 }); | |||
| 188 | + | tone(ac, dest, { type: 'square', freq: 1200, freqEnd: 2400, start: h + 0.7, duration: 0.35, gain: 0.05 }); | |||
| 189 | + | tone(ac, dest, { type: 'square', freq: 2400, freqEnd: 1200, start: h + 1.1, duration: 0.35, gain: 0.05 }); | |||
| 190 | + | // Noise burst (training) | |||
| 191 | + | const src = ac.createBufferSource(); | |||
| 192 | + | src.buffer = noiseBuffer(ac, 1.2); | |||
| 193 | + | const filter = ac.createBiquadFilter(); | |||
| 194 | + | filter.type = 'bandpass'; | |||
| 195 | + | filter.frequency.value = 1800; | |||
| 196 | + | filter.Q.value = 0.7; | |||
| 197 | + | const g = ac.createGain(); | |||
| 198 | + | g.gain.setValueAtTime(0.0001, h + 1.5); | |||
| 199 | + | g.gain.exponentialRampToValueAtTime(0.09, h + 1.6); | |||
| 200 | + | g.gain.exponentialRampToValueAtTime(0.0001, h + 2.7); | |||
| 201 | + | src.connect(filter).connect(g).connect(dest); | |||
| 202 | + | src.start(h + 1.5); | |||
| 203 | + | } | |||
| 204 | + | }; | |||
| 205 | + | ||||
| 206 | + | export const SOUND_DURATIONS_MS: Record<AimSound, number> = { | |||
| 207 | + | doorOpen: 500, | |||
| 208 | + | doorSlam: 300, | |||
| 209 | + | imReceive: 400, | |||
| 210 | + | imSend: 120, | |||
| 211 | + | welcome: 800, | |||
| 212 | + | modem: 4500 | |||
| 213 | + | }; | |||
| 214 | + | ||||
| 215 | + | export function playSound(name: AimSound) { | |||
| 216 | + | if (!enabled) return; | |||
| 217 | + | const ac = getContext(); | |||
| 218 | + | if (!ac) return; | |||
| 219 | + | const master = ac.createGain(); | |||
| 220 | + | master.gain.value = 0.9; | |||
| 221 | + | master.connect(ac.destination); | |||
| 222 | + | try { | |||
| 223 | + | players[name](ac, master, ac.currentTime); | |||
| 224 | + | } catch (err) { | |||
| 225 | + | console.warn('Sound playback failed', err); | |||
| 226 | + | } | |||
| 227 | + | } | |||
A src/lib/components/desktop/xp-window.svelte +263 -0
| @@ -0,0 +1,263 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | /** | |
| 3 | + | * Generic Windows XP window shell driven by the desktop window manager. | |
| 4 | + | * Handles: drag, optional resize, focus/z-index, minimize/close buttons, | |
| 5 | + | * mobile full-screen fallback. Content goes in the default snippet. | |
| 6 | + | */ | |
| 7 | + | import type { Snippet } from 'svelte'; | |
| 8 | + | import { onMount } from 'svelte'; | |
| 9 | + | import { draggable } from '$lib/actions/draggable'; | |
| 10 | + | import { resizable } from '$lib/actions/resizable'; | |
| 11 | + | import { desktop } from '$lib/states/desktop.svelte'; | |
| 12 | + | ||
| 13 | + | let { | |
| 14 | + | id, | |
| 15 | + | width = 420, | |
| 16 | + | height = 320, | |
| 17 | + | minWidth = 240, | |
| 18 | + | minHeight = 160, | |
| 19 | + | x, | |
| 20 | + | y, | |
| 21 | + | resizableWindow = true, | |
| 22 | + | showMaximize = false, | |
| 23 | + | bodyClass = '', | |
| 24 | + | children, | |
| 25 | + | menu, | |
| 26 | + | onClose | |
| 27 | + | } = $props<{ | |
| 28 | + | id: string; | |
| 29 | + | width?: number; | |
| 30 | + | height?: number; | |
| 31 | + | minWidth?: number; | |
| 32 | + | minHeight?: number; | |
| 33 | + | x?: number; | |
| 34 | + | y?: number; | |
| 35 | + | resizableWindow?: boolean; | |
| 36 | + | showMaximize?: boolean; | |
| 37 | + | bodyClass?: string; | |
| 38 | + | children: Snippet; | |
| 39 | + | menu?: Snippet; | |
| 40 | + | onClose?: () => void; | |
| 41 | + | }>(); | |
| 42 | + | ||
| 43 | + | const win = $derived(desktop.get(id)); | |
| 44 | + | const focused = $derived(desktop.focusedId === id); | |
| 45 | + | ||
| 46 | + | // svelte-ignore state_referenced_locally -- initial geometry only; the user moves/resizes afterwards | |
| 47 | + | let w = $state(width); | |
| 48 | + | // svelte-ignore state_referenced_locally | |
| 49 | + | let h = $state(height); | |
| 50 | + | // svelte-ignore state_referenced_locally | |
| 51 | + | let left = $state(x ?? 0); | |
| 52 | + | // svelte-ignore state_referenced_locally | |
| 53 | + | let top = $state(y ?? 0); | |
| 54 | + | let isMobile = $state(false); | |
| 55 | + | let isMaximized = $state(false); | |
| 56 | + | let restore: { w: number; h: number; left: number; top: number } | null = null; | |
| 57 | + | ||
| 58 | + | onMount(() => { | |
| 59 | + | isMobile = window.innerWidth <= 768; | |
| 60 | + | if (x === undefined || y === undefined) { | |
| 61 | + | // Cascade new windows slightly from the centre so they do not stack exactly | |
| 62 | + | const offset = (desktop.windows.length % 6) * 24; | |
| 63 | + | left = Math.max(8, Math.round((window.innerWidth - w) / 2) + offset); | |
| 64 | + | top = Math.max(8, Math.round((window.innerHeight - h) / 2 - 40) + offset); | |
| 65 | + | } | |
| 66 | + | const onResize = () => { | |
| 67 | + | isMobile = window.innerWidth <= 768; | |
| 68 | + | }; | |
| 69 | + | window.addEventListener('resize', onResize); | |
| 70 | + | return () => window.removeEventListener('resize', onResize); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | function close() { | |
| 74 | + | onClose?.(); | |
| 75 | + | desktop.close(id); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | function minimize(e: MouseEvent) { | |
| 79 | + | e.stopPropagation(); | |
| 80 | + | desktop.minimize(id); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | function toggleMaximize(e?: MouseEvent) { | |
| 84 | + | e?.stopPropagation(); | |
| 85 | + | if (isMobile) return; | |
| 86 | + | if (isMaximized && restore) { | |
| 87 | + | w = restore.w; | |
| 88 | + | h = restore.h; | |
| 89 | + | left = restore.left; | |
| 90 | + | top = restore.top; | |
| 91 | + | isMaximized = false; | |
| 92 | + | } else { | |
| 93 | + | restore = { w, h, left, top }; | |
| 94 | + | left = 4; | |
| 95 | + | top = 4; | |
| 96 | + | w = window.innerWidth - 8; | |
| 97 | + | h = window.innerHeight - 8 - 30; // leave room for the taskbar | |
| 98 | + | isMaximized = true; | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | </script> | |
| 102 | + | ||
| 103 | + | {#if win} | |
| 104 | + | <div | |
| 105 | + | class="window xp-window" | |
| 106 | + | class:inactive={!focused} | |
| 107 | + | class:hidden={win.minimized} | |
| 108 | + | class:mobile={isMobile} | |
| 109 | + | style="width: {w}px; height: {h}px; left: {left}px; top: {top}px; z-index: {win.z};" | |
| 110 | + | role="dialog" | |
| 111 | + | aria-label={win.title} | |
| 112 | + | tabindex="-1" | |
| 113 | + | onmousedown={() => desktop.focus(id)} | |
| 114 | + | use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }} | |
| 115 | + | ondragmove={(e) => { | |
| 116 | + | left = e.detail.x; | |
| 117 | + | top = e.detail.y; | |
| 118 | + | }} | |
| 119 | + | use:resizable={{ | |
| 120 | + | enabled: resizableWindow && !isMobile && !isMaximized, | |
| 121 | + | minWidth, | |
| 122 | + | minHeight, | |
| 123 | + | maxWidth: typeof window !== 'undefined' ? window.innerWidth - 16 : 1600, | |
| 124 | + | maxHeight: typeof window !== 'undefined' ? window.innerHeight - 40 : 1200 | |
| 125 | + | }} | |
| 126 | + | onresizemove={(e) => { | |
| 127 | + | w = Math.max(minWidth, e.detail.width); | |
| 128 | + | h = Math.max(minHeight, e.detail.height); | |
| 129 | + | }} | |
| 130 | + | > | |
| 131 | + | <div class="title-bar" ondblclick={() => showMaximize && toggleMaximize()} role="presentation"> | |
| 132 | + | <div class="title-bar-text"> | |
| 133 | + | <img src={win.icon} alt="" width="16" height="16" class="title-icon" /> | |
| 134 | + | <span class="title-text">{win.title}</span> | |
| 135 | + | </div> | |
| 136 | + | <div class="title-bar-controls"> | |
| 137 | + | <button aria-label="Minimize" onclick={minimize}></button> | |
| 138 | + | {#if showMaximize && !isMobile} | |
| 139 | + | <button | |
| 140 | + | aria-label={isMaximized ? 'Restore' : 'Maximize'} | |
| 141 | + | onclick={toggleMaximize} | |
| 142 | + | ></button> | |
| 143 | + | {/if} | |
| 144 | + | <button aria-label="Close" onclick={close}></button> | |
| 145 | + | </div> | |
| 146 | + | </div> | |
| 147 | + | {#if menu} | |
| 148 | + | <div class="xp-menubar">{@render menu()}</div> | |
| 149 | + | {/if} | |
| 150 | + | <div class="window-body xp-body {bodyClass}" class:has-menu={Boolean(menu)}> | |
| 151 | + | {@render children()} | |
| 152 | + | </div> | |
| 153 | + | </div> | |
| 154 | + | {/if} | |
| 155 | + | ||
| 156 | + | <style> | |
| 157 | + | .xp-window { | |
| 158 | + | position: fixed; | |
| 159 | + | box-sizing: border-box; | |
| 160 | + | display: flex; | |
| 161 | + | flex-direction: column; | |
| 162 | + | font-family: Tahoma, 'Pixelated MS Sans Serif', Arial, sans-serif; | |
| 163 | + | box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.35); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | .xp-window.hidden { | |
| 167 | + | display: none; | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | .xp-window.inactive { | |
| 171 | + | box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /* xp.css already styles active/inactive gradients through :not(:focus-within); | |
| 175 | + | force the inactive look based on our own focus tracking instead. */ | |
| 176 | + | .xp-window.inactive .title-bar { | |
| 177 | + | background: linear-gradient(180deg, #7697e7 0%, #7e9ee3 8%, #94afec 40%, #97b4e9 88%, #82a5e4 100%); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | .title-bar { | |
| 181 | + | cursor: move; | |
| 182 | + | user-select: none; | |
| 183 | + | display: flex; | |
| 184 | + | align-items: center; | |
| 185 | + | flex: 0 0 auto; | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | .title-bar-text { | |
| 189 | + | display: flex; | |
| 190 | + | align-items: center; | |
| 191 | + | gap: 4px; | |
| 192 | + | white-space: nowrap; | |
| 193 | + | overflow: hidden; | |
| 194 | + | text-overflow: ellipsis; | |
| 195 | + | min-width: 0; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | .title-text { | |
| 199 | + | overflow: hidden; | |
| 200 | + | text-overflow: ellipsis; | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | .title-icon { | |
| 204 | + | flex: 0 0 16px; | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | .xp-menubar { | |
| 208 | + | display: flex; | |
| 209 | + | gap: 2px; | |
| 210 | + | padding: 1px 4px; | |
| 211 | + | background: #ece9d8; | |
| 212 | + | border-bottom: 1px solid #aca899; | |
| 213 | + | font-size: 11px; | |
| 214 | + | flex: 0 0 auto; | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | .xp-menubar :global(button) { | |
| 218 | + | border: 1px solid transparent; | |
| 219 | + | background: transparent; | |
| 220 | + | box-shadow: none; | |
| 221 | + | min-width: 0; | |
| 222 | + | padding: 2px 6px; | |
| 223 | + | font-size: 11px; | |
| 224 | + | border-radius: 0; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | .xp-menubar :global(button:hover) { | |
| 228 | + | background: #316ac5; | |
| 229 | + | color: white; | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | .xp-body { | |
| 233 | + | flex: 1 1 auto; | |
| 234 | + | min-height: 0; | |
| 235 | + | display: flex; | |
| 236 | + | flex-direction: column; | |
| 237 | + | margin: 0; | |
| 238 | + | padding: 6px; | |
| 239 | + | overflow: hidden; | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | :global(.xp-window .resize-handle) { | |
| 243 | + | position: absolute; | |
| 244 | + | bottom: 0; | |
| 245 | + | right: 0; | |
| 246 | + | width: 15px !important; | |
| 247 | + | height: 15px !important; | |
| 248 | + | cursor: se-resize !important; | |
| 249 | + | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23555'/%3E%3C/svg%3E"); | |
| 250 | + | background-position: bottom right; | |
| 251 | + | background-repeat: no-repeat; | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | @media (max-width: 768px) { | |
| 255 | + | .xp-window.mobile { | |
| 256 | + | left: 0 !important; | |
| 257 | + | top: 0 !important; | |
| 258 | + | width: 100vw !important; | |
| 259 | + | height: calc(100vh - 30px) !important; | |
| 260 | + | border-radius: 0; | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | </style> | |
| @@ -0,0 +1,263 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | /** | |||
| 3 | + | * Generic Windows XP window shell driven by the desktop window manager. | |||
| 4 | + | * Handles: drag, optional resize, focus/z-index, minimize/close buttons, | |||
| 5 | + | * mobile full-screen fallback. Content goes in the default snippet. | |||
| 6 | + | */ | |||
| 7 | + | import type { Snippet } from 'svelte'; | |||
| 8 | + | import { onMount } from 'svelte'; | |||
| 9 | + | import { draggable } from '$lib/actions/draggable'; | |||
| 10 | + | import { resizable } from '$lib/actions/resizable'; | |||
| 11 | + | import { desktop } from '$lib/states/desktop.svelte'; | |||
| 12 | + | ||||
| 13 | + | let { | |||
| 14 | + | id, | |||
| 15 | + | width = 420, | |||
| 16 | + | height = 320, | |||
| 17 | + | minWidth = 240, | |||
| 18 | + | minHeight = 160, | |||
| 19 | + | x, | |||
| 20 | + | y, | |||
| 21 | + | resizableWindow = true, | |||
| 22 | + | showMaximize = false, | |||
| 23 | + | bodyClass = '', | |||
| 24 | + | children, | |||
| 25 | + | menu, | |||
| 26 | + | onClose | |||
| 27 | + | } = $props<{ | |||
| 28 | + | id: string; | |||
| 29 | + | width?: number; | |||
| 30 | + | height?: number; | |||
| 31 | + | minWidth?: number; | |||
| 32 | + | minHeight?: number; | |||
| 33 | + | x?: number; | |||
| 34 | + | y?: number; | |||
| 35 | + | resizableWindow?: boolean; | |||
| 36 | + | showMaximize?: boolean; | |||
| 37 | + | bodyClass?: string; | |||
| 38 | + | children: Snippet; | |||
| 39 | + | menu?: Snippet; | |||
| 40 | + | onClose?: () => void; | |||
| 41 | + | }>(); | |||
| 42 | + | ||||
| 43 | + | const win = $derived(desktop.get(id)); | |||
| 44 | + | const focused = $derived(desktop.focusedId === id); | |||
| 45 | + | ||||
| 46 | + | // svelte-ignore state_referenced_locally -- initial geometry only; the user moves/resizes afterwards | |||
| 47 | + | let w = $state(width); | |||
| 48 | + | // svelte-ignore state_referenced_locally | |||
| 49 | + | let h = $state(height); | |||
| 50 | + | // svelte-ignore state_referenced_locally | |||
| 51 | + | let left = $state(x ?? 0); | |||
| 52 | + | // svelte-ignore state_referenced_locally | |||
| 53 | + | let top = $state(y ?? 0); | |||
| 54 | + | let isMobile = $state(false); | |||
| 55 | + | let isMaximized = $state(false); | |||
| 56 | + | let restore: { w: number; h: number; left: number; top: number } | null = null; | |||
| 57 | + | ||||
| 58 | + | onMount(() => { | |||
| 59 | + | isMobile = window.innerWidth <= 768; | |||
| 60 | + | if (x === undefined || y === undefined) { | |||
| 61 | + | // Cascade new windows slightly from the centre so they do not stack exactly | |||
| 62 | + | const offset = (desktop.windows.length % 6) * 24; | |||
| 63 | + | left = Math.max(8, Math.round((window.innerWidth - w) / 2) + offset); | |||
| 64 | + | top = Math.max(8, Math.round((window.innerHeight - h) / 2 - 40) + offset); | |||
| 65 | + | } | |||
| 66 | + | const onResize = () => { | |||
| 67 | + | isMobile = window.innerWidth <= 768; | |||
| 68 | + | }; | |||
| 69 | + | window.addEventListener('resize', onResize); | |||
| 70 | + | return () => window.removeEventListener('resize', onResize); | |||
| 71 | + | }); | |||
| 72 | + | ||||
| 73 | + | function close() { | |||
| 74 | + | onClose?.(); | |||
| 75 | + | desktop.close(id); | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | function minimize(e: MouseEvent) { | |||
| 79 | + | e.stopPropagation(); | |||
| 80 | + | desktop.minimize(id); | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | function toggleMaximize(e?: MouseEvent) { | |||
| 84 | + | e?.stopPropagation(); | |||
| 85 | + | if (isMobile) return; | |||
| 86 | + | if (isMaximized && restore) { | |||
| 87 | + | w = restore.w; | |||
| 88 | + | h = restore.h; | |||
| 89 | + | left = restore.left; | |||
| 90 | + | top = restore.top; | |||
| 91 | + | isMaximized = false; | |||
| 92 | + | } else { | |||
| 93 | + | restore = { w, h, left, top }; | |||
| 94 | + | left = 4; | |||
| 95 | + | top = 4; | |||
| 96 | + | w = window.innerWidth - 8; | |||
| 97 | + | h = window.innerHeight - 8 - 30; // leave room for the taskbar | |||
| 98 | + | isMaximized = true; | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | </script> | |||
| 102 | + | ||||
| 103 | + | {#if win} | |||
| 104 | + | <div | |||
| 105 | + | class="window xp-window" | |||
| 106 | + | class:inactive={!focused} | |||
| 107 | + | class:hidden={win.minimized} | |||
| 108 | + | class:mobile={isMobile} | |||
| 109 | + | style="width: {w}px; height: {h}px; left: {left}px; top: {top}px; z-index: {win.z};" | |||
| 110 | + | role="dialog" | |||
| 111 | + | aria-label={win.title} | |||
| 112 | + | tabindex="-1" | |||
| 113 | + | onmousedown={() => desktop.focus(id)} | |||
| 114 | + | use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }} | |||
| 115 | + | ondragmove={(e) => { | |||
| 116 | + | left = e.detail.x; | |||
| 117 | + | top = e.detail.y; | |||
| 118 | + | }} | |||
| 119 | + | use:resizable={{ | |||
| 120 | + | enabled: resizableWindow && !isMobile && !isMaximized, | |||
| 121 | + | minWidth, | |||
| 122 | + | minHeight, | |||
| 123 | + | maxWidth: typeof window !== 'undefined' ? window.innerWidth - 16 : 1600, | |||
| 124 | + | maxHeight: typeof window !== 'undefined' ? window.innerHeight - 40 : 1200 | |||
| 125 | + | }} | |||
| 126 | + | onresizemove={(e) => { | |||
| 127 | + | w = Math.max(minWidth, e.detail.width); | |||
| 128 | + | h = Math.max(minHeight, e.detail.height); | |||
| 129 | + | }} | |||
| 130 | + | > | |||
| 131 | + | <div class="title-bar" ondblclick={() => showMaximize && toggleMaximize()} role="presentation"> | |||
| 132 | + | <div class="title-bar-text"> | |||
| 133 | + | <img src={win.icon} alt="" width="16" height="16" class="title-icon" /> | |||
| 134 | + | <span class="title-text">{win.title}</span> | |||
| 135 | + | </div> | |||
| 136 | + | <div class="title-bar-controls"> | |||
| 137 | + | <button aria-label="Minimize" onclick={minimize}></button> | |||
| 138 | + | {#if showMaximize && !isMobile} | |||
| 139 | + | <button | |||
| 140 | + | aria-label={isMaximized ? 'Restore' : 'Maximize'} | |||
| 141 | + | onclick={toggleMaximize} | |||
| 142 | + | ></button> | |||
| 143 | + | {/if} | |||
| 144 | + | <button aria-label="Close" onclick={close}></button> | |||
| 145 | + | </div> | |||
| 146 | + | </div> | |||
| 147 | + | {#if menu} | |||
| 148 | + | <div class="xp-menubar">{@render menu()}</div> | |||
| 149 | + | {/if} | |||
| 150 | + | <div class="window-body xp-body {bodyClass}" class:has-menu={Boolean(menu)}> | |||
| 151 | + | {@render children()} | |||
| 152 | + | </div> | |||
| 153 | + | </div> | |||
| 154 | + | {/if} | |||
| 155 | + | ||||
| 156 | + | <style> | |||
| 157 | + | .xp-window { | |||
| 158 | + | position: fixed; | |||
| 159 | + | box-sizing: border-box; | |||
| 160 | + | display: flex; | |||
| 161 | + | flex-direction: column; | |||
| 162 | + | font-family: Tahoma, 'Pixelated MS Sans Serif', Arial, sans-serif; | |||
| 163 | + | box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.35); | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | .xp-window.hidden { | |||
| 167 | + | display: none; | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | .xp-window.inactive { | |||
| 171 | + | box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2); | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | /* xp.css already styles active/inactive gradients through :not(:focus-within); | |||
| 175 | + | force the inactive look based on our own focus tracking instead. */ | |||
| 176 | + | .xp-window.inactive .title-bar { | |||
| 177 | + | background: linear-gradient(180deg, #7697e7 0%, #7e9ee3 8%, #94afec 40%, #97b4e9 88%, #82a5e4 100%); | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | .title-bar { | |||
| 181 | + | cursor: move; | |||
| 182 | + | user-select: none; | |||
| 183 | + | display: flex; | |||
| 184 | + | align-items: center; | |||
| 185 | + | flex: 0 0 auto; | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | .title-bar-text { | |||
| 189 | + | display: flex; | |||
| 190 | + | align-items: center; | |||
| 191 | + | gap: 4px; | |||
| 192 | + | white-space: nowrap; | |||
| 193 | + | overflow: hidden; | |||
| 194 | + | text-overflow: ellipsis; | |||
| 195 | + | min-width: 0; | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | .title-text { | |||
| 199 | + | overflow: hidden; | |||
| 200 | + | text-overflow: ellipsis; | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | .title-icon { | |||
| 204 | + | flex: 0 0 16px; | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | .xp-menubar { | |||
| 208 | + | display: flex; | |||
| 209 | + | gap: 2px; | |||
| 210 | + | padding: 1px 4px; | |||
| 211 | + | background: #ece9d8; | |||
| 212 | + | border-bottom: 1px solid #aca899; | |||
| 213 | + | font-size: 11px; | |||
| 214 | + | flex: 0 0 auto; | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | .xp-menubar :global(button) { | |||
| 218 | + | border: 1px solid transparent; | |||
| 219 | + | background: transparent; | |||
| 220 | + | box-shadow: none; | |||
| 221 | + | min-width: 0; | |||
| 222 | + | padding: 2px 6px; | |||
| 223 | + | font-size: 11px; | |||
| 224 | + | border-radius: 0; | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | .xp-menubar :global(button:hover) { | |||
| 228 | + | background: #316ac5; | |||
| 229 | + | color: white; | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | .xp-body { | |||
| 233 | + | flex: 1 1 auto; | |||
| 234 | + | min-height: 0; | |||
| 235 | + | display: flex; | |||
| 236 | + | flex-direction: column; | |||
| 237 | + | margin: 0; | |||
| 238 | + | padding: 6px; | |||
| 239 | + | overflow: hidden; | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | :global(.xp-window .resize-handle) { | |||
| 243 | + | position: absolute; | |||
| 244 | + | bottom: 0; | |||
| 245 | + | right: 0; | |||
| 246 | + | width: 15px !important; | |||
| 247 | + | height: 15px !important; | |||
| 248 | + | cursor: se-resize !important; | |||
| 249 | + | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23555'/%3E%3C/svg%3E"); | |||
| 250 | + | background-position: bottom right; | |||
| 251 | + | background-repeat: no-repeat; | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | @media (max-width: 768px) { | |||
| 255 | + | .xp-window.mobile { | |||
| 256 | + | left: 0 !important; | |||
| 257 | + | top: 0 !important; | |||
| 258 | + | width: 100vw !important; | |||
| 259 | + | height: calc(100vh - 30px) !important; | |||
| 260 | + | border-radius: 0; | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | </style> | |||
M src/lib/db/convex.server.ts +3 -0
| @@ -153,6 +153,9 @@ export interface ConvexChatRoom { | |||
| 153 | 153 | name?: string; | |
| 154 | 154 | type: string; | |
| 155 | 155 | createdAt: number; | |
| 156 | + | topic?: string; | |
| 157 | + | memberIds?: string[]; | |
| 158 | + | createdBy?: string; | |
| 156 | 159 | } | |
| 157 | 160 | ||
| 158 | 161 | export interface ConvexTextPreferences { | |
| @@ -153,6 +153,9 @@ export interface ConvexChatRoom { | |||||
| 153 | name?: string; | 153 | name?: string; | ||
| 154 | type: string; | 154 | type: string; | ||
| 155 | createdAt: number; | 155 | createdAt: number; | ||
| 156 | + | topic?: string; | |||
| 157 | + | memberIds?: string[]; | |||
| 158 | + | createdBy?: string; | |||
| 156 | } | 159 | } | ||
| 157 | 160 | ||||
| 158 | export interface ConvexTextPreferences { | 161 | export interface ConvexTextPreferences { | ||
M src/lib/states/chat.svelte.ts +14 -0
| @@ -12,6 +12,8 @@ class ChatState { | |||
| 12 | 12 | // Current user state (from session) | |
| 13 | 13 | private _currentUser = $state<SafeUser | null>(null); | |
| 14 | 14 | private _currentRoomId = $state<string | null>(null); | |
| 15 | + | // SHA-256 hash of the session token: used as `tokenHash` for Convex authQuery/authMutation | |
| 16 | + | private _sessionTokenHash = $state<string | null>(null); | |
| 15 | 17 | ||
| 16 | 18 | // Connection state for UI feedback | |
| 17 | 19 | private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected'); | |
| @@ -43,6 +45,7 @@ class ChatState { | |||
| 43 | 45 | setCurrentUser(user: User | SafeUser | null) { | |
| 44 | 46 | if (!user) { | |
| 45 | 47 | this._currentUser = null; | |
| 48 | + | this._sessionTokenHash = null; | |
| 46 | 49 | this._connectionStatus = 'disconnected'; | |
| 47 | 50 | this._roomMentionCounts = {}; | |
| 48 | 51 | return; | |
| @@ -53,6 +56,17 @@ class ChatState { | |||
| 53 | 56 | this._connectionStatus = 'connected'; | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 59 | + | /** | |
| 60 | + | * Session token hash for client-side authenticated Convex calls | |
| 61 | + | */ | |
| 62 | + | getSessionTokenHash() { | |
| 63 | + | return this._sessionTokenHash; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | setSessionTokenHash(hash: string | null) { | |
| 67 | + | this._sessionTokenHash = hash; | |
| 68 | + | } | |
| 69 | + | ||
| 56 | 70 | /** | |
| 57 | 71 | * Get the current room ID | |
| 58 | 72 | */ | |
| @@ -12,6 +12,8 @@ class ChatState { | |||||
| 12 | // Current user state (from session) | 12 | // Current user state (from session) | ||
| 13 | private _currentUser = $state<SafeUser | null>(null); | 13 | private _currentUser = $state<SafeUser | null>(null); | ||
| 14 | private _currentRoomId = $state<string | null>(null); | 14 | private _currentRoomId = $state<string | null>(null); | ||
| 15 | + | // SHA-256 hash of the session token: used as `tokenHash` for Convex authQuery/authMutation | |||
| 16 | + | private _sessionTokenHash = $state<string | null>(null); | |||
| 15 | 17 | ||||
| 16 | // Connection state for UI feedback | 18 | // Connection state for UI feedback | ||
| 17 | private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected'); | 19 | private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected'); | ||
| @@ -43,6 +45,7 @@ class ChatState { | |||||
| 43 | setCurrentUser(user: User | SafeUser | null) { | 45 | setCurrentUser(user: User | SafeUser | null) { | ||
| 44 | if (!user) { | 46 | if (!user) { | ||
| 45 | this._currentUser = null; | 47 | this._currentUser = null; | ||
| 48 | + | this._sessionTokenHash = null; | |||
| 46 | this._connectionStatus = 'disconnected'; | 49 | this._connectionStatus = 'disconnected'; | ||
| 47 | this._roomMentionCounts = {}; | 50 | this._roomMentionCounts = {}; | ||
| 48 | return; | 51 | return; | ||
| @@ -53,6 +56,17 @@ class ChatState { | |||||
| 53 | this._connectionStatus = 'connected'; | 56 | this._connectionStatus = 'connected'; | ||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 59 | + | /** | |||
| 60 | + | * Session token hash for client-side authenticated Convex calls | |||
| 61 | + | */ | |||
| 62 | + | getSessionTokenHash() { | |||
| 63 | + | return this._sessionTokenHash; | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | setSessionTokenHash(hash: string | null) { | |||
| 67 | + | this._sessionTokenHash = hash; | |||
| 68 | + | } | |||
| 69 | + | ||||
| 56 | /** | 70 | /** | ||
| 57 | * Get the current room ID | 71 | * Get the current room ID | ||
| 58 | */ | 72 | */ | ||
A src/lib/states/desktop.svelte.ts +232 -0
| @@ -0,0 +1,232 @@ | |||
| 1 | + | /** | |
| 2 | + | * Desktop window manager. | |
| 3 | + | * | |
| 4 | + | * Every window on the XP desktop (buddy list, chat rooms, IMs, dialogs) is | |
| 5 | + | * registered here so the taskbar, focus order and z-index are consistent. | |
| 6 | + | */ | |
| 7 | + | import type { Id } from '../../convex/_generated/dataModel'; | |
| 8 | + | ||
| 9 | + | export type WindowKind = | |
| 10 | + | | 'buddy-list' | |
| 11 | + | | 'chat-room' | |
| 12 | + | | 'im' | |
| 13 | + | | 'login' | |
| 14 | + | | 'profile' | |
| 15 | + | | 'away' | |
| 16 | + | | 'new-room' | |
| 17 | + | | 'preferences' | |
| 18 | + | | 'about'; | |
| 19 | + | ||
| 20 | + | export interface DesktopWindow { | |
| 21 | + | id: string; | |
| 22 | + | kind: WindowKind; | |
| 23 | + | title: string; | |
| 24 | + | icon: string; | |
| 25 | + | z: number; | |
| 26 | + | minimized: boolean; | |
| 27 | + | /** Set when a window wants the taskbar button to blink (new IM). */ | |
| 28 | + | attention: boolean; | |
| 29 | + | /** Kind-specific payload */ | |
| 30 | + | props: Record<string, unknown>; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export interface ImWindowProps { | |
| 34 | + | roomId: Id<'chatRooms'>; | |
| 35 | + | otherUserId: Id<'users'>; | |
| 36 | + | otherNickname: string; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | export interface ChatRoomWindowProps { | |
| 40 | + | roomId: Id<'chatRooms'> | null; // null = default room | |
| 41 | + | roomName: string; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | export interface ProfileWindowProps { | |
| 45 | + | userId: Id<'users'>; | |
| 46 | + | nickname: string; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | class DesktopState { | |
| 50 | + | windows = $state<DesktopWindow[]>([]); | |
| 51 | + | focusedId = $state<string | null>(null); | |
| 52 | + | private nextZ = 10; | |
| 53 | + | ||
| 54 | + | /** Whether the login "sign on" sequence is running (modem animation). */ | |
| 55 | + | signingOn = $state(false); | |
| 56 | + | ||
| 57 | + | focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null); | |
| 58 | + | taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id))); | |
| 59 | + | ||
| 60 | + | get(id: string) { | |
| 61 | + | return this.windows.find((w) => w.id === id) ?? null; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | has(id: string) { | |
| 65 | + | return this.windows.some((w) => w.id === id); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /** | |
| 69 | + | * Open (or focus if already open) a window. | |
| 70 | + | * Windows are identified by a stable id so that e.g. an IM with a given | |
| 71 | + | * user is never opened twice. | |
| 72 | + | */ | |
| 73 | + | open(win: Omit<DesktopWindow, 'z' | 'minimized' | 'attention'>) { | |
| 74 | + | const existing = this.get(win.id); | |
| 75 | + | if (existing) { | |
| 76 | + | existing.title = win.title; | |
| 77 | + | existing.props = win.props; | |
| 78 | + | this.focus(win.id); | |
| 79 | + | return existing; | |
| 80 | + | } | |
| 81 | + | const created: DesktopWindow = { | |
| 82 | + | ...win, | |
| 83 | + | z: ++this.nextZ, | |
| 84 | + | minimized: false, | |
| 85 | + | attention: false | |
| 86 | + | }; | |
| 87 | + | this.windows = [...this.windows, created]; | |
| 88 | + | this.focusedId = created.id; | |
| 89 | + | return created; | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | close(id: string) { | |
| 93 | + | this.windows = this.windows.filter((w) => w.id !== id); | |
| 94 | + | if (this.focusedId === id) { | |
| 95 | + | const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0]; | |
| 96 | + | this.focusedId = top?.id ?? null; | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | focus(id: string) { | |
| 101 | + | const win = this.get(id); | |
| 102 | + | if (!win) return; | |
| 103 | + | win.minimized = false; | |
| 104 | + | win.attention = false; | |
| 105 | + | win.z = ++this.nextZ; | |
| 106 | + | this.focusedId = id; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | minimize(id: string) { | |
| 110 | + | const win = this.get(id); | |
| 111 | + | if (!win) return; | |
| 112 | + | win.minimized = true; | |
| 113 | + | if (this.focusedId === id) { | |
| 114 | + | const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0]; | |
| 115 | + | this.focusedId = top?.id ?? null; | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | toggleFromTaskbar(id: string) { | |
| 120 | + | const win = this.get(id); | |
| 121 | + | if (!win) return; | |
| 122 | + | if (win.minimized || this.focusedId !== id) { | |
| 123 | + | this.focus(id); | |
| 124 | + | } else { | |
| 125 | + | this.minimize(id); | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | requestAttention(id: string) { | |
| 130 | + | const win = this.get(id); | |
| 131 | + | if (win && this.focusedId !== id) win.attention = true; | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | blurAll() { | |
| 135 | + | this.focusedId = null; | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // ---------- Convenience openers ---------- | |
| 139 | + | ||
| 140 | + | openBuddyList() { | |
| 141 | + | return this.open({ | |
| 142 | + | id: 'buddy-list', | |
| 143 | + | kind: 'buddy-list', | |
| 144 | + | title: 'Liste de contacts', | |
| 145 | + | icon: '/aim/running-man.svg', | |
| 146 | + | props: {} | |
| 147 | + | }); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) { | |
| 151 | + | const props: ChatRoomWindowProps = { roomId, roomName }; | |
| 152 | + | return this.open({ | |
| 153 | + | id: roomId ? `room-${roomId}` : 'room-default', | |
| 154 | + | kind: 'chat-room', | |
| 155 | + | title: `Salon : ${roomName}`, | |
| 156 | + | icon: '/aim/chat-room.svg', | |
| 157 | + | props: props as unknown as Record<string, unknown> | |
| 158 | + | }); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | openIm(props: ImWindowProps) { | |
| 162 | + | return this.open({ | |
| 163 | + | id: `im-${props.roomId}`, | |
| 164 | + | kind: 'im', | |
| 165 | + | title: `${props.otherNickname} - Message instantané`, | |
| 166 | + | icon: '/aim/im.svg', | |
| 167 | + | props: props as unknown as Record<string, unknown> | |
| 168 | + | }); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | openLogin(tab: 'signin' | 'signup' = 'signin') { | |
| 172 | + | return this.open({ | |
| 173 | + | id: 'login', | |
| 174 | + | kind: 'login', | |
| 175 | + | title: 'Connexion', | |
| 176 | + | icon: '/aim/running-man.svg', | |
| 177 | + | props: { tab } | |
| 178 | + | }); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | openProfile(props: ProfileWindowProps) { | |
| 182 | + | return this.open({ | |
| 183 | + | id: `profile-${props.userId}`, | |
| 184 | + | kind: 'profile', | |
| 185 | + | title: `Infos sur ${props.nickname}`, | |
| 186 | + | icon: '/aim/info.svg', | |
| 187 | + | props: props as unknown as Record<string, unknown> | |
| 188 | + | }); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | openAway() { | |
| 192 | + | return this.open({ | |
| 193 | + | id: 'away', | |
| 194 | + | kind: 'away', | |
| 195 | + | title: 'Message d’absence', | |
| 196 | + | icon: '/aim/away.svg', | |
| 197 | + | props: {} | |
| 198 | + | }); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | openNewRoom() { | |
| 202 | + | return this.open({ | |
| 203 | + | id: 'new-room', | |
| 204 | + | kind: 'new-room', | |
| 205 | + | title: 'Nouveau salon', | |
| 206 | + | icon: '/aim/chat-room.svg', | |
| 207 | + | props: {} | |
| 208 | + | }); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | openPreferences() { | |
| 212 | + | return this.open({ | |
| 213 | + | id: 'preferences', | |
| 214 | + | kind: 'preferences', | |
| 215 | + | title: 'Préférences', | |
| 216 | + | icon: '/aim/setup-icon.png', | |
| 217 | + | props: {} | |
| 218 | + | }); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | openAbout() { | |
| 222 | + | return this.open({ | |
| 223 | + | id: 'about', | |
| 224 | + | kind: 'about', | |
| 225 | + | title: 'À propos de PDR AIM', | |
| 226 | + | icon: '/aim/help-icon.png', | |
| 227 | + | props: {} | |
| 228 | + | }); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | export const desktop = new DesktopState(); | |
| @@ -0,0 +1,232 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Desktop window manager. | |||
| 3 | + | * | |||
| 4 | + | * Every window on the XP desktop (buddy list, chat rooms, IMs, dialogs) is | |||
| 5 | + | * registered here so the taskbar, focus order and z-index are consistent. | |||
| 6 | + | */ | |||
| 7 | + | import type { Id } from '../../convex/_generated/dataModel'; | |||
| 8 | + | ||||
| 9 | + | export type WindowKind = | |||
| 10 | + | | 'buddy-list' | |||
| 11 | + | | 'chat-room' | |||
| 12 | + | | 'im' | |||
| 13 | + | | 'login' | |||
| 14 | + | | 'profile' | |||
| 15 | + | | 'away' | |||
| 16 | + | | 'new-room' | |||
| 17 | + | | 'preferences' | |||
| 18 | + | | 'about'; | |||
| 19 | + | ||||
| 20 | + | export interface DesktopWindow { | |||
| 21 | + | id: string; | |||
| 22 | + | kind: WindowKind; | |||
| 23 | + | title: string; | |||
| 24 | + | icon: string; | |||
| 25 | + | z: number; | |||
| 26 | + | minimized: boolean; | |||
| 27 | + | /** Set when a window wants the taskbar button to blink (new IM). */ | |||
| 28 | + | attention: boolean; | |||
| 29 | + | /** Kind-specific payload */ | |||
| 30 | + | props: Record<string, unknown>; | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | export interface ImWindowProps { | |||
| 34 | + | roomId: Id<'chatRooms'>; | |||
| 35 | + | otherUserId: Id<'users'>; | |||
| 36 | + | otherNickname: string; | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | export interface ChatRoomWindowProps { | |||
| 40 | + | roomId: Id<'chatRooms'> | null; // null = default room | |||
| 41 | + | roomName: string; | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | export interface ProfileWindowProps { | |||
| 45 | + | userId: Id<'users'>; | |||
| 46 | + | nickname: string; | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | class DesktopState { | |||
| 50 | + | windows = $state<DesktopWindow[]>([]); | |||
| 51 | + | focusedId = $state<string | null>(null); | |||
| 52 | + | private nextZ = 10; | |||
| 53 | + | ||||
| 54 | + | /** Whether the login "sign on" sequence is running (modem animation). */ | |||
| 55 | + | signingOn = $state(false); | |||
| 56 | + | ||||
| 57 | + | focused = $derived(this.windows.find((w) => w.id === this.focusedId) ?? null); | |||
| 58 | + | taskbarWindows = $derived([...this.windows].sort((a, b) => a.id.localeCompare(b.id))); | |||
| 59 | + | ||||
| 60 | + | get(id: string) { | |||
| 61 | + | return this.windows.find((w) => w.id === id) ?? null; | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | has(id: string) { | |||
| 65 | + | return this.windows.some((w) => w.id === id); | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | /** | |||
| 69 | + | * Open (or focus if already open) a window. | |||
| 70 | + | * Windows are identified by a stable id so that e.g. an IM with a given | |||
| 71 | + | * user is never opened twice. | |||
| 72 | + | */ | |||
| 73 | + | open(win: Omit<DesktopWindow, 'z' | 'minimized' | 'attention'>) { | |||
| 74 | + | const existing = this.get(win.id); | |||
| 75 | + | if (existing) { | |||
| 76 | + | existing.title = win.title; | |||
| 77 | + | existing.props = win.props; | |||
| 78 | + | this.focus(win.id); | |||
| 79 | + | return existing; | |||
| 80 | + | } | |||
| 81 | + | const created: DesktopWindow = { | |||
| 82 | + | ...win, | |||
| 83 | + | z: ++this.nextZ, | |||
| 84 | + | minimized: false, | |||
| 85 | + | attention: false | |||
| 86 | + | }; | |||
| 87 | + | this.windows = [...this.windows, created]; | |||
| 88 | + | this.focusedId = created.id; | |||
| 89 | + | return created; | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | close(id: string) { | |||
| 93 | + | this.windows = this.windows.filter((w) => w.id !== id); | |||
| 94 | + | if (this.focusedId === id) { | |||
| 95 | + | const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0]; | |||
| 96 | + | this.focusedId = top?.id ?? null; | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | focus(id: string) { | |||
| 101 | + | const win = this.get(id); | |||
| 102 | + | if (!win) return; | |||
| 103 | + | win.minimized = false; | |||
| 104 | + | win.attention = false; | |||
| 105 | + | win.z = ++this.nextZ; | |||
| 106 | + | this.focusedId = id; | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | minimize(id: string) { | |||
| 110 | + | const win = this.get(id); | |||
| 111 | + | if (!win) return; | |||
| 112 | + | win.minimized = true; | |||
| 113 | + | if (this.focusedId === id) { | |||
| 114 | + | const top = [...this.windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0]; | |||
| 115 | + | this.focusedId = top?.id ?? null; | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | toggleFromTaskbar(id: string) { | |||
| 120 | + | const win = this.get(id); | |||
| 121 | + | if (!win) return; | |||
| 122 | + | if (win.minimized || this.focusedId !== id) { | |||
| 123 | + | this.focus(id); | |||
| 124 | + | } else { | |||
| 125 | + | this.minimize(id); | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | requestAttention(id: string) { | |||
| 130 | + | const win = this.get(id); | |||
| 131 | + | if (win && this.focusedId !== id) win.attention = true; | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | blurAll() { | |||
| 135 | + | this.focusedId = null; | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // ---------- Convenience openers ---------- | |||
| 139 | + | ||||
| 140 | + | openBuddyList() { | |||
| 141 | + | return this.open({ | |||
| 142 | + | id: 'buddy-list', | |||
| 143 | + | kind: 'buddy-list', | |||
| 144 | + | title: 'Liste de contacts', | |||
| 145 | + | icon: '/aim/running-man.svg', | |||
| 146 | + | props: {} | |||
| 147 | + | }); | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | openChatRoom(roomId: Id<'chatRooms'> | null, roomName: string) { | |||
| 151 | + | const props: ChatRoomWindowProps = { roomId, roomName }; | |||
| 152 | + | return this.open({ | |||
| 153 | + | id: roomId ? `room-${roomId}` : 'room-default', | |||
| 154 | + | kind: 'chat-room', | |||
| 155 | + | title: `Salon : ${roomName}`, | |||
| 156 | + | icon: '/aim/chat-room.svg', | |||
| 157 | + | props: props as unknown as Record<string, unknown> | |||
| 158 | + | }); | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | openIm(props: ImWindowProps) { | |||
| 162 | + | return this.open({ | |||
| 163 | + | id: `im-${props.roomId}`, | |||
| 164 | + | kind: 'im', | |||
| 165 | + | title: `${props.otherNickname} - Message instantané`, | |||
| 166 | + | icon: '/aim/im.svg', | |||
| 167 | + | props: props as unknown as Record<string, unknown> | |||
| 168 | + | }); | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | openLogin(tab: 'signin' | 'signup' = 'signin') { | |||
| 172 | + | return this.open({ | |||
| 173 | + | id: 'login', | |||
| 174 | + | kind: 'login', | |||
| 175 | + | title: 'Connexion', | |||
| 176 | + | icon: '/aim/running-man.svg', | |||
| 177 | + | props: { tab } | |||
| 178 | + | }); | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | openProfile(props: ProfileWindowProps) { | |||
| 182 | + | return this.open({ | |||
| 183 | + | id: `profile-${props.userId}`, | |||
| 184 | + | kind: 'profile', | |||
| 185 | + | title: `Infos sur ${props.nickname}`, | |||
| 186 | + | icon: '/aim/info.svg', | |||
| 187 | + | props: props as unknown as Record<string, unknown> | |||
| 188 | + | }); | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | openAway() { | |||
| 192 | + | return this.open({ | |||
| 193 | + | id: 'away', | |||
| 194 | + | kind: 'away', | |||
| 195 | + | title: 'Message d’absence', | |||
| 196 | + | icon: '/aim/away.svg', | |||
| 197 | + | props: {} | |||
| 198 | + | }); | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | openNewRoom() { | |||
| 202 | + | return this.open({ | |||
| 203 | + | id: 'new-room', | |||
| 204 | + | kind: 'new-room', | |||
| 205 | + | title: 'Nouveau salon', | |||
| 206 | + | icon: '/aim/chat-room.svg', | |||
| 207 | + | props: {} | |||
| 208 | + | }); | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | openPreferences() { | |||
| 212 | + | return this.open({ | |||
| 213 | + | id: 'preferences', | |||
| 214 | + | kind: 'preferences', | |||
| 215 | + | title: 'Préférences', | |||
| 216 | + | icon: '/aim/setup-icon.png', | |||
| 217 | + | props: {} | |||
| 218 | + | }); | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | openAbout() { | |||
| 222 | + | return this.open({ | |||
| 223 | + | id: 'about', | |||
| 224 | + | kind: 'about', | |||
| 225 | + | title: 'À propos de PDR AIM', | |||
| 226 | + | icon: '/aim/help-icon.png', | |||
| 227 | + | props: {} | |||
| 228 | + | }); | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | export const desktop = new DesktopState(); | |||
A src/lib/types/aim.ts +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | import type { Id } from '../../convex/_generated/dataModel'; | |
| 2 | + | ||
| 3 | + | export type AimStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline'; | |
| 4 | + | ||
| 5 | + | /** Public user shape returned by Convex queries (getUsersPublic, getMyBuddies…) */ | |
| 6 | + | export interface AimUser { | |
| 7 | + | id: Id<'users'>; | |
| 8 | + | nickname: string; | |
| 9 | + | status: AimStatus; | |
| 10 | + | avatarUrl?: string | null; | |
| 11 | + | lastSeen?: number | null; | |
| 12 | + | awayMessage?: string; | |
| 13 | + | buddyIcon?: string; | |
| 14 | + | createdAt?: number; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | export interface Buddy extends AimUser { | |
| 18 | + | group: string; | |
| 19 | + | addedAt: number; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | export interface DirectRoomSummary { | |
| 23 | + | id: Id<'chatRooms'>; | |
| 24 | + | other: AimUser | null; | |
| 25 | + | lastMessage: { content: string; timestamp: number; senderId: Id<'users'> } | null; | |
| 26 | + | unreadCount: number; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | export interface GroupRoomSummary { | |
| 30 | + | id: Id<'chatRooms'>; | |
| 31 | + | name: string; | |
| 32 | + | topic?: string; | |
| 33 | + | createdAt: number; | |
| 34 | + | isDefault: boolean; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | export const DEFAULT_BUDDY_GROUPS = ['Buddies', 'Family', 'Co-Workers'] as const; | |
| 38 | + | ||
| 39 | + | export const STATUS_LABELS_FR: Record<AimStatus, string> = { | |
| 40 | + | online: 'En ligne', | |
| 41 | + | away: 'Absent', | |
| 42 | + | busy: 'Occupé', | |
| 43 | + | idle: 'Inactif', | |
| 44 | + | offline: 'Hors ligne' | |
| 45 | + | }; | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | import type { Id } from '../../convex/_generated/dataModel'; | |||
| 2 | + | ||||
| 3 | + | export type AimStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline'; | |||
| 4 | + | ||||
| 5 | + | /** Public user shape returned by Convex queries (getUsersPublic, getMyBuddies…) */ | |||
| 6 | + | export interface AimUser { | |||
| 7 | + | id: Id<'users'>; | |||
| 8 | + | nickname: string; | |||
| 9 | + | status: AimStatus; | |||
| 10 | + | avatarUrl?: string | null; | |||
| 11 | + | lastSeen?: number | null; | |||
| 12 | + | awayMessage?: string; | |||
| 13 | + | buddyIcon?: string; | |||
| 14 | + | createdAt?: number; | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | export interface Buddy extends AimUser { | |||
| 18 | + | group: string; | |||
| 19 | + | addedAt: number; | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | export interface DirectRoomSummary { | |||
| 23 | + | id: Id<'chatRooms'>; | |||
| 24 | + | other: AimUser | null; | |||
| 25 | + | lastMessage: { content: string; timestamp: number; senderId: Id<'users'> } | null; | |||
| 26 | + | unreadCount: number; | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | export interface GroupRoomSummary { | |||
| 30 | + | id: Id<'chatRooms'>; | |||
| 31 | + | name: string; | |||
| 32 | + | topic?: string; | |||
| 33 | + | createdAt: number; | |||
| 34 | + | isDefault: boolean; | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | export const DEFAULT_BUDDY_GROUPS = ['Buddies', 'Family', 'Co-Workers'] as const; | |||
| 38 | + | ||||
| 39 | + | export const STATUS_LABELS_FR: Record<AimStatus, string> = { | |||
| 40 | + | online: 'En ligne', | |||
| 41 | + | away: 'Absent', | |||
| 42 | + | busy: 'Occupé', | |||
| 43 | + | idle: 'Inactif', | |||
| 44 | + | offline: 'Hors ligne' | |||
| 45 | + | }; | |||
M src/routes/+layout.svelte +3 -0
| @@ -227,6 +227,9 @@ | |||
| 227 | 227 | if (data.session?.id) { | |
| 228 | 228 | console.debug('Updating global chatState with data.user:', data.user); | |
| 229 | 229 | chatState.setCurrentUser(data.user); | |
| 230 | + | chatState.setSessionTokenHash(data.session.id); | |
| 231 | + | } else { | |
| 232 | + | chatState.setCurrentUser(null); | |
| 230 | 233 | } | |
| 231 | 234 | }); | |
| 232 | 235 | </script> | |
| @@ -227,6 +227,9 @@ | |||||
| 227 | if (data.session?.id) { | 227 | if (data.session?.id) { | ||
| 228 | console.debug('Updating global chatState with data.user:', data.user); | 228 | console.debug('Updating global chatState with data.user:', data.user); | ||
| 229 | chatState.setCurrentUser(data.user); | 229 | chatState.setCurrentUser(data.user); | ||
| 230 | + | chatState.setSessionTokenHash(data.session.id); | |||
| 231 | + | } else { | |||
| 232 | + | chatState.setCurrentUser(null); | |||
| 230 | } | 233 | } | ||
| 231 | }); | 234 | }); | ||
| 232 | </script> | 235 | </script> | ||
M src/routes/api/chat/messages/+server.ts +14 -2
| @@ -1,8 +1,8 @@ | |||
| 1 | - | import { messages } from '$lib/db/convex.server'; | |
| 1 | + | import { messages, chatRooms } from '$lib/db/convex.server'; | |
| 2 | 2 | import { getDefaultChatRoomId } from '$lib/utils/chat.server'; | |
| 3 | 3 | import type { Message } from '$lib/types/chat'; | |
| 4 | 4 | import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads'; | |
| 5 | - | import { error } from '@sveltejs/kit'; | |
| 5 | + | import { error, isHttpError } from '@sveltejs/kit'; | |
| 6 | 6 | import { createLogger } from '$lib/utils/logger.server'; | |
| 7 | 7 | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | |
| 8 | 8 | import { sendMessageSchema } from '$lib/validation/message'; | |
| @@ -60,6 +60,17 @@ export const GET: RequestHandler = async ({ url, locals }) => { | |||
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | try { | |
| 63 | + | // Direct (IM) rooms are private to their two members | |
| 64 | + | const room = await chatRooms.getById(roomId); | |
| 65 | + | if (!room) { | |
| 66 | + | throw error(404, 'Chat room not found'); | |
| 67 | + | } | |
| 68 | + | if (room.type === 'direct') { | |
| 69 | + | if (!locals.user || !(room.memberIds ?? []).includes(locals.user.id)) { | |
| 70 | + | throw error(403, 'Not a member of this conversation'); | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 63 | 74 | log.debug('Fetching messages from Convex', { | |
| 64 | 75 | beforeTimestamp, | |
| 65 | 76 | roomId, | |
| @@ -109,6 +120,7 @@ export const GET: RequestHandler = async ({ url, locals }) => { | |||
| 109 | 120 | headers: { 'Content-Type': 'application/json' } | |
| 110 | 121 | }); | |
| 111 | 122 | } catch (err) { | |
| 123 | + | if (isHttpError(err)) throw err; | |
| 112 | 124 | log.error('Error fetching messages:', { error: err }); | |
| 113 | 125 | throw error(500, 'Failed to fetch messages'); | |
| 114 | 126 | } | |
| @@ -1,8 +1,8 @@ | |||||
| 1 | - | import { messages } from '$lib/db/convex.server'; | 1 | + | import { messages, chatRooms } from '$lib/db/convex.server'; |
| 2 | import { getDefaultChatRoomId } from '$lib/utils/chat.server'; | 2 | import { getDefaultChatRoomId } from '$lib/utils/chat.server'; | ||
| 3 | import type { Message } from '$lib/types/chat'; | 3 | import type { Message } from '$lib/types/chat'; | ||
| 4 | import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads'; | 4 | import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads'; | ||
| 5 | - | import { error } from '@sveltejs/kit'; | 5 | + | import { error, isHttpError } from '@sveltejs/kit'; |
| 6 | import { createLogger } from '$lib/utils/logger.server'; | 6 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 7 | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | 7 | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | ||
| 8 | import { sendMessageSchema } from '$lib/validation/message'; | 8 | import { sendMessageSchema } from '$lib/validation/message'; | ||
| @@ -60,6 +60,17 @@ export const GET: RequestHandler = async ({ url, locals }) => { | |||||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | try { | 62 | try { | ||
| 63 | + | // Direct (IM) rooms are private to their two members | |||
| 64 | + | const room = await chatRooms.getById(roomId); | |||
| 65 | + | if (!room) { | |||
| 66 | + | throw error(404, 'Chat room not found'); | |||
| 67 | + | } | |||
| 68 | + | if (room.type === 'direct') { | |||
| 69 | + | if (!locals.user || !(room.memberIds ?? []).includes(locals.user.id)) { | |||
| 70 | + | throw error(403, 'Not a member of this conversation'); | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 63 | log.debug('Fetching messages from Convex', { | 74 | log.debug('Fetching messages from Convex', { | ||
| 64 | beforeTimestamp, | 75 | beforeTimestamp, | ||
| 65 | roomId, | 76 | roomId, | ||
| @@ -109,6 +120,7 @@ export const GET: RequestHandler = async ({ url, locals }) => { | |||||
| 109 | headers: { 'Content-Type': 'application/json' } | 120 | headers: { 'Content-Type': 'application/json' } | ||
| 110 | }); | 121 | }); | ||
| 111 | } catch (err) { | 122 | } catch (err) { | ||
| 123 | + | if (isHttpError(err)) throw err; | |||
| 112 | log.error('Error fetching messages:', { error: err }); | 124 | log.error('Error fetching messages:', { error: err }); | ||
| 113 | throw error(500, 'Failed to fetch messages'); | 125 | throw error(500, 'Failed to fetch messages'); | ||
| 114 | } | 126 | } | ||
A static/aim/away.svg +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |
| 2 | + | <rect x="1" y="3" width="14" height="10" fill="#fff8c4" stroke="#b58b00"/> | |
| 3 | + | <path d="M3 6h10M3 8.5h7M3 11h5" stroke="#b58b00" stroke-width="1"/> | |
| 4 | + | </svg> | |
| @@ -0,0 +1,4 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |||
| 2 | + | <rect x="1" y="3" width="14" height="10" fill="#fff8c4" stroke="#b58b00"/> | |||
| 3 | + | <path d="M3 6h10M3 8.5h7M3 11h5" stroke="#b58b00" stroke-width="1"/> | |||
| 4 | + | </svg> | |||
A static/aim/chat-room.svg +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |
| 2 | + | <circle cx="5" cy="5" r="2.5" fill="#f6c700" stroke="#8a6d00"/> | |
| 3 | + | <circle cx="11" cy="5" r="2.5" fill="#f6c700" stroke="#8a6d00"/> | |
| 4 | + | <path d="M1 14c0-3 2-5 4-5s4 2 4 5z" fill="#3b7ddd"/> | |
| 5 | + | <path d="M7 14c0-3 2-5 4-5s4 2 4 5z" fill="#e0512f"/> | |
| 6 | + | </svg> | |
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |||
| 2 | + | <circle cx="5" cy="5" r="2.5" fill="#f6c700" stroke="#8a6d00"/> | |||
| 3 | + | <circle cx="11" cy="5" r="2.5" fill="#f6c700" stroke="#8a6d00"/> | |||
| 4 | + | <path d="M1 14c0-3 2-5 4-5s4 2 4 5z" fill="#3b7ddd"/> | |||
| 5 | + | <path d="M7 14c0-3 2-5 4-5s4 2 4 5z" fill="#e0512f"/> | |||
| 6 | + | </svg> | |||
A static/aim/door-closed.svg +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |
| 2 | + | <rect x="3" y="1" width="10" height="14" fill="#8b4513" stroke="#5c3310"/> | |
| 3 | + | <circle cx="11" cy="8" r="0.8" fill="#ffd700"/> | |
| 4 | + | </svg> | |
| @@ -0,0 +1,4 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |||
| 2 | + | <rect x="3" y="1" width="10" height="14" fill="#8b4513" stroke="#5c3310"/> | |||
| 3 | + | <circle cx="11" cy="8" r="0.8" fill="#ffd700"/> | |||
| 4 | + | </svg> | |||
A static/aim/door-open.svg +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |
| 2 | + | <rect x="2" y="1" width="8" height="14" fill="#b5651d" stroke="#5c3310"/> | |
| 3 | + | <path d="M10 1l4 2v10l-4 2z" fill="#8b4513" stroke="#5c3310"/> | |
| 4 | + | <circle cx="8.5" cy="8" r="0.8" fill="#ffd700"/> | |
| 5 | + | </svg> | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |||
| 2 | + | <rect x="2" y="1" width="8" height="14" fill="#b5651d" stroke="#5c3310"/> | |||
| 3 | + | <path d="M10 1l4 2v10l-4 2z" fill="#8b4513" stroke="#5c3310"/> | |||
| 4 | + | <circle cx="8.5" cy="8" r="0.8" fill="#ffd700"/> | |||
| 5 | + | </svg> | |||
A static/aim/im.svg +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |
| 2 | + | <path d="M1 2h14v9H6l-3 3v-3H1z" fill="#fff" stroke="#1d4fa8"/> | |
| 3 | + | <rect x="3" y="4" width="10" height="1.5" fill="#1d4fa8"/> | |
| 4 | + | <rect x="3" y="7" width="7" height="1.5" fill="#1d4fa8"/> | |
| 5 | + | </svg> | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |||
| 2 | + | <path d="M1 2h14v9H6l-3 3v-3H1z" fill="#fff" stroke="#1d4fa8"/> | |||
| 3 | + | <rect x="3" y="4" width="10" height="1.5" fill="#1d4fa8"/> | |||
| 4 | + | <rect x="3" y="7" width="7" height="1.5" fill="#1d4fa8"/> | |||
| 5 | + | </svg> | |||
A static/aim/info.svg +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |
| 2 | + | <circle cx="8" cy="8" r="7" fill="#3b7ddd" stroke="#1d4fa8"/> | |
| 3 | + | <rect x="7" y="7" width="2" height="5" fill="#fff"/> | |
| 4 | + | <circle cx="8" cy="4.5" r="1.2" fill="#fff"/> | |
| 5 | + | </svg> | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"> | |||
| 2 | + | <circle cx="8" cy="8" r="7" fill="#3b7ddd" stroke="#1d4fa8"/> | |||
| 3 | + | <rect x="7" y="7" width="2" height="5" fill="#fff"/> | |||
| 4 | + | <circle cx="8" cy="4.5" r="1.2" fill="#fff"/> | |||
| 5 | + | </svg> | |||
A static/aim/running-man.svg +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> | |
| 2 | + | <circle cx="16" cy="16" r="15" fill="#f6c700" stroke="#c99a00" stroke-width="1"/> | |
| 3 | + | <g fill="#1b1b1b"> | |
| 4 | + | <circle cx="19" cy="8" r="3"/> | |
| 5 | + | <path d="M14 12 l6 1 l3 5 l-2 1 l-2.5 -3 l-1 4 l4 4 l-1.5 5 l-2.5 -1 l1 -3.5 l-4 -3 l-3 5 l-2.5 -1.5 l3.5 -6 l1.5 -4 l-4 1 l-1 -2.5z"/> | |
| 6 | + | </g> | |
| 7 | + | </svg> | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> | |||
| 2 | + | <circle cx="16" cy="16" r="15" fill="#f6c700" stroke="#c99a00" stroke-width="1"/> | |||
| 3 | + | <g fill="#1b1b1b"> | |||
| 4 | + | <circle cx="19" cy="8" r="3"/> | |||
| 5 | + | <path d="M14 12 l6 1 l3 5 l-2 1 l-2.5 -3 l-1 4 l4 4 l-1.5 5 l-2.5 -1 l1 -3.5 l-4 -3 l-3 5 l-2.5 -1.5 l3.5 -6 l1.5 -4 l-4 1 l-1 -2.5z"/> | |||
| 6 | + | </g> | |||
| 7 | + | </svg> | |||
A static/aim/start.svg +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M1 3l6-1v6H1zM8 2l7-1v7H8zM1 9h6v6l-6-1zM8 9h7v7l-7-1z" fill="#fff"/><path d="M1 3l6-1v6H1z" fill="#f35325"/><path d="M8 2l7-1v7H8z" fill="#81bc06"/><path d="M1 9h6v6l-6-1z" fill="#05a6f0"/><path d="M8 9h7v7l-7-1z" fill="#ffba08"/></svg> | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M1 3l6-1v6H1zM8 2l7-1v7H8zM1 9h6v6l-6-1zM8 9h7v7l-7-1z" fill="#fff"/><path d="M1 3l6-1v6H1z" fill="#f35325"/><path d="M8 2l7-1v7H8z" fill="#81bc06"/><path d="M1 9h6v6l-6-1z" fill="#05a6f0"/><path d="M8 9h7v7l-7-1z" fill="#ffba08"/></svg> | |||
A static/aim/status-away.svg +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><rect x="1" y="2" width="10" height="8" fill="#fff8c4" stroke="#b58b00"/><path d="M3 5h6M3 7h4" stroke="#b58b00"/></svg> | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><rect x="1" y="2" width="10" height="8" fill="#fff8c4" stroke="#b58b00"/><path d="M3 5h6M3 7h4" stroke="#b58b00"/></svg> | |||
A static/aim/status-busy.svg +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#e0512f" stroke="#8a2a10"/><rect x="3" y="5.2" width="6" height="1.6" fill="#fff"/></svg> | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#e0512f" stroke="#8a2a10"/><rect x="3" y="5.2" width="6" height="1.6" fill="#fff"/></svg> | |||
A static/aim/status-idle.svg +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#d9d9d9" stroke="#8a8a8a"/><path d="M4.5 3.5l3 1-1.5 4-1.5-1z" fill="#6b6b6b"/></svg> | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#d9d9d9" stroke="#8a8a8a"/><path d="M4.5 3.5l3 1-1.5 4-1.5-1z" fill="#6b6b6b"/></svg> | |||
A static/aim/status-offline.svg +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="#8a8a8a" stroke-dasharray="2 1.5"/></svg> | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="#8a8a8a" stroke-dasharray="2 1.5"/></svg> | |||
A static/aim/status-online.svg +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#f6c700" stroke="#8a6d00"/><path d="M4.5 3.5l3 1-1.5 4-1.5-1z" fill="#1b1b1b"/></svg> | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><circle cx="6" cy="6" r="4.5" fill="#f6c700" stroke="#8a6d00"/><path d="M4.5 3.5l3 1-1.5 4-1.5-1z" fill="#1b1b1b"/></svg> | |||