From 71e018d80237ef5078217196d4a93194e76b9c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82=20=7C=20Zaid=20Omar=20Medh?= =?UTF-8?q?at?= Date: Fri, 10 Jul 2026 16:54:40 +0500 Subject: [PATCH] init presence --- README.md | 10 +- infra/centrifugo/config.json | 3 +- .../1720000000003_conversations.cjs | 46 ++++ .../backend/migrations/1720000000004_live.cjs | 42 +++ packages/backend/src/app.ts | 50 +++- packages/backend/src/db/schema.ts | 42 ++- .../backend/src/modules/auth/auth.service.ts | 2 + .../modules/contacts/contacts.repository.ts | 52 ++++ .../src/modules/contacts/contacts.routes.ts | 72 +++++ .../src/modules/contacts/contacts.service.ts | 60 +++++ .../backend/src/modules/contacts/index.ts | 5 + .../conversations/conversations.mapper.ts | 31 +++ .../conversations/conversations.repository.ts | 251 ++++++++++++++++++ .../conversations/conversations.routes.ts | 222 ++++++++++++++++ .../conversations/conversations.service.ts | 202 ++++++++++++++ .../src/modules/conversations/delivery.ts | 27 ++ .../src/modules/conversations/index.ts | 15 ++ .../conversations/read-state.repository.ts | 65 +++++ .../src/modules/messages/messages.mapper.ts | 12 +- .../modules/messages/messages.repository.ts | 110 +++++++- .../src/modules/messages/messages.routes.ts | 139 +++++++++- .../src/modules/messages/messages.service.ts | 143 ++++++++-- .../backend/src/modules/presence/index.ts | 3 + .../src/modules/presence/presence.routes.ts | 37 +++ .../src/modules/presence/presence.service.ts | 38 +++ .../src/modules/realtime/realtime.routes.ts | 5 +- .../src/modules/realtime/realtime.service.ts | 14 +- packages/backend/src/modules/rooms/index.ts | 6 - .../backend/src/modules/rooms/rooms.mapper.ts | 21 -- .../src/modules/rooms/rooms.repository.ts | 103 ------- .../backend/src/modules/rooms/rooms.routes.ts | 144 ---------- .../src/modules/rooms/rooms.service.ts | 101 ------- .../src/modules/users/users.repository.ts | 19 ++ .../backend/src/modules/users/users.routes.ts | 55 +++- .../src/modules/users/users.service.ts | 18 ++ packages/backend/src/plugins/centrifugo.ts | 30 +++ packages/core/src/api/contacts.ts | 20 ++ packages/core/src/api/conversations.ts | 71 +++++ packages/core/src/api/index.ts | 23 +- packages/core/src/api/messages.ts | 57 +++- packages/core/src/api/presence.ts | 21 ++ packages/core/src/api/rooms.ts | 45 ---- packages/core/src/api/users.ts | 15 ++ packages/core/src/channels/index.ts | 22 +- packages/core/src/events/index.ts | 58 +++- packages/core/src/schemas/conversation.ts | 62 +++++ packages/core/src/schemas/entities.ts | 79 +++++- packages/core/src/schemas/index.ts | 37 ++- packages/core/src/schemas/live.ts | 42 +++ packages/core/src/schemas/room.ts | 36 --- packages/core/src/types/contact.ts | 7 + packages/core/src/types/conversation.ts | 23 ++ packages/core/src/types/index.ts | 6 +- packages/core/src/types/message.ts | 15 +- packages/core/src/types/presence.ts | 5 + packages/core/src/types/room.ts | 15 -- packages/web/src/app/App.tsx | 93 +++++-- packages/web/src/app/index.css | 173 ++++++++++++ packages/web/src/features/contacts/index.ts | 3 + packages/web/src/features/contacts/model.ts | 34 +++ .../features/contacts/ui/ContactsPanel.tsx | 75 ++++++ .../web/src/features/conversations/index.ts | 3 + .../web/src/features/conversations/model.ts | 81 ++++++ .../conversations/ui/ConversationSidebar.tsx | 160 +++++++++++ packages/web/src/features/messaging/index.ts | 4 +- packages/web/src/features/messaging/model.ts | 239 +++++++++++++++-- .../src/features/messaging/ui/ChatView.tsx | 97 ++++++- packages/web/src/features/rooms/index.ts | 3 - packages/web/src/features/rooms/model.ts | 50 ---- .../web/src/features/rooms/ui/RoomSidebar.tsx | 104 -------- 70 files changed, 3179 insertions(+), 794 deletions(-) create mode 100644 packages/backend/migrations/1720000000003_conversations.cjs create mode 100644 packages/backend/migrations/1720000000004_live.cjs create mode 100644 packages/backend/src/modules/contacts/contacts.repository.ts create mode 100644 packages/backend/src/modules/contacts/contacts.routes.ts create mode 100644 packages/backend/src/modules/contacts/contacts.service.ts create mode 100644 packages/backend/src/modules/contacts/index.ts create mode 100644 packages/backend/src/modules/conversations/conversations.mapper.ts create mode 100644 packages/backend/src/modules/conversations/conversations.repository.ts create mode 100644 packages/backend/src/modules/conversations/conversations.routes.ts create mode 100644 packages/backend/src/modules/conversations/conversations.service.ts create mode 100644 packages/backend/src/modules/conversations/delivery.ts create mode 100644 packages/backend/src/modules/conversations/index.ts create mode 100644 packages/backend/src/modules/conversations/read-state.repository.ts create mode 100644 packages/backend/src/modules/presence/index.ts create mode 100644 packages/backend/src/modules/presence/presence.routes.ts create mode 100644 packages/backend/src/modules/presence/presence.service.ts delete mode 100644 packages/backend/src/modules/rooms/index.ts delete mode 100644 packages/backend/src/modules/rooms/rooms.mapper.ts delete mode 100644 packages/backend/src/modules/rooms/rooms.repository.ts delete mode 100644 packages/backend/src/modules/rooms/rooms.routes.ts delete mode 100644 packages/backend/src/modules/rooms/rooms.service.ts create mode 100644 packages/core/src/api/contacts.ts create mode 100644 packages/core/src/api/conversations.ts create mode 100644 packages/core/src/api/presence.ts delete mode 100644 packages/core/src/api/rooms.ts create mode 100644 packages/core/src/api/users.ts create mode 100644 packages/core/src/schemas/conversation.ts create mode 100644 packages/core/src/schemas/live.ts delete mode 100644 packages/core/src/schemas/room.ts create mode 100644 packages/core/src/types/contact.ts create mode 100644 packages/core/src/types/conversation.ts create mode 100644 packages/core/src/types/presence.ts delete mode 100644 packages/core/src/types/room.ts create mode 100644 packages/web/src/features/contacts/index.ts create mode 100644 packages/web/src/features/contacts/model.ts create mode 100644 packages/web/src/features/contacts/ui/ContactsPanel.tsx create mode 100644 packages/web/src/features/conversations/index.ts create mode 100644 packages/web/src/features/conversations/model.ts create mode 100644 packages/web/src/features/conversations/ui/ConversationSidebar.tsx delete mode 100644 packages/web/src/features/rooms/index.ts delete mode 100644 packages/web/src/features/rooms/model.ts delete mode 100644 packages/web/src/features/rooms/ui/RoomSidebar.tsx diff --git a/README.md b/README.md index 2053435..6c9bf0d 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,13 @@ Open the Swagger UI, then: 2. Click **Authorize** (top right), paste the access token, and authorize. 3. Protected endpoints (`/me`, `/auth/sessions`, `/auth/centrifugo-token`, …) now work from "Try it out". -> Dev note: the backend runs in Docker with `node_modules` baked into the image. -> After changing backend dependencies, recreate the container so it picks them up: -> `docker compose up -d --build --force-recreate backend`. +> Dev notes: +> - The backend runs in Docker with `node_modules` baked into the image. After +> changing backend dependencies, recreate the container so it picks them up: +> `docker compose up -d --build --force-recreate backend`. +> - nginx resolves the `backend` upstream once at startup, so after recreating the +> backend container, restart nginx too: `docker compose restart nginx` +> (otherwise you'll get 502s from a stale cached IP). ## Quality gates (enforced mechanically — a violation fails the build) diff --git a/infra/centrifugo/config.json b/infra/centrifugo/config.json index 05f15ac..1940188 100644 --- a/infra/centrifugo/config.json +++ b/infra/centrifugo/config.json @@ -15,7 +15,7 @@ "channel": { "namespaces": [ { - "name": "room", + "name": "conv", "presence": true, "join_leave": true, "force_push_join_leave": true, @@ -28,6 +28,7 @@ { "name": "user", "presence": true, + "join_leave": true, "history_size": 100, "history_ttl": "300s", "force_recovery": true, diff --git a/packages/backend/migrations/1720000000003_conversations.cjs b/packages/backend/migrations/1720000000003_conversations.cjs new file mode 100644 index 0000000..8ab8f7d --- /dev/null +++ b/packages/backend/migrations/1720000000003_conversations.cjs @@ -0,0 +1,46 @@ +// Phase 4 — reshape rooms into a unified "conversations" model (direct + group) +// and add contacts. Renames preserve existing group data (existing rooms become +// type='group'). + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + // rooms -> conversations + pgm.renameTable('rooms', 'conversations'); + pgm.renameColumn('conversations', 'name', 'title'); + pgm.alterColumn('conversations', 'title', { notNull: false }); + pgm.addColumns('conversations', { + type: { type: 'text', notNull: true, default: 'group' }, + // Canonical sorted user-pair for a direct conversation; unique so a 1:1 is + // never duplicated. NULL for groups (Postgres allows many NULLs in UNIQUE). + direct_key: { type: 'text' }, + last_message_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('conversations', 'conversations_direct_key_uq', { unique: ['direct_key'] }); + + // room_members -> conversation_members + pgm.renameTable('room_members', 'conversation_members'); + pgm.renameColumn('conversation_members', 'room_id', 'conversation_id'); + + // messages.room_id -> conversation_id (indexes + dedupe constraint follow the rename) + pgm.renameColumn('messages', 'room_id', 'conversation_id'); + + pgm.createTable('contacts', { + owner_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + contact_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('contacts', 'contacts_pkey', { primaryKey: ['owner_id', 'contact_id'] }); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.dropTable('contacts'); + pgm.renameColumn('messages', 'conversation_id', 'room_id'); + pgm.renameColumn('conversation_members', 'conversation_id', 'room_id'); + pgm.renameTable('conversation_members', 'room_members'); + pgm.dropConstraint('conversations', 'conversations_direct_key_uq'); + pgm.dropColumns('conversations', ['type', 'direct_key', 'last_message_at']); + pgm.alterColumn('conversations', 'title', { notNull: true }); + pgm.renameColumn('conversations', 'title', 'name'); + pgm.renameTable('conversations', 'rooms'); +}; diff --git a/packages/backend/migrations/1720000000004_live.cjs b/packages/backend/migrations/1720000000004_live.cjs new file mode 100644 index 0000000..85279cf --- /dev/null +++ b/packages/backend/migrations/1720000000004_live.cjs @@ -0,0 +1,42 @@ +// Phase 5 — live features: message edits/deletes, reactions, read state. + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.addColumns('messages', { + edited_at: { type: 'timestamptz' }, + deleted_at: { type: 'timestamptz' }, + }); + + pgm.createTable('reactions', { + message_id: { type: 'uuid', notNull: true, references: 'messages', onDelete: 'CASCADE' }, + user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + emoji: { type: 'text', notNull: true }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('reactions', 'reactions_pkey', { + primaryKey: ['message_id', 'user_id', 'emoji'], + }); + pgm.createIndex('reactions', 'message_id'); + + pgm.createTable('read_state', { + conversation_id: { + type: 'uuid', + notNull: true, + references: 'conversations', + onDelete: 'CASCADE', + }, + user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + last_read_seq: { type: 'bigint', notNull: true, default: 0 }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('read_state', 'read_state_pkey', { + primaryKey: ['conversation_id', 'user_id'], + }); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.dropTable('read_state'); + pgm.dropTable('reactions'); + pgm.dropColumns('messages', ['edited_at', 'deleted_at']); +}; diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 2be45a3..63b923b 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -2,7 +2,7 @@ import Fastify from 'fastify'; import type { FastifyError, FastifyInstance } from 'fastify'; import cors from '@fastify/cors'; import cookie from '@fastify/cookie'; -import { roomChannel } from '@altricade/core'; +import { conversationChannel, userChannel } from '@altricade/core'; import { loadConfig } from './config'; import type { AppConfig } from './config'; import { HttpError } from './shared/http-error'; @@ -15,8 +15,16 @@ import { swaggerPlugin } from './plugins/swagger'; import { healthRoutes } from './routes/health'; import { createUsersRepository, createUsersService, usersRoutes } from './modules/users'; import { createRefreshTokensRepository, createAuthService, authPlugin, authRoutes } from './modules/auth'; -import { createRoomsRepository, createRoomsService, roomsRoutes } from './modules/rooms'; +import { + createConversationsRepository, + createConversationsService, + createReadStateRepository, + createDeliver, + conversationsRoutes, +} from './modules/conversations'; import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages'; +import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts'; +import { createPresenceService, presenceRoutes } from './modules/presence'; import { realtimeRoutes } from './modules/realtime'; import type { Publisher } from './shared/publisher'; @@ -76,8 +84,11 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise app.centrifugo.publish(channel, data); const usersRepository = createUsersRepository(app.db); const refreshTokens = createRefreshTokensRepository(app.db); - const roomsRepository = createRoomsRepository(app.db); + const conversationsRepository = createConversationsRepository(app.db); + const readStateRepository = createReadStateRepository(app.db); const messagesRepository = createMessagesRepository(app.db); + const contactsRepository = createContactsRepository(app.db); + const deliver = createDeliver(conversationsRepository, publish); app.decorate('usersService', createUsersService(usersRepository)); app.decorate( @@ -95,12 +106,33 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise (await app.centrifugo.presenceStats(userChannel(userId))) > 0, + }), ); // Auth preHandler decorator must exist before routes that use it register. @@ -110,11 +142,13 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise; - name: string; + type: Generated; + title: string | null; + direct_key: string | null; created_by: string; created_at: Generated; + last_message_at: Generated; } -export interface RoomMembersTable { - room_id: string; +export interface ConversationMembersTable { + conversation_id: string; user_id: string; role: Generated; joined_at: Generated; @@ -46,19 +49,44 @@ export interface MessagesTable { id: Generated; // int8 — parsed to a JS number by the pg type parser in the db plugin. seq: Generated; - room_id: string; + conversation_id: string; sender_id: string; client_msg_id: string; content: string; content_type: Generated; encryption: string | null; created_at: Generated; + edited_at: Date | null; + deleted_at: Date | null; +} + +export interface ContactsTable { + owner_id: string; + contact_id: string; + created_at: Generated; +} + +export interface ReactionsTable { + message_id: string; + user_id: string; + emoji: string; + created_at: Generated; +} + +export interface ReadStateTable { + conversation_id: string; + user_id: string; + last_read_seq: Generated; + updated_at: ColumnType; } export interface Database { users: UsersTable; refresh_tokens: RefreshTokensTable; - rooms: RoomsTable; - room_members: RoomMembersTable; + conversations: ConversationsTable; + conversation_members: ConversationMembersTable; messages: MessagesTable; + contacts: ContactsTable; + reactions: ReactionsTable; + read_state: ReadStateTable; } diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts index 3078729..e2cf146 100644 --- a/packages/backend/src/modules/auth/auth.service.ts +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -168,6 +168,8 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => { }, centrifugoToken: async (userId) => { + // The client fetches this to (re)connect — a good moment to mark them seen. + await users.touchLastSeen(userId); const token = await signCentrifugoToken( config.centrifugoSecret, config.centrifugoTtlSeconds, diff --git a/packages/backend/src/modules/contacts/contacts.repository.ts b/packages/backend/src/modules/contacts/contacts.repository.ts new file mode 100644 index 0000000..e1d40e5 --- /dev/null +++ b/packages/backend/src/modules/contacts/contacts.repository.ts @@ -0,0 +1,52 @@ +import type { Kysely } from 'kysely'; +import type { Database } from '../../db/schema'; + +export interface ContactWithUser { + contact_id: string; + created_at: Date; + username: string; + display_name: string; + avatar_ref: string | null; +} + +export interface ContactsRepository { + add(ownerId: string, contactId: string): Promise; + remove(ownerId: string, contactId: string): Promise; + listForOwner(ownerId: string): Promise; + getOne(ownerId: string, contactId: string): Promise; +} + +export const createContactsRepository = (db: Kysely): ContactsRepository => { + const withUser = (ownerId: string) => + db + .selectFrom('contacts') + .innerJoin('users', 'users.id', 'contacts.contact_id') + .where('contacts.owner_id', '=', ownerId) + .select([ + 'contacts.contact_id as contact_id', + 'contacts.created_at as created_at', + 'users.username as username', + 'users.display_name as display_name', + 'users.avatar_ref as avatar_ref', + ]); + + return { + add: async (ownerId, contactId) => { + await db + .insertInto('contacts') + .values({ owner_id: ownerId, contact_id: contactId }) + .onConflict((oc) => oc.columns(['owner_id', 'contact_id']).doNothing()) + .execute(); + }, + remove: async (ownerId, contactId) => { + await db + .deleteFrom('contacts') + .where('owner_id', '=', ownerId) + .where('contact_id', '=', contactId) + .execute(); + }, + listForOwner: (ownerId) => withUser(ownerId).orderBy('users.username', 'asc').execute(), + getOne: (ownerId, contactId) => + withUser(ownerId).where('contacts.contact_id', '=', contactId).executeTakeFirst(), + }; +}; diff --git a/packages/backend/src/modules/contacts/contacts.routes.ts b/packages/backend/src/modules/contacts/contacts.routes.ts new file mode 100644 index 0000000..1a77c0c --- /dev/null +++ b/packages/backend/src/modules/contacts/contacts.routes.ts @@ -0,0 +1,72 @@ +import type { FastifyInstance } from 'fastify'; +import { addContactBodySchema, contactSchema, contactListSchema, errorSchema } from '@altricade/core'; +import type { AddContactBody } from '@altricade/core'; + +const bearerAuth = [{ bearerAuth: [] }]; +const userParamsSchema = { + type: 'object', + required: ['userId'], + properties: { userId: { type: 'string', format: 'uuid' } }, +} as const; + +export const contactsRoutes = (app: FastifyInstance): Promise => { + app.get( + '/contacts', + { + schema: { + tags: ['contacts'], + summary: 'List my contacts', + security: bearerAuth, + response: { 200: contactListSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.contactsService.list(user.id)); + }, + ); + + app.post<{ Body: AddContactBody }>( + '/contacts', + { + schema: { + tags: ['contacts'], + summary: 'Add a contact by username', + security: bearerAuth, + body: addContactBodySchema, + response: { 201: contactSchema, 400: errorSchema, 401: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const contact = await app.contactsService.add(user.id, request.body.username); + return reply.code(201).send(contact); + }, + ); + + app.delete<{ Params: { userId: string } }>( + '/contacts/:userId', + { + schema: { + tags: ['contacts'], + summary: 'Remove a contact', + security: bearerAuth, + params: userParamsSchema, + response: { 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.contactsService.remove(user.id, request.params.userId); + return reply.code(204).send(); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/contacts/contacts.service.ts b/packages/backend/src/modules/contacts/contacts.service.ts new file mode 100644 index 0000000..bf1f559 --- /dev/null +++ b/packages/backend/src/modules/contacts/contacts.service.ts @@ -0,0 +1,60 @@ +import type { Contact } from '@altricade/core'; +import { HttpError } from '../../shared/http-error'; +import type { UsersRepository } from '../users'; +import type { ContactsRepository, ContactWithUser } from './contacts.repository'; + +export interface ContactsServiceDeps { + contacts: ContactsRepository; + users: UsersRepository; +} + +export interface ContactsService { + list(ownerId: string): Promise; + add(ownerId: string, username: string): Promise; + remove(ownerId: string, contactId: string): Promise; +} + +const toContact = (row: ContactWithUser): Contact => ({ + userId: row.contact_id, + user: { + id: row.contact_id, + username: row.username, + displayName: row.display_name, + avatarUrl: row.avatar_ref, + }, + createdAt: row.created_at.toISOString(), +}); + +export const createContactsService = (deps: ContactsServiceDeps): ContactsService => { + const { contacts, users } = deps; + + return { + list: async (ownerId) => (await contacts.listForOwner(ownerId)).map(toContact), + + add: async (ownerId, username) => { + const target = await users.findByUsername(username); + if (target === undefined) { + throw new HttpError(404, 'user_not_found', 'No such user'); + } + if (target.id === ownerId) { + throw new HttpError(400, 'invalid_target', 'Cannot add yourself as a contact'); + } + await contacts.add(ownerId, target.id); + const row = await contacts.getOne(ownerId, target.id); + if (row === undefined) { + throw new HttpError(500, 'internal_error', 'Contact not found after add'); + } + return toContact(row); + }, + + remove: async (ownerId, contactId) => { + await contacts.remove(ownerId, contactId); + }, + }; +}; + +declare module 'fastify' { + interface FastifyInstance { + contactsService: ContactsService; + } +} diff --git a/packages/backend/src/modules/contacts/index.ts b/packages/backend/src/modules/contacts/index.ts new file mode 100644 index 0000000..9d1c426 --- /dev/null +++ b/packages/backend/src/modules/contacts/index.ts @@ -0,0 +1,5 @@ +export { createContactsRepository } from './contacts.repository'; +export type { ContactsRepository, ContactWithUser } from './contacts.repository'; +export { createContactsService } from './contacts.service'; +export type { ContactsService, ContactsServiceDeps } from './contacts.service'; +export { contactsRoutes } from './contacts.routes'; diff --git a/packages/backend/src/modules/conversations/conversations.mapper.ts b/packages/backend/src/modules/conversations/conversations.mapper.ts new file mode 100644 index 0000000..68cd86b --- /dev/null +++ b/packages/backend/src/modules/conversations/conversations.mapper.ts @@ -0,0 +1,31 @@ +import type { Conversation, ConversationMember, PublicUser } from '@altricade/core'; +import type { ConversationRow, MemberWithUser } from './conversations.repository'; + +export const memberToPublicUser = (member: MemberWithUser): PublicUser => ({ + id: member.user_id, + username: member.username, + displayName: member.display_name, + avatarUrl: member.avatar_ref, +}); + +export const toConversation = ( + row: ConversationRow, + peer: PublicUser | null, + unreadCount = 0, +): Conversation => ({ + id: row.id, + type: row.type === 'direct' ? 'direct' : 'group', + title: row.title, + peer, + createdBy: row.created_by, + createdAt: row.created_at.toISOString(), + lastMessageAt: row.last_message_at.toISOString(), + unreadCount, +}); + +export const toMember = (member: MemberWithUser): ConversationMember => ({ + userId: member.user_id, + role: member.role, + joinedAt: member.joined_at.toISOString(), + user: memberToPublicUser(member), +}); diff --git a/packages/backend/src/modules/conversations/conversations.repository.ts b/packages/backend/src/modules/conversations/conversations.repository.ts new file mode 100644 index 0000000..30f51ab --- /dev/null +++ b/packages/backend/src/modules/conversations/conversations.repository.ts @@ -0,0 +1,251 @@ +import type { Kysely, Selectable } from 'kysely'; +import type { Database, ConversationsTable } from '../../db/schema'; + +export type ConversationRow = Selectable; + +export interface MemberWithUser { + user_id: string; + role: string; + joined_at: Date; + username: string; + display_name: string; + avatar_ref: string | null; +} + +export interface DeliveryInfo { + type: string; + memberIds: string[]; +} + +export interface FindOrCreate { + conversation: ConversationRow; + created: boolean; +} + +const directKeyFor = (a: string, b: string): string => (a < b ? `${a}:${b}` : `${b}:${a}`); + +export interface ConversationsRepository { + createGroup(title: string, ownerId: string, memberIds: string[]): Promise; + findOrCreateDirect(userA: string, userB: string): Promise; + findById(id: string): Promise; + listForUser(userId: string): Promise; + peersForDirect(userId: string, conversationIds: string[]): Promise>; + getPeer(conversationId: string, userId: string): Promise; + isMember(conversationId: string, userId: string): Promise; + getRole(conversationId: string, userId: string): Promise; + addMember(conversationId: string, userId: string, role: string): Promise; + removeMember(conversationId: string, userId: string): Promise; + listMembers(conversationId: string): Promise; + getDeliveryInfo(conversationId: string): Promise; + touchLastMessage(conversationId: string): Promise; +} + +export const createConversationsRepository = (db: Kysely): ConversationsRepository => { + const membersWithUser = (conversationId: string) => + db + .selectFrom('conversation_members') + .innerJoin('users', 'users.id', 'conversation_members.user_id') + .where('conversation_members.conversation_id', '=', conversationId) + .select([ + 'conversation_members.user_id as user_id', + 'conversation_members.role as role', + 'conversation_members.joined_at as joined_at', + 'users.username as username', + 'users.display_name as display_name', + 'users.avatar_ref as avatar_ref', + ]); + + return { + createGroup: (title, ownerId, memberIds) => + db.transaction().execute(async (trx) => { + const conversation = await trx + .insertInto('conversations') + .values({ type: 'group', title, created_by: ownerId }) + .returningAll() + .executeTakeFirstOrThrow(); + const uniqueOthers = memberIds.filter((id) => id !== ownerId); + const rows = [ + { conversation_id: conversation.id, user_id: ownerId, role: 'owner' }, + ...uniqueOthers.map((id) => ({ + conversation_id: conversation.id, + user_id: id, + role: 'member', + })), + ]; + await trx + .insertInto('conversation_members') + .values(rows) + .onConflict((oc) => oc.columns(['conversation_id', 'user_id']).doNothing()) + .execute(); + return conversation; + }), + + findOrCreateDirect: async (userA, userB) => { + const directKey = directKeyFor(userA, userB); + const existing = await db + .selectFrom('conversations') + .selectAll() + .where('direct_key', '=', directKey) + .executeTakeFirst(); + if (existing !== undefined) { + return { conversation: existing, created: false }; + } + try { + const conversation = await db.transaction().execute(async (trx) => { + const created = await trx + .insertInto('conversations') + .values({ type: 'direct', direct_key: directKey, created_by: userA }) + .returningAll() + .executeTakeFirstOrThrow(); + await trx + .insertInto('conversation_members') + .values([ + { conversation_id: created.id, user_id: userA, role: 'member' }, + { conversation_id: created.id, user_id: userB, role: 'member' }, + ]) + .execute(); + return created; + }); + return { conversation, created: true }; + } catch { + // Lost a race to create it; fetch the winner. + const winner = await db + .selectFrom('conversations') + .selectAll() + .where('direct_key', '=', directKey) + .executeTakeFirstOrThrow(); + return { conversation: winner, created: false }; + } + }, + + findById: (id) => + db.selectFrom('conversations').selectAll().where('id', '=', id).executeTakeFirst(), + + listForUser: (userId) => + db + .selectFrom('conversations') + .innerJoin( + 'conversation_members', + 'conversation_members.conversation_id', + 'conversations.id', + ) + .where('conversation_members.user_id', '=', userId) + .selectAll('conversations') + .orderBy('conversations.last_message_at', 'desc') + .execute(), + + peersForDirect: async (userId, conversationIds) => { + const map = new Map(); + if (conversationIds.length === 0) { + return map; + } + const rows = await db + .selectFrom('conversation_members') + .innerJoin('users', 'users.id', 'conversation_members.user_id') + .where('conversation_members.conversation_id', 'in', conversationIds) + .where('conversation_members.user_id', '!=', userId) + .select([ + 'conversation_members.conversation_id as conversation_id', + 'conversation_members.user_id as user_id', + 'conversation_members.role as role', + 'conversation_members.joined_at as joined_at', + 'users.username as username', + 'users.display_name as display_name', + 'users.avatar_ref as avatar_ref', + ]) + .execute(); + for (const row of rows) { + map.set(row.conversation_id, { + user_id: row.user_id, + role: row.role, + joined_at: row.joined_at, + username: row.username, + display_name: row.display_name, + avatar_ref: row.avatar_ref, + }); + } + return map; + }, + + getPeer: async (conversationId, userId) => { + const row = await db + .selectFrom('conversation_members') + .innerJoin('users', 'users.id', 'conversation_members.user_id') + .where('conversation_members.conversation_id', '=', conversationId) + .where('conversation_members.user_id', '!=', userId) + .select([ + 'conversation_members.user_id as user_id', + 'conversation_members.role as role', + 'conversation_members.joined_at as joined_at', + 'users.username as username', + 'users.display_name as display_name', + 'users.avatar_ref as avatar_ref', + ]) + .executeTakeFirst(); + return row; + }, + + isMember: async (conversationId, userId) => { + const row = await db + .selectFrom('conversation_members') + .select('user_id') + .where('conversation_id', '=', conversationId) + .where('user_id', '=', userId) + .executeTakeFirst(); + return row !== undefined; + }, + + getRole: async (conversationId, userId) => { + const row = await db + .selectFrom('conversation_members') + .select('role') + .where('conversation_id', '=', conversationId) + .where('user_id', '=', userId) + .executeTakeFirst(); + return row?.role; + }, + + addMember: async (conversationId, userId, role) => { + await db + .insertInto('conversation_members') + .values({ conversation_id: conversationId, user_id: userId, role }) + .onConflict((oc) => oc.columns(['conversation_id', 'user_id']).doNothing()) + .execute(); + }, + + removeMember: async (conversationId, userId) => { + await db + .deleteFrom('conversation_members') + .where('conversation_id', '=', conversationId) + .where('user_id', '=', userId) + .execute(); + }, + + listMembers: (conversationId) => membersWithUser(conversationId).orderBy('joined_at', 'asc').execute(), + + getDeliveryInfo: async (conversationId) => { + const conversation = await db + .selectFrom('conversations') + .select('type') + .where('id', '=', conversationId) + .executeTakeFirst(); + if (conversation === undefined) { + return undefined; + } + const members = await db + .selectFrom('conversation_members') + .select('user_id') + .where('conversation_id', '=', conversationId) + .execute(); + return { type: conversation.type, memberIds: members.map((m) => m.user_id) }; + }, + + touchLastMessage: async (conversationId) => { + await db + .updateTable('conversations') + .set({ last_message_at: new Date() }) + .where('id', '=', conversationId) + .execute(); + }, + }; +}; diff --git a/packages/backend/src/modules/conversations/conversations.routes.ts b/packages/backend/src/modules/conversations/conversations.routes.ts new file mode 100644 index 0000000..39255a3 --- /dev/null +++ b/packages/backend/src/modules/conversations/conversations.routes.ts @@ -0,0 +1,222 @@ +import type { FastifyInstance } from 'fastify'; +import { + createDirectBodySchema, + createGroupBodySchema, + addMemberBodySchema, + readBodySchema, + typingBodySchema, + conversationSchema, + conversationListSchema, + conversationMemberListSchema, + errorSchema, +} from '@altricade/core'; +import type { + CreateDirectBody, + CreateGroupBody, + AddMemberBody, + ReadBody, + TypingBody, +} from '@altricade/core'; + +const bearerAuth = [{ bearerAuth: [] }]; +const idParamsSchema = { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', format: 'uuid' } }, +} as const; +const memberParamsSchema = { + type: 'object', + required: ['id', 'userId'], + properties: { id: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' } }, +} as const; + +export const conversationsRoutes = (app: FastifyInstance): Promise => { + app.get( + '/conversations', + { + schema: { + tags: ['conversations'], + summary: 'List my conversations (DMs + groups, newest first)', + security: bearerAuth, + response: { 200: conversationListSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.conversationsService.list(user.id)); + }, + ); + + app.post<{ Body: CreateDirectBody }>( + '/conversations/direct', + { + schema: { + tags: ['conversations'], + summary: 'Start (or reopen) a direct conversation with a user', + security: bearerAuth, + body: createDirectBodySchema, + response: { 201: conversationSchema, 400: errorSchema, 401: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const conversation = await app.conversationsService.createDirect(user.id, request.body.username); + return reply.code(201).send(conversation); + }, + ); + + app.post<{ Body: CreateGroupBody }>( + '/conversations', + { + schema: { + tags: ['conversations'], + summary: 'Create a group conversation', + security: bearerAuth, + body: createGroupBodySchema, + response: { 201: conversationSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const conversation = await app.conversationsService.createGroup( + user.id, + request.body.title, + request.body.members ?? [], + ); + return reply.code(201).send(conversation); + }, + ); + + app.get<{ Params: { id: string } }>( + '/conversations/:id', + { + schema: { + tags: ['conversations'], + summary: 'Get a conversation (member only)', + security: bearerAuth, + params: idParamsSchema, + response: { 200: conversationSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.conversationsService.get(request.params.id, user.id)); + }, + ); + + app.get<{ Params: { id: string } }>( + '/conversations/:id/members', + { + schema: { + tags: ['conversations'], + summary: 'List conversation members (member only)', + security: bearerAuth, + params: idParamsSchema, + response: { 200: conversationMemberListSchema, 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.conversationsService.listMembers(request.params.id, user.id)); + }, + ); + + app.post<{ Params: { id: string }; Body: AddMemberBody }>( + '/conversations/:id/members', + { + schema: { + tags: ['conversations'], + summary: 'Add a member to a group (owner only)', + security: bearerAuth, + params: idParamsSchema, + body: addMemberBodySchema, + response: { 201: conversationMemberListSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const members = await app.conversationsService.addMember( + request.params.id, + user.id, + request.body.username, + ); + return reply.code(201).send(members); + }, + ); + + app.delete<{ Params: { id: string; userId: string } }>( + '/conversations/:id/members/:userId', + { + schema: { + tags: ['conversations'], + summary: 'Remove a member (owner) or leave (self)', + security: bearerAuth, + params: memberParamsSchema, + response: { 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.conversationsService.removeMember(request.params.id, user.id, request.params.userId); + return reply.code(204).send(); + }, + ); + + app.post<{ Params: { id: string }; Body: ReadBody }>( + '/conversations/:id/read', + { + schema: { + tags: ['live'], + summary: 'Mark read up to a sequence number', + security: bearerAuth, + params: idParamsSchema, + body: readBodySchema, + response: { 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.conversationsService.markRead(request.params.id, user.id, request.body.seq); + return reply.code(204).send(); + }, + ); + + app.post<{ Params: { id: string }; Body: TypingBody }>( + '/conversations/:id/typing', + { + schema: { + tags: ['live'], + summary: 'Send a typing indicator (ephemeral)', + security: bearerAuth, + params: idParamsSchema, + body: typingBodySchema, + response: { 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.conversationsService.setTyping(request.params.id, user.id, request.body.state); + return reply.code(204).send(); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/conversations/conversations.service.ts b/packages/backend/src/modules/conversations/conversations.service.ts new file mode 100644 index 0000000..a0bd188 --- /dev/null +++ b/packages/backend/src/modules/conversations/conversations.service.ts @@ -0,0 +1,202 @@ +import type { + Conversation, + ConversationMember, + ConversationNewEvent, + ConversationMembershipEvent, + ReadReceiptEvent, + TypingEvent, +} from '@altricade/core'; +import { userChannel } from '@altricade/core'; +import { HttpError } from '../../shared/http-error'; +import type { Publisher } from '../../shared/publisher'; +import { toPublicUser } from '../users'; +import type { UsersRepository } from '../users'; +import type { ConversationsRepository, DeliveryInfo } from './conversations.repository'; +import type { ReadStateRepository } from './read-state.repository'; +import type { Deliver } from './delivery'; +import { toConversation, toMember, memberToPublicUser } from './conversations.mapper'; + +export interface ConversationsServiceDeps { + conversations: ConversationsRepository; + users: UsersRepository; + readState: ReadStateRepository; + deliver: Deliver; + publish: Publisher; +} + +export interface ConversationsService { + createDirect(actorId: string, username: string): Promise; + createGroup(actorId: string, title: string, memberUsernames: string[]): Promise; + list(userId: string): Promise; + get(conversationId: string, userId: string): Promise; + listMembers(conversationId: string, userId: string): Promise; + addMember(conversationId: string, actorId: string, username: string): Promise; + removeMember(conversationId: string, actorId: string, targetUserId: string): Promise; + isMember(conversationId: string, userId: string): Promise; + getDeliveryInfo(conversationId: string): Promise; + markRead(conversationId: string, userId: string, seq: number): Promise; + setTyping(conversationId: string, userId: string, state: 'start' | 'stop'): Promise; +} + +export const createConversationsService = ( + deps: ConversationsServiceDeps, +): ConversationsService => { + const { conversations, users, readState, deliver, publish } = deps; + + const assertMember = async (conversationId: string, userId: string): Promise => { + if (!(await conversations.isMember(conversationId, userId))) { + throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation'); + } + }; + + const assertOwner = async (conversationId: string, userId: string): Promise => { + if ((await conversations.getRole(conversationId, userId)) !== 'owner') { + throw new HttpError(403, 'not_owner', 'Only the group owner can do this'); + } + }; + + const conversationNew = (conversation: Conversation): ConversationNewEvent => ({ + type: 'conversation.new', + conversation, + }); + + const membership = ( + action: ConversationMembershipEvent['action'], + conversationId: string, + userId: string, + ): ConversationMembershipEvent => ({ type: 'conversation.membership', action, conversationId, userId }); + + return { + createDirect: async (actorId, username) => { + const target = await users.findByUsername(username); + if (target === undefined) { + throw new HttpError(404, 'user_not_found', 'No such user'); + } + if (target.id === actorId) { + throw new HttpError(400, 'invalid_target', 'Cannot start a direct chat with yourself'); + } + const targetPublic = toPublicUser(target); + const { conversation, created } = await conversations.findOrCreateDirect(actorId, target.id); + + if (created) { + const actorRow = await users.findById(actorId); + const actorPublic = actorRow === undefined ? null : toPublicUser(actorRow); + await publish( + userChannel(target.id), + conversationNew(toConversation(conversation, actorPublic)), + ); + await publish( + userChannel(actorId), + conversationNew(toConversation(conversation, targetPublic)), + ); + } + return toConversation(conversation, targetPublic); + }, + + createGroup: async (actorId, title, memberUsernames) => { + const memberIds: string[] = []; + for (const username of memberUsernames) { + const found = await users.findByUsername(username); + if (found !== undefined && found.id !== actorId) { + memberIds.push(found.id); + } + } + const conversation = toConversation( + await conversations.createGroup(title, actorId, memberIds), + null, + ); + for (const userId of [actorId, ...memberIds]) { + await publish(userChannel(userId), conversationNew(conversation)); + } + return conversation; + }, + + list: async (userId) => { + const rows = await conversations.listForUser(userId); + const directIds = rows.filter((row) => row.type === 'direct').map((row) => row.id); + const peers = await conversations.peersForDirect(userId, directIds); + const unread = await readState.unreadCounts(userId); + return rows.map((row) => { + const count = unread.get(row.id) ?? 0; + if (row.type !== 'direct') { + return toConversation(row, null, count); + } + const peer = peers.get(row.id); + return toConversation(row, peer === undefined ? null : memberToPublicUser(peer), count); + }); + }, + + get: async (conversationId, userId) => { + await assertMember(conversationId, userId); + const conversation = await conversations.findById(conversationId); + if (conversation === undefined) { + throw new HttpError(404, 'not_found', 'Conversation not found'); + } + const count = await readState.unreadFor(conversationId, userId); + if (conversation.type !== 'direct') { + return toConversation(conversation, null, count); + } + const peer = await conversations.getPeer(conversationId, userId); + return toConversation( + conversation, + peer === undefined ? null : memberToPublicUser(peer), + count, + ); + }, + + listMembers: async (conversationId, userId) => { + await assertMember(conversationId, userId); + return (await conversations.listMembers(conversationId)).map(toMember); + }, + + addMember: async (conversationId, actorId, username) => { + await assertOwner(conversationId, actorId); + const target = await users.findByUsername(username); + if (target === undefined) { + throw new HttpError(404, 'user_not_found', 'No such user'); + } + await conversations.addMember(conversationId, target.id, 'member'); + const conversation = await conversations.findById(conversationId); + if (conversation !== undefined) { + await publish(userChannel(target.id), conversationNew(toConversation(conversation, null))); + } + await publish(userChannel(target.id), membership('added', conversationId, target.id)); + return (await conversations.listMembers(conversationId)).map(toMember); + }, + + removeMember: async (conversationId, actorId, targetUserId) => { + if (actorId !== targetUserId) { + await assertOwner(conversationId, actorId); + } + await conversations.removeMember(conversationId, targetUserId); + await publish(userChannel(targetUserId), membership('removed', conversationId, targetUserId)); + }, + + isMember: (conversationId, userId) => conversations.isMember(conversationId, userId), + + getDeliveryInfo: (conversationId) => conversations.getDeliveryInfo(conversationId), + + markRead: async (conversationId, userId, seq) => { + await assertMember(conversationId, userId); + await readState.setRead(conversationId, userId, seq); + const event: ReadReceiptEvent = { type: 'read.receipt', conversationId, userId, seq }; + await deliver(conversationId, event); + }, + + setTyping: async (conversationId, userId, state) => { + await assertMember(conversationId, userId); + const event: TypingEvent = { + type: state === 'start' ? 'typing.start' : 'typing.stop', + conversationId, + userId, + }; + await deliver(conversationId, event); + }, + }; +}; + +declare module 'fastify' { + interface FastifyInstance { + conversationsService: ConversationsService; + } +} diff --git a/packages/backend/src/modules/conversations/delivery.ts b/packages/backend/src/modules/conversations/delivery.ts new file mode 100644 index 0000000..f213ce4 --- /dev/null +++ b/packages/backend/src/modules/conversations/delivery.ts @@ -0,0 +1,27 @@ +import { conversationChannel, userChannel } from '@altricade/core'; +import type { Publisher } from '../../shared/publisher'; +import type { ConversationsRepository } from './conversations.repository'; + +export type Deliver = (conversationId: string, event: unknown) => Promise; + +// Route an event to a conversation's audience: group → the conv channel; +// direct → both participants' personal channels. Shared by messages, reactions, +// read receipts, and typing so delivery lives in exactly one place. +export const createDeliver = ( + conversations: ConversationsRepository, + publish: Publisher, +): Deliver => { + return async (conversationId, event) => { + const info = await conversations.getDeliveryInfo(conversationId); + if (info === undefined) { + return; + } + if (info.type === 'group') { + await publish(conversationChannel(conversationId), event); + return; + } + for (const memberId of info.memberIds) { + await publish(userChannel(memberId), event); + } + }; +}; diff --git a/packages/backend/src/modules/conversations/index.ts b/packages/backend/src/modules/conversations/index.ts new file mode 100644 index 0000000..0113479 --- /dev/null +++ b/packages/backend/src/modules/conversations/index.ts @@ -0,0 +1,15 @@ +export { createConversationsRepository } from './conversations.repository'; +export type { + ConversationsRepository, + ConversationRow, + MemberWithUser, + DeliveryInfo, +} from './conversations.repository'; +export { createConversationsService } from './conversations.service'; +export type { ConversationsService, ConversationsServiceDeps } from './conversations.service'; +export { toConversation, toMember, memberToPublicUser } from './conversations.mapper'; +export { createDeliver } from './delivery'; +export type { Deliver } from './delivery'; +export { createReadStateRepository } from './read-state.repository'; +export type { ReadStateRepository } from './read-state.repository'; +export { conversationsRoutes } from './conversations.routes'; diff --git a/packages/backend/src/modules/conversations/read-state.repository.ts b/packages/backend/src/modules/conversations/read-state.repository.ts new file mode 100644 index 0000000..318000a --- /dev/null +++ b/packages/backend/src/modules/conversations/read-state.repository.ts @@ -0,0 +1,65 @@ +import { sql } from 'kysely'; +import type { Kysely } from 'kysely'; +import type { Database } from '../../db/schema'; + +export interface ReadStateRepository { + setRead(conversationId: string, userId: string, seq: number): Promise; + unreadCounts(userId: string): Promise>; + unreadFor(conversationId: string, userId: string): Promise; +} + +export const createReadStateRepository = (db: Kysely): ReadStateRepository => ({ + setRead: async (conversationId, userId, seq) => { + await db + .insertInto('read_state') + .values({ + conversation_id: conversationId, + user_id: userId, + last_read_seq: seq, + updated_at: new Date(), + }) + .onConflict((oc) => + oc.columns(['conversation_id', 'user_id']).doUpdateSet({ + last_read_seq: sql`greatest(read_state.last_read_seq, excluded.last_read_seq)`, + updated_at: new Date(), + }), + ) + .execute(); + }, + + unreadCounts: async (userId) => { + const result = await sql<{ conversation_id: string; unread: number }>` + SELECT cm.conversation_id AS conversation_id, + count(m.id)::int AS unread + FROM conversation_members cm + LEFT JOIN read_state rs + ON rs.conversation_id = cm.conversation_id AND rs.user_id = cm.user_id + LEFT JOIN messages m + ON m.conversation_id = cm.conversation_id + AND m.seq > COALESCE(rs.last_read_seq, 0) + AND m.sender_id <> cm.user_id + AND m.deleted_at IS NULL + WHERE cm.user_id = ${userId} + GROUP BY cm.conversation_id + `.execute(db); + const map = new Map(); + for (const row of result.rows) { + map.set(row.conversation_id, row.unread); + } + return map; + }, + + unreadFor: async (conversationId, userId) => { + const result = await sql<{ unread: number }>` + SELECT count(m.id)::int AS unread + FROM messages m + LEFT JOIN read_state rs + ON rs.conversation_id = m.conversation_id AND rs.user_id = ${userId} + WHERE m.conversation_id = ${conversationId} + AND m.seq > COALESCE(rs.last_read_seq, 0) + AND m.sender_id <> ${userId} + AND m.deleted_at IS NULL + `.execute(db); + return result.rows[0]?.unread ?? 0; + }, +}); diff --git a/packages/backend/src/modules/messages/messages.mapper.ts b/packages/backend/src/modules/messages/messages.mapper.ts index 34b9919..e57473a 100644 --- a/packages/backend/src/modules/messages/messages.mapper.ts +++ b/packages/backend/src/modules/messages/messages.mapper.ts @@ -1,9 +1,12 @@ -import type { Message } from '@altricade/core'; +import type { Message, ReactionSummary } from '@altricade/core'; import type { MessageWithSenderRow } from './messages.repository'; -export const toMessage = (row: MessageWithSenderRow): Message => ({ +export const toMessage = ( + row: MessageWithSenderRow, + reactions: ReactionSummary[] = [], +): Message => ({ id: row.id, - roomId: row.room_id, + conversationId: row.conversation_id, senderId: row.sender_id, sender: { id: row.sender_id, @@ -17,4 +20,7 @@ export const toMessage = (row: MessageWithSenderRow): Message => ({ clientMsgId: row.client_msg_id, seq: row.seq, createdAt: row.created_at.toISOString(), + editedAt: row.edited_at === null ? null : row.edited_at.toISOString(), + deletedAt: row.deleted_at === null ? null : row.deleted_at.toISOString(), + reactions, }); diff --git a/packages/backend/src/modules/messages/messages.repository.ts b/packages/backend/src/modules/messages/messages.repository.ts index b04470b..fe66981 100644 --- a/packages/backend/src/modules/messages/messages.repository.ts +++ b/packages/backend/src/modules/messages/messages.repository.ts @@ -1,23 +1,27 @@ +import { sql } from 'kysely'; import type { Kysely } from 'kysely'; +import type { ReactionSummary } from '@altricade/core'; import type { Database } from '../../db/schema'; export interface MessageWithSenderRow { id: string; seq: number; - room_id: string; + conversation_id: string; sender_id: string; client_msg_id: string; content: string; content_type: string; encryption: string | null; created_at: Date; + edited_at: Date | null; + deleted_at: Date | null; sender_username: string; sender_display_name: string; sender_avatar_ref: string | null; } export interface NewMessage { - roomId: string; + conversationId: string; senderId: string; clientMsgId: string; content: string; @@ -28,16 +32,26 @@ export interface NewMessage { export interface MessagesRepository { insert(input: NewMessage): Promise<{ id: string } | undefined>; findIdByDedupe( - roomId: string, + conversationId: string, senderId: string, clientMsgId: string, ): Promise; getWithSenderById(id: string): Promise; listHistory( - roomId: string, + conversationId: string, beforeSeq: number | null, limit: number, ): Promise; + getConversationId(messageId: string): Promise; + editContent( + messageId: string, + senderId: string, + content: string, + ): Promise<{ id: string } | undefined>; + softDelete(messageId: string, senderId: string): Promise<{ id: string } | undefined>; + addReaction(messageId: string, userId: string, emoji: string): Promise; + removeReaction(messageId: string, userId: string, emoji: string): Promise; + reactionsFor(messageIds: string[], userId: string): Promise>; } export const createMessagesRepository = (db: Kysely): MessagesRepository => { @@ -48,13 +62,15 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito .select([ 'messages.id as id', 'messages.seq as seq', - 'messages.room_id as room_id', + 'messages.conversation_id as conversation_id', 'messages.sender_id as sender_id', 'messages.client_msg_id as client_msg_id', 'messages.content as content', 'messages.content_type as content_type', 'messages.encryption as encryption', 'messages.created_at as created_at', + 'messages.edited_at as edited_at', + 'messages.deleted_at as deleted_at', 'users.username as sender_username', 'users.display_name as sender_display_name', 'users.avatar_ref as sender_avatar_ref', @@ -65,22 +81,24 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito db .insertInto('messages') .values({ - room_id: input.roomId, + conversation_id: input.conversationId, sender_id: input.senderId, client_msg_id: input.clientMsgId, content: input.content, content_type: input.contentType, encryption: input.encryption, }) - .onConflict((oc) => oc.columns(['room_id', 'sender_id', 'client_msg_id']).doNothing()) + .onConflict((oc) => + oc.columns(['conversation_id', 'sender_id', 'client_msg_id']).doNothing(), + ) .returning('id') .executeTakeFirst(), - findIdByDedupe: async (roomId, senderId, clientMsgId) => { + findIdByDedupe: async (conversationId, senderId, clientMsgId) => { const row = await db .selectFrom('messages') .select('id') - .where('room_id', '=', roomId) + .where('conversation_id', '=', conversationId) .where('sender_id', '=', senderId) .where('client_msg_id', '=', clientMsgId) .executeTakeFirst(); @@ -89,12 +107,82 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(), - listHistory: (roomId, beforeSeq, limit) => { - let query = withSender().where('messages.room_id', '=', roomId); + listHistory: (conversationId, beforeSeq, limit) => { + let query = withSender().where('messages.conversation_id', '=', conversationId); if (beforeSeq !== null) { query = query.where('messages.seq', '<', beforeSeq); } return query.orderBy('messages.seq', 'desc').limit(limit).execute(); }, + + getConversationId: async (messageId) => { + const row = await db + .selectFrom('messages') + .select('conversation_id') + .where('id', '=', messageId) + .executeTakeFirst(); + return row?.conversation_id; + }, + + editContent: (messageId, senderId, content) => + db + .updateTable('messages') + .set({ content, edited_at: new Date() }) + .where('id', '=', messageId) + .where('sender_id', '=', senderId) + .where('deleted_at', 'is', null) + .returning('id') + .executeTakeFirst(), + + softDelete: (messageId, senderId) => + db + .updateTable('messages') + .set({ deleted_at: new Date(), content: '' }) + .where('id', '=', messageId) + .where('sender_id', '=', senderId) + .where('deleted_at', 'is', null) + .returning('id') + .executeTakeFirst(), + + addReaction: async (messageId, userId, emoji) => { + await db + .insertInto('reactions') + .values({ message_id: messageId, user_id: userId, emoji }) + .onConflict((oc) => oc.columns(['message_id', 'user_id', 'emoji']).doNothing()) + .execute(); + }, + + removeReaction: async (messageId, userId, emoji) => { + await db + .deleteFrom('reactions') + .where('message_id', '=', messageId) + .where('user_id', '=', userId) + .where('emoji', '=', emoji) + .execute(); + }, + + reactionsFor: async (messageIds, userId) => { + const map = new Map(); + if (messageIds.length === 0) { + return map; + } + const rows = await db + .selectFrom('reactions') + .where('message_id', 'in', messageIds) + .groupBy(['message_id', 'emoji']) + .select((eb) => [ + 'message_id', + 'emoji', + eb.fn.countAll().as('count'), + sql`bool_or(user_id = ${userId})`.as('mine'), + ]) + .execute(); + for (const row of rows) { + const list = map.get(row.message_id) ?? []; + list.push({ emoji: row.emoji, count: row.count, mine: row.mine }); + map.set(row.message_id, list); + } + return map; + }, }; }; diff --git a/packages/backend/src/modules/messages/messages.routes.ts b/packages/backend/src/modules/messages/messages.routes.ts index 1002fd7..fa4d33d 100644 --- a/packages/backend/src/modules/messages/messages.routes.ts +++ b/packages/backend/src/modules/messages/messages.routes.ts @@ -1,13 +1,37 @@ import type { FastifyInstance } from 'fastify'; -import { sendMessageBodySchema, messageSchema, messageListSchema, errorSchema } from '@altricade/core'; -import type { SendMessageBody } from '@altricade/core'; +import { + sendMessageBodySchema, + editMessageBodySchema, + reactionBodySchema, + messageSchema, + messageListSchema, + errorSchema, +} from '@altricade/core'; +import type { SendMessageBody, EditMessageBody, ReactionBody } from '@altricade/core'; const bearerAuth = [{ bearerAuth: [] }]; -const roomParamsSchema = { +const idParamsSchema = { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, } as const; +const messageParamsSchema = { + type: 'object', + required: ['id', 'messageId'], + properties: { + id: { type: 'string', format: 'uuid' }, + messageId: { type: 'string', format: 'uuid' }, + }, +} as const; +const reactionParamsSchema = { + type: 'object', + required: ['id', 'messageId', 'emoji'], + properties: { + id: { type: 'string', format: 'uuid' }, + messageId: { type: 'string', format: 'uuid' }, + emoji: { type: 'string', minLength: 1, maxLength: 32 }, + }, +} as const; const historyQuerySchema = { type: 'object', properties: { @@ -18,13 +42,13 @@ const historyQuerySchema = { export const messagesRoutes = (app: FastifyInstance): Promise => { app.get<{ Params: { id: string }; Querystring: { before?: number; limit: number } }>( - '/rooms/:id/messages', + '/conversations/:id/messages', { schema: { tags: ['messages'], - summary: 'Load room message history (newest first)', + summary: 'Load conversation message history (newest first)', security: bearerAuth, - params: roomParamsSchema, + params: idParamsSchema, querystring: historyQuerySchema, response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema }, }, @@ -45,13 +69,13 @@ export const messagesRoutes = (app: FastifyInstance): Promise => { ); app.post<{ Params: { id: string }; Body: SendMessageBody }>( - '/rooms/:id/messages', + '/conversations/:id/messages', { schema: { tags: ['messages'], - summary: 'Send a message to a room', + summary: 'Send a message to a conversation', security: bearerAuth, - params: roomParamsSchema, + params: idParamsSchema, body: sendMessageBodySchema, response: { 200: messageSchema, 201: messageSchema, 401: errorSchema, 403: errorSchema }, }, @@ -65,5 +89,102 @@ export const messagesRoutes = (app: FastifyInstance): Promise => { }, ); + app.patch<{ Params: { id: string; messageId: string }; Body: EditMessageBody }>( + '/conversations/:id/messages/:messageId', + { + schema: { + tags: ['messages'], + summary: 'Edit a message (sender only)', + security: bearerAuth, + params: messageParamsSchema, + body: editMessageBodySchema, + response: { 200: messageSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const message = await app.messagesService.edit( + request.params.id, + request.params.messageId, + user.id, + request.body.content, + ); + return reply.send(message); + }, + ); + + app.delete<{ Params: { id: string; messageId: string } }>( + '/conversations/:id/messages/:messageId', + { + schema: { + tags: ['messages'], + summary: 'Delete a message (sender only, soft delete)', + security: bearerAuth, + params: messageParamsSchema, + response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.messagesService.remove(request.params.id, request.params.messageId, user.id); + return reply.code(204).send(); + }, + ); + + app.post<{ Params: { id: string; messageId: string }; Body: ReactionBody }>( + '/conversations/:id/messages/:messageId/reactions', + { + schema: { + tags: ['messages'], + summary: 'Add a reaction', + security: bearerAuth, + params: messageParamsSchema, + body: reactionBodySchema, + response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.messagesService.addReaction( + request.params.id, + request.params.messageId, + user.id, + request.body.emoji, + ); + return reply.code(204).send(); + }, + ); + + app.delete<{ Params: { id: string; messageId: string; emoji: string } }>( + '/conversations/:id/messages/:messageId/reactions/:emoji', + { + schema: { + tags: ['messages'], + summary: 'Remove a reaction', + security: bearerAuth, + params: reactionParamsSchema, + response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.messagesService.removeReaction( + request.params.id, + request.params.messageId, + user.id, + decodeURIComponent(request.params.emoji), + ); + return reply.code(204).send(); + }, + ); + return Promise.resolve(); }; diff --git a/packages/backend/src/modules/messages/messages.service.ts b/packages/backend/src/modules/messages/messages.service.ts index 46c4d7d..86f2d54 100644 --- a/packages/backend/src/modules/messages/messages.service.ts +++ b/packages/backend/src/modules/messages/messages.service.ts @@ -1,15 +1,21 @@ -import type { Message, MessageNewEvent, SendMessageBody } from '@altricade/core'; -import { roomChannel } from '@altricade/core'; +import type { + Message, + MessageNewEvent, + MessageEditEvent, + MessageDeleteEvent, + ReactionEvent, + SendMessageBody, +} from '@altricade/core'; import { HttpError } from '../../shared/http-error'; -import type { Publisher } from '../../shared/publisher'; -import type { RoomsRepository } from '../rooms'; +import type { ConversationsRepository } from '../conversations'; +import type { Deliver } from '../conversations'; import type { MessagesRepository } from './messages.repository'; import { toMessage } from './messages.mapper'; export interface MessagesServiceDeps { messages: MessagesRepository; - rooms: RoomsRepository; - publish: Publisher; + conversations: ConversationsRepository; + deliver: Deliver; } export interface SentMessage { @@ -18,30 +24,64 @@ export interface SentMessage { } export interface MessagesService { - send(roomId: string, senderId: string, input: SendMessageBody): Promise; + send(conversationId: string, senderId: string, input: SendMessageBody): Promise; history( - roomId: string, + conversationId: string, userId: string, beforeSeq: number | null, limit: number, ): Promise; + edit( + conversationId: string, + messageId: string, + userId: string, + content: string, + ): Promise; + remove(conversationId: string, messageId: string, userId: string): Promise; + addReaction( + conversationId: string, + messageId: string, + userId: string, + emoji: string, + ): Promise; + removeReaction( + conversationId: string, + messageId: string, + userId: string, + emoji: string, + ): Promise; } export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => { - const { messages, rooms, publish } = deps; + const { messages, conversations, deliver } = deps; - const assertMember = async (roomId: string, userId: string): Promise => { - if (!(await rooms.isMember(roomId, userId))) { - throw new HttpError(403, 'not_a_member', 'You are not a member of this room'); + const assertMember = async (conversationId: string, userId: string): Promise => { + if (!(await conversations.isMember(conversationId, userId))) { + throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation'); } }; + const assertMessageIn = async (conversationId: string, messageId: string): Promise => { + if ((await messages.getConversationId(messageId)) !== conversationId) { + throw new HttpError(404, 'not_found', 'Message not found'); + } + }; + + const loadMessage = async (messageId: string, userId: string): Promise => { + const row = await messages.getWithSenderById(messageId); + if (row === undefined) { + throw new HttpError(404, 'not_found', 'Message not found'); + } + const reactions = await messages.reactionsFor([messageId], userId); + return toMessage(row, reactions.get(messageId) ?? []); + }; + return { - send: async (roomId, senderId, input) => { - await assertMember(roomId, senderId); + send: async (conversationId, senderId, input) => { + await assertMember(conversationId, senderId); const inserted = await messages.insert({ - roomId, + conversationId, senderId, clientMsgId: input.clientMsgId, content: input.content, @@ -49,7 +89,9 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic encryption: input.encryption ?? null, }); - const id = inserted?.id ?? (await messages.findIdByDedupe(roomId, senderId, input.clientMsgId)); + const id = + inserted?.id ?? + (await messages.findIdByDedupe(conversationId, senderId, input.clientMsgId)); if (id === undefined) { throw new HttpError(500, 'internal_error', 'Message could not be persisted'); } @@ -57,19 +99,76 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic if (row === undefined) { throw new HttpError(500, 'internal_error', 'Message not found after insert'); } - const message = toMessage(row); + const message = toMessage(row, []); - // Publish only for a genuinely new message (not an idempotent replay). if (inserted !== undefined) { + await conversations.touchLastMessage(conversationId); const event: MessageNewEvent = { type: 'message.new', message }; - await publish(roomChannel(roomId), event); + await deliver(conversationId, event); } return { message, created: inserted !== undefined }; }, - history: async (roomId, userId, beforeSeq, limit) => { - await assertMember(roomId, userId); - return (await messages.listHistory(roomId, beforeSeq, limit)).map(toMessage); + history: async (conversationId, userId, beforeSeq, limit) => { + await assertMember(conversationId, userId); + const rows = await messages.listHistory(conversationId, beforeSeq, limit); + const reactions = await messages.reactionsFor( + rows.map((row) => row.id), + userId, + ); + return rows.map((row) => toMessage(row, reactions.get(row.id) ?? [])); + }, + + edit: async (conversationId, messageId, userId, content) => { + await assertMember(conversationId, userId); + await assertMessageIn(conversationId, messageId); + const edited = await messages.editContent(messageId, userId, content); + if (edited === undefined) { + throw new HttpError(403, 'not_editable', 'You can only edit your own messages'); + } + const message = await loadMessage(messageId, userId); + const event: MessageEditEvent = { type: 'message.edit', message }; + await deliver(conversationId, event); + return message; + }, + + remove: async (conversationId, messageId, userId) => { + await assertMember(conversationId, userId); + await assertMessageIn(conversationId, messageId); + const deleted = await messages.softDelete(messageId, userId); + if (deleted === undefined) { + throw new HttpError(403, 'not_deletable', 'You can only delete your own messages'); + } + const event: MessageDeleteEvent = { type: 'message.delete', conversationId, messageId }; + await deliver(conversationId, event); + }, + + addReaction: async (conversationId, messageId, userId, emoji) => { + await assertMember(conversationId, userId); + await assertMessageIn(conversationId, messageId); + await messages.addReaction(messageId, userId, emoji); + const event: ReactionEvent = { + type: 'reaction.add', + conversationId, + messageId, + emoji, + userId, + }; + await deliver(conversationId, event); + }, + + removeReaction: async (conversationId, messageId, userId, emoji) => { + await assertMember(conversationId, userId); + await assertMessageIn(conversationId, messageId); + await messages.removeReaction(messageId, userId, emoji); + const event: ReactionEvent = { + type: 'reaction.remove', + conversationId, + messageId, + emoji, + userId, + }; + await deliver(conversationId, event); }, }; }; diff --git a/packages/backend/src/modules/presence/index.ts b/packages/backend/src/modules/presence/index.ts new file mode 100644 index 0000000..8f8ebb4 --- /dev/null +++ b/packages/backend/src/modules/presence/index.ts @@ -0,0 +1,3 @@ +export { createPresenceService } from './presence.service'; +export type { PresenceService, PresenceServiceDeps } from './presence.service'; +export { presenceRoutes } from './presence.routes'; diff --git a/packages/backend/src/modules/presence/presence.routes.ts b/packages/backend/src/modules/presence/presence.routes.ts new file mode 100644 index 0000000..66c1b33 --- /dev/null +++ b/packages/backend/src/modules/presence/presence.routes.ts @@ -0,0 +1,37 @@ +import type { FastifyInstance } from 'fastify'; +import { presenceListSchema, errorSchema } from '@altricade/core'; + +const querySchema = { + type: 'object', + required: ['userIds'], + properties: { userIds: { type: 'string', minLength: 1 } }, +} as const; + +export const presenceRoutes = (app: FastifyInstance): Promise => { + app.get<{ Querystring: { userIds: string } }>( + '/presence', + { + schema: { + tags: ['live'], + summary: 'Online status + last-seen for a set of users (comma-separated ids)', + security: [{ bearerAuth: [] }], + querystring: querySchema, + response: { 200: presenceListSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) { + return reply.code(401).send({ error: 'unauthorized' }); + } + const ids = request.query.userIds + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0); + return reply.send(await app.presenceService.getPresence(ids)); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/presence/presence.service.ts b/packages/backend/src/modules/presence/presence.service.ts new file mode 100644 index 0000000..0fd80b3 --- /dev/null +++ b/packages/backend/src/modules/presence/presence.service.ts @@ -0,0 +1,38 @@ +import type { Presence } from '@altricade/core'; +import type { UsersRepository, UserRow } from '../users'; + +export interface PresenceServiceDeps { + users: UsersRepository; + isOnline: (userId: string) => Promise; +} + +export interface PresenceService { + getPresence(userIds: string[]): Promise; +} + +export const createPresenceService = (deps: PresenceServiceDeps): PresenceService => ({ + getPresence: async (userIds) => { + const rows = await deps.users.findManyByIds(userIds); + const byId = new Map(rows.map((row) => [row.id, row])); + const result: Presence[] = []; + for (const id of userIds) { + const row = byId.get(id); + if (row === undefined) { + continue; + } + const online = await deps.isOnline(id); + result.push({ + userId: id, + online, + lastSeenAt: row.last_seen_at === null ? null : row.last_seen_at.toISOString(), + }); + } + return result; + }, +}); + +declare module 'fastify' { + interface FastifyInstance { + presenceService: PresenceService; + } +} diff --git a/packages/backend/src/modules/realtime/realtime.routes.ts b/packages/backend/src/modules/realtime/realtime.routes.ts index 15d7ca0..b9d6810 100644 --- a/packages/backend/src/modules/realtime/realtime.routes.ts +++ b/packages/backend/src/modules/realtime/realtime.routes.ts @@ -30,7 +30,10 @@ export const realtimeRoutes = (app: FastifyInstance): Promise => { async (request, reply) => { const { user, channel } = request.body; const decision = await authorizeSubscription( - { isRoomMember: (roomId, userId) => app.roomsService.isMember(roomId, userId) }, + { + isConversationMember: (conversationId, userId) => + app.conversationsService.isMember(conversationId, userId), + }, user ?? '', channel, ); diff --git a/packages/backend/src/modules/realtime/realtime.service.ts b/packages/backend/src/modules/realtime/realtime.service.ts index a2011d9..f605f18 100644 --- a/packages/backend/src/modules/realtime/realtime.service.ts +++ b/packages/backend/src/modules/realtime/realtime.service.ts @@ -1,11 +1,11 @@ -import { isUserChannel, roomChannelId, userChannel } from '@altricade/core'; +import { isUserChannel, conversationChannelId, userChannel } from '@altricade/core'; export type SubscribeDecision = | { allowed: true } | { allowed: false; errorCode: number; errorMessage: string }; export interface SubscribeDeps { - isRoomMember: (roomId: string, userId: string) => Promise; + isConversationMember: (conversationId: string, userId: string) => Promise; } const deny = (errorMessage: string): SubscribeDecision => ({ @@ -16,7 +16,7 @@ const deny = (errorMessage: string): SubscribeDecision => ({ // Subscription authorization for the Centrifugo subscribe-proxy. // user: → only that user's own personal channel. -// room: → only current members (live DB check → kicked users lose access). +// conv: → only current members (live DB check → kicked users lose access). export const authorizeSubscription = async ( deps: SubscribeDeps, userId: string, @@ -26,12 +26,14 @@ export const authorizeSubscription = async ( return channel === userChannel(userId) ? { allowed: true } : deny('permission denied'); } - const roomId = roomChannelId(channel); - if (roomId !== null) { + const conversationId = conversationChannelId(channel); + if (conversationId !== null) { if (userId === '') { return deny('permission denied'); } - return (await deps.isRoomMember(roomId, userId)) ? { allowed: true } : deny('not a member'); + return (await deps.isConversationMember(conversationId, userId)) + ? { allowed: true } + : deny('not a member'); } return deny('unknown channel'); diff --git a/packages/backend/src/modules/rooms/index.ts b/packages/backend/src/modules/rooms/index.ts deleted file mode 100644 index c640071..0000000 --- a/packages/backend/src/modules/rooms/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { createRoomsRepository } from './rooms.repository'; -export type { RoomsRepository, RoomRow, RoomMemberWithUser } from './rooms.repository'; -export { createRoomsService } from './rooms.service'; -export type { RoomsService, RoomsServiceDeps } from './rooms.service'; -export { toRoom, toRoomMember } from './rooms.mapper'; -export { roomsRoutes } from './rooms.routes'; diff --git a/packages/backend/src/modules/rooms/rooms.mapper.ts b/packages/backend/src/modules/rooms/rooms.mapper.ts deleted file mode 100644 index 15d095d..0000000 --- a/packages/backend/src/modules/rooms/rooms.mapper.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Room, RoomMember } from '@altricade/core'; -import type { RoomRow, RoomMemberWithUser } from './rooms.repository'; - -export const toRoom = (row: RoomRow): Room => ({ - id: row.id, - name: row.name, - createdBy: row.created_by, - createdAt: row.created_at.toISOString(), -}); - -export const toRoomMember = (row: RoomMemberWithUser): RoomMember => ({ - userId: row.user_id, - role: row.role, - joinedAt: row.joined_at.toISOString(), - user: { - id: row.user_id, - username: row.username, - displayName: row.display_name, - avatarUrl: row.avatar_ref, - }, -}); diff --git a/packages/backend/src/modules/rooms/rooms.repository.ts b/packages/backend/src/modules/rooms/rooms.repository.ts deleted file mode 100644 index 5582760..0000000 --- a/packages/backend/src/modules/rooms/rooms.repository.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { Kysely, Selectable } from 'kysely'; -import type { Database, RoomsTable } from '../../db/schema'; - -export type RoomRow = Selectable; - -export interface RoomMemberWithUser { - user_id: string; - role: string; - joined_at: Date; - username: string; - display_name: string; - avatar_ref: string | null; -} - -export interface RoomsRepository { - createWithOwner(name: string, ownerId: string): Promise; - findById(id: string): Promise; - listForUser(userId: string): Promise; - isMember(roomId: string, userId: string): Promise; - getRole(roomId: string, userId: string): Promise; - addMember(roomId: string, userId: string, role: string): Promise; - removeMember(roomId: string, userId: string): Promise; - listMembers(roomId: string): Promise; -} - -export const createRoomsRepository = (db: Kysely): RoomsRepository => ({ - createWithOwner: (name, ownerId) => - db.transaction().execute(async (trx) => { - const room = await trx - .insertInto('rooms') - .values({ name, created_by: ownerId }) - .returningAll() - .executeTakeFirstOrThrow(); - await trx - .insertInto('room_members') - .values({ room_id: room.id, user_id: ownerId, role: 'owner' }) - .execute(); - return room; - }), - - findById: (id) => db.selectFrom('rooms').selectAll().where('id', '=', id).executeTakeFirst(), - - listForUser: (userId) => - db - .selectFrom('rooms') - .innerJoin('room_members', 'room_members.room_id', 'rooms.id') - .where('room_members.user_id', '=', userId) - .selectAll('rooms') - .orderBy('rooms.created_at', 'desc') - .execute(), - - isMember: async (roomId, userId) => { - const row = await db - .selectFrom('room_members') - .select('user_id') - .where('room_id', '=', roomId) - .where('user_id', '=', userId) - .executeTakeFirst(); - return row !== undefined; - }, - - getRole: async (roomId, userId) => { - const row = await db - .selectFrom('room_members') - .select('role') - .where('room_id', '=', roomId) - .where('user_id', '=', userId) - .executeTakeFirst(); - return row?.role; - }, - - addMember: async (roomId, userId, role) => { - await db - .insertInto('room_members') - .values({ room_id: roomId, user_id: userId, role }) - .onConflict((oc) => oc.columns(['room_id', 'user_id']).doNothing()) - .execute(); - }, - - removeMember: async (roomId, userId) => { - await db - .deleteFrom('room_members') - .where('room_id', '=', roomId) - .where('user_id', '=', userId) - .execute(); - }, - - listMembers: (roomId) => - db - .selectFrom('room_members') - .innerJoin('users', 'users.id', 'room_members.user_id') - .where('room_members.room_id', '=', roomId) - .select([ - 'room_members.user_id as user_id', - 'room_members.role as role', - 'room_members.joined_at as joined_at', - 'users.username as username', - 'users.display_name as display_name', - 'users.avatar_ref as avatar_ref', - ]) - .orderBy('room_members.joined_at', 'asc') - .execute(), -}); diff --git a/packages/backend/src/modules/rooms/rooms.routes.ts b/packages/backend/src/modules/rooms/rooms.routes.ts deleted file mode 100644 index 58823a5..0000000 --- a/packages/backend/src/modules/rooms/rooms.routes.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { - createRoomBodySchema, - addMemberBodySchema, - roomSchema, - roomListSchema, - roomMemberSchema, - roomMemberListSchema, - errorSchema, -} from '@altricade/core'; -import type { CreateRoomBody, AddMemberBody } from '@altricade/core'; - -const bearerAuth = [{ bearerAuth: [] }]; -const roomParamsSchema = { - type: 'object', - required: ['id'], - properties: { id: { type: 'string', format: 'uuid' } }, -} as const; -const memberParamsSchema = { - type: 'object', - required: ['id', 'userId'], - properties: { id: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' } }, -} as const; - -export const roomsRoutes = (app: FastifyInstance): Promise => { - app.post<{ Body: CreateRoomBody }>( - '/rooms', - { - schema: { - tags: ['rooms'], - summary: 'Create a room', - security: bearerAuth, - body: createRoomBodySchema, - response: { 201: roomSchema, 401: errorSchema }, - }, - preHandler: app.authenticate, - }, - async (request, reply) => { - const user = request.authUser; - if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); - const room = await app.roomsService.create(request.body.name, user.id); - return reply.code(201).send(room); - }, - ); - - app.get( - '/rooms', - { - schema: { - tags: ['rooms'], - summary: 'List rooms I belong to', - security: bearerAuth, - response: { 200: roomListSchema, 401: errorSchema }, - }, - preHandler: app.authenticate, - }, - async (request, reply) => { - const user = request.authUser; - if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); - return reply.send(await app.roomsService.listMine(user.id)); - }, - ); - - app.get<{ Params: { id: string } }>( - '/rooms/:id', - { - schema: { - tags: ['rooms'], - summary: 'Get a room (member only)', - security: bearerAuth, - params: roomParamsSchema, - response: { 200: roomSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema }, - }, - preHandler: app.authenticate, - }, - async (request, reply) => { - const user = request.authUser; - if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); - return reply.send(await app.roomsService.get(request.params.id, user.id)); - }, - ); - - app.get<{ Params: { id: string } }>( - '/rooms/:id/members', - { - schema: { - tags: ['rooms'], - summary: 'List room members (member only)', - security: bearerAuth, - params: roomParamsSchema, - response: { 200: roomMemberListSchema, 401: errorSchema, 403: errorSchema }, - }, - preHandler: app.authenticate, - }, - async (request, reply) => { - const user = request.authUser; - if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); - return reply.send(await app.roomsService.listMembers(request.params.id, user.id)); - }, - ); - - app.post<{ Params: { id: string }; Body: AddMemberBody }>( - '/rooms/:id/members', - { - schema: { - tags: ['rooms'], - summary: 'Add a member by username (owner only)', - security: bearerAuth, - params: roomParamsSchema, - body: addMemberBodySchema, - response: { 201: roomMemberSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema }, - }, - preHandler: app.authenticate, - }, - async (request, reply) => { - const user = request.authUser; - if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); - const member = await app.roomsService.addMember(request.params.id, user.id, request.body.username); - return reply.code(201).send(member); - }, - ); - - app.delete<{ Params: { id: string; userId: string } }>( - '/rooms/:id/members/:userId', - { - schema: { - tags: ['rooms'], - summary: 'Remove a member (owner) or leave (self)', - security: bearerAuth, - params: memberParamsSchema, - response: { 401: errorSchema, 403: errorSchema }, - }, - preHandler: app.authenticate, - }, - async (request, reply) => { - const user = request.authUser; - if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); - await app.roomsService.removeMember(request.params.id, user.id, request.params.userId); - return reply.code(204).send(); - }, - ); - - return Promise.resolve(); -}; diff --git a/packages/backend/src/modules/rooms/rooms.service.ts b/packages/backend/src/modules/rooms/rooms.service.ts deleted file mode 100644 index 0349f82..0000000 --- a/packages/backend/src/modules/rooms/rooms.service.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Room, RoomMember, RoomMembershipEvent } from '@altricade/core'; -import { userChannel } from '@altricade/core'; -import { HttpError } from '../../shared/http-error'; -import type { Publisher } from '../../shared/publisher'; -import type { UsersRepository } from '../users'; -import type { RoomsRepository } from './rooms.repository'; -import { toRoom, toRoomMember } from './rooms.mapper'; - -export interface RoomsServiceDeps { - rooms: RoomsRepository; - users: UsersRepository; - publish: Publisher; -} - -export interface RoomsService { - create(name: string, ownerId: string): Promise; - get(roomId: string, userId: string): Promise; - listMine(userId: string): Promise; - listMembers(roomId: string, userId: string): Promise; - addMember(roomId: string, actorId: string, username: string): Promise; - removeMember(roomId: string, actorId: string, targetUserId: string): Promise; - isMember(roomId: string, userId: string): Promise; - assertMember(roomId: string, userId: string): Promise; -} - -export const createRoomsService = (deps: RoomsServiceDeps): RoomsService => { - const { rooms, users, publish } = deps; - - const assertMember = async (roomId: string, userId: string): Promise => { - if (!(await rooms.isMember(roomId, userId))) { - throw new HttpError(403, 'not_a_member', 'You are not a member of this room'); - } - }; - - const assertOwner = async (roomId: string, userId: string): Promise => { - const role = await rooms.getRole(roomId, userId); - if (role !== 'owner') { - throw new HttpError(403, 'not_owner', 'Only the room owner can do this'); - } - }; - - const membershipEvent = ( - action: RoomMembershipEvent['action'], - roomId: string, - userId: string, - ): RoomMembershipEvent => ({ type: 'room.membership', action, roomId, userId }); - - return { - create: async (name, ownerId) => toRoom(await rooms.createWithOwner(name, ownerId)), - - get: async (roomId, userId) => { - await assertMember(roomId, userId); - const room = await rooms.findById(roomId); - if (room === undefined) { - throw new HttpError(404, 'not_found', 'Room not found'); - } - return toRoom(room); - }, - - listMine: async (userId) => (await rooms.listForUser(userId)).map(toRoom), - - listMembers: async (roomId, userId) => { - await assertMember(roomId, userId); - return (await rooms.listMembers(roomId)).map(toRoomMember); - }, - - addMember: async (roomId, actorId, username) => { - await assertOwner(roomId, actorId); - const target = await users.findByUsername(username); - if (target === undefined) { - throw new HttpError(404, 'user_not_found', 'No such user'); - } - await rooms.addMember(roomId, target.id, 'member'); - await publish(userChannel(target.id), membershipEvent('added', roomId, target.id)); - const members = await rooms.listMembers(roomId); - const added = members.find((m) => m.user_id === target.id); - if (added === undefined) { - throw new HttpError(500, 'internal_error', 'Member not found after add'); - } - return toRoomMember(added); - }, - - removeMember: async (roomId, actorId, targetUserId) => { - if (actorId !== targetUserId) { - await assertOwner(roomId, actorId); - } - await rooms.removeMember(roomId, targetUserId); - await publish(userChannel(targetUserId), membershipEvent('removed', roomId, targetUserId)); - }, - - isMember: (roomId, userId) => rooms.isMember(roomId, userId), - - assertMember, - }; -}; - -declare module 'fastify' { - interface FastifyInstance { - roomsService: RoomsService; - } -} diff --git a/packages/backend/src/modules/users/users.repository.ts b/packages/backend/src/modules/users/users.repository.ts index 36be400..65cef91 100644 --- a/packages/backend/src/modules/users/users.repository.ts +++ b/packages/backend/src/modules/users/users.repository.ts @@ -21,6 +21,8 @@ export interface UsersRepository { create(input: NewUser): Promise; findByUsername(username: string): Promise; findById(id: string): Promise; + findManyByIds(ids: string[]): Promise; + searchByPrefix(query: string, excludeUserId: string, limit: number): Promise; updateProfile(id: string, patch: ProfilePatch): Promise; touchLastSeen(id: string): Promise; } @@ -44,6 +46,23 @@ export const createUsersRepository = (db: Kysely): UsersRepository => findById: (id) => db.selectFrom('users').selectAll().where('id', '=', id).executeTakeFirst(), + findManyByIds: (ids) => + ids.length === 0 + ? Promise.resolve([]) + : db.selectFrom('users').selectAll().where('id', 'in', ids).execute(), + + searchByPrefix: (query, excludeUserId, limit) => + db + .selectFrom('users') + .selectAll() + .where('id', '!=', excludeUserId) + .where((eb) => + eb.or([eb('username', 'ilike', `${query}%`), eb('display_name', 'ilike', `${query}%`)]), + ) + .orderBy('username', 'asc') + .limit(limit) + .execute(), + updateProfile: (id, patch) => { const values: Updateable = { updated_at: new Date() }; if (patch.displayName !== undefined) { diff --git a/packages/backend/src/modules/users/users.routes.ts b/packages/backend/src/modules/users/users.routes.ts index 488f21c..816793c 100644 --- a/packages/backend/src/modules/users/users.routes.ts +++ b/packages/backend/src/modules/users/users.routes.ts @@ -1,7 +1,19 @@ import type { FastifyInstance } from 'fastify'; -import { updateMeBodySchema, userSchema, publicUserSchema, errorSchema } from '@altricade/core'; +import { + updateMeBodySchema, + userSchema, + publicUserSchema, + publicUserListSchema, + errorSchema, +} from '@altricade/core'; import type { UpdateMeBody } from '@altricade/core'; +const searchQuerySchema = { + type: 'object', + required: ['q'], + properties: { q: { type: 'string', minLength: 1, maxLength: 64 } }, +} as const; + const usernameParamsSchema = { type: 'object', required: ['username'], @@ -54,6 +66,47 @@ export const usersRoutes = (app: FastifyInstance): Promise => { }, ); + app.get<{ Querystring: { q: string } }>( + '/users/search', + { + schema: { + tags: ['users'], + summary: 'Search users by username or display name', + security: bearerAuth, + querystring: searchQuerySchema, + response: { 200: publicUserListSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) { + return reply.code(401).send({ error: 'unauthorized' }); + } + return reply.send(await app.usersService.search(request.query.q, user.id)); + }, + ); + + app.post( + '/me/heartbeat', + { + schema: { + tags: ['users'], + summary: 'Keep-alive: bump last_seen', + security: bearerAuth, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) { + return reply.code(401).send({ error: 'unauthorized' }); + } + await app.usersService.heartbeat(user.id); + return reply.code(204).send(); + }, + ); + app.get<{ Params: { username: string } }>( '/users/:username', { diff --git a/packages/backend/src/modules/users/users.service.ts b/packages/backend/src/modules/users/users.service.ts index 4815820..c013435 100644 --- a/packages/backend/src/modules/users/users.service.ts +++ b/packages/backend/src/modules/users/users.service.ts @@ -7,8 +7,15 @@ export interface UsersService { getMe(userId: string): Promise; getPublicProfile(username: string): Promise; updateProfile(userId: string, patch: UpdateMeBody): Promise; + search(query: string, excludeUserId: string): Promise; + heartbeat(userId: string): Promise; } +const SEARCH_LIMIT = 20; + +// Escape LIKE wildcards so user input is matched literally. +const escapeLike = (value: string): string => value.replace(/[\\%_]/g, '\\$&'); + export const createUsersService = (users: UsersRepository): UsersService => ({ getMe: async (userId) => { const row = await users.findById(userId); @@ -33,6 +40,17 @@ export const createUsersService = (users: UsersRepository): UsersService => ({ } return toUser(row); }, + + search: async (query, excludeUserId) => { + const trimmed = query.trim(); + if (trimmed.length === 0) { + return []; + } + const rows = await users.searchByPrefix(escapeLike(trimmed), excludeUserId, SEARCH_LIMIT); + return rows.map(toPublicUser); + }, + + heartbeat: (userId) => users.touchLastSeen(userId), }); declare module 'fastify' { diff --git a/packages/backend/src/plugins/centrifugo.ts b/packages/backend/src/plugins/centrifugo.ts index 729d3c3..34feacd 100644 --- a/packages/backend/src/plugins/centrifugo.ts +++ b/packages/backend/src/plugins/centrifugo.ts @@ -3,8 +3,22 @@ import fp from 'fastify-plugin'; export interface CentrifugoClient { /** Publish an event payload into a channel via Centrifugo's server HTTP API. */ publish(channel: string, data: unknown): Promise; + /** Number of clients currently present in a channel (0 if none / on error). */ + presenceStats(channel: string): Promise; } +const extractNumClients = (data: unknown): number => { + if (typeof data !== 'object' || data === null || !('result' in data)) { + return 0; + } + const result = data.result; + if (typeof result !== 'object' || result === null || !('num_clients' in result)) { + return 0; + } + const numClients = result.num_clients; + return typeof numClients === 'number' ? numClients : 0; +}; + declare module 'fastify' { interface FastifyInstance { centrifugo: CentrifugoClient; @@ -31,6 +45,22 @@ export const centrifugoPlugin = fp( throw new Error(`Centrifugo publish failed with status ${String(response.status)}`); } }, + + async presenceStats(channel) { + const response = await fetch(`${apiUrl}/presence_stats`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + }, + body: JSON.stringify({ channel }), + }); + if (!response.ok) { + return 0; + } + const data: unknown = await response.json(); + return extractNumClients(data); + }, }; app.decorate('centrifugo', client); diff --git a/packages/core/src/api/contacts.ts b/packages/core/src/api/contacts.ts new file mode 100644 index 0000000..64e6e79 --- /dev/null +++ b/packages/core/src/api/contacts.ts @@ -0,0 +1,20 @@ +import { contactListSchema, contactSchema } from '../schemas/index'; +import type { AddContactBody } from '../schemas/index'; +import type { Contact } from '../types/index'; +import { compileValidator, parse, requestJson } from './http'; +import type { ApiClientConfig } from './http'; + +const contactV = compileValidator(contactSchema); +const contactListV = compileValidator(contactListSchema); + +export const listContacts = async (config: ApiClientConfig): Promise => + parse(contactListV, await requestJson(config, 'GET', '/contacts')); + +export const addContact = async ( + config: ApiClientConfig, + body: AddContactBody, +): Promise => parse(contactV, await requestJson(config, 'POST', '/contacts', body)); + +export const removeContact = async (config: ApiClientConfig, userId: string): Promise => { + await requestJson(config, 'DELETE', `/contacts/${userId}`); +}; diff --git a/packages/core/src/api/conversations.ts b/packages/core/src/api/conversations.ts new file mode 100644 index 0000000..20b9c40 --- /dev/null +++ b/packages/core/src/api/conversations.ts @@ -0,0 +1,71 @@ +import { + conversationSchema, + conversationListSchema, + conversationMemberListSchema, +} from '../schemas/index'; +import type { CreateDirectBody, CreateGroupBody, AddMemberBody } from '../schemas/index'; +import type { Conversation, ConversationMember } from '../types/index'; +import { compileValidator, parse, requestJson } from './http'; +import type { ApiClientConfig } from './http'; + +const conversationV = compileValidator(conversationSchema); +const conversationListV = compileValidator(conversationListSchema); +const memberListV = compileValidator(conversationMemberListSchema); + +export const listConversations = async (config: ApiClientConfig): Promise => + parse(conversationListV, await requestJson(config, 'GET', '/conversations')); + +export const getConversation = async ( + config: ApiClientConfig, + id: string, +): Promise => + parse(conversationV, await requestJson(config, 'GET', `/conversations/${id}`)); + +export const createDirect = async ( + config: ApiClientConfig, + body: CreateDirectBody, +): Promise => + parse(conversationV, await requestJson(config, 'POST', '/conversations/direct', body)); + +export const createGroup = async ( + config: ApiClientConfig, + body: CreateGroupBody, +): Promise => + parse(conversationV, await requestJson(config, 'POST', '/conversations', body)); + +export const listMembers = async ( + config: ApiClientConfig, + id: string, +): Promise => + parse(memberListV, await requestJson(config, 'GET', `/conversations/${id}/members`)); + +export const addMember = async ( + config: ApiClientConfig, + id: string, + body: AddMemberBody, +): Promise => + parse(memberListV, await requestJson(config, 'POST', `/conversations/${id}/members`, body)); + +export const removeMember = async ( + config: ApiClientConfig, + id: string, + userId: string, +): Promise => { + await requestJson(config, 'DELETE', `/conversations/${id}/members/${userId}`); +}; + +export const markRead = async ( + config: ApiClientConfig, + id: string, + seq: number, +): Promise => { + await requestJson(config, 'POST', `/conversations/${id}/read`, { seq }); +}; + +export const sendTyping = async ( + config: ApiClientConfig, + id: string, + state: 'start' | 'stop', +): Promise => { + await requestJson(config, 'POST', `/conversations/${id}/typing`, { state }); +}; diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index d986b98..5b21c80 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -13,6 +13,25 @@ export { getCentrifugoToken, } from './auth'; export { sendEcho } from './realtime'; -export { createRoom, listRooms, getRoom, listMembers, addMember, removeMember } from './rooms'; -export { sendMessage, getHistory } from './messages'; +export { searchUsers } from './users'; +export { + listConversations, + getConversation, + createDirect, + createGroup, + listMembers, + addMember, + removeMember, +} from './conversations'; +export { listContacts, addContact, removeContact } from './contacts'; +export { + sendMessage, + getHistory, + editMessage, + deleteMessage, + addReaction, + removeReaction, +} from './messages'; export type { HistoryOptions } from './messages'; +export { markRead, sendTyping } from './conversations'; +export { getPresence, heartbeat } from './presence'; diff --git a/packages/core/src/api/messages.ts b/packages/core/src/api/messages.ts index 8cfae21..12f909c 100644 --- a/packages/core/src/api/messages.ts +++ b/packages/core/src/api/messages.ts @@ -1,5 +1,5 @@ import { messageSchema, messageListSchema } from '../schemas/index'; -import type { SendMessageBody } from '../schemas/index'; +import type { SendMessageBody, EditMessageBody, ReactionBody } from '../schemas/index'; import type { Message } from '../types/index'; import { compileValidator, parse, requestJson } from './http'; import type { ApiClientConfig } from './http'; @@ -9,10 +9,10 @@ const messageListV = compileValidator(messageListSchema); export const sendMessage = async ( config: ApiClientConfig, - roomId: string, + conversationId: string, body: SendMessageBody, ): Promise => - parse(messageV, await requestJson(config, 'POST', `/rooms/${roomId}/messages`, body)); + parse(messageV, await requestJson(config, 'POST', `/conversations/${conversationId}/messages`, body)); export interface HistoryOptions { before?: number; @@ -21,7 +21,7 @@ export interface HistoryOptions { export const getHistory = async ( config: ApiClientConfig, - roomId: string, + conversationId: string, options: HistoryOptions = {}, ): Promise => { const params = new URLSearchParams(); @@ -32,6 +32,53 @@ export const getHistory = async ( params.set('limit', String(options.limit)); } const qs = params.toString(); - const path = qs.length > 0 ? `/rooms/${roomId}/messages?${qs}` : `/rooms/${roomId}/messages`; + const base = `/conversations/${conversationId}/messages`; + const path = qs.length > 0 ? `${base}?${qs}` : base; return parse(messageListV, await requestJson(config, 'GET', path)); }; + +export const editMessage = async ( + config: ApiClientConfig, + conversationId: string, + messageId: string, + body: EditMessageBody, +): Promise => + parse( + messageV, + await requestJson(config, 'PATCH', `/conversations/${conversationId}/messages/${messageId}`, body), + ); + +export const deleteMessage = async ( + config: ApiClientConfig, + conversationId: string, + messageId: string, +): Promise => { + await requestJson(config, 'DELETE', `/conversations/${conversationId}/messages/${messageId}`); +}; + +export const addReaction = async ( + config: ApiClientConfig, + conversationId: string, + messageId: string, + body: ReactionBody, +): Promise => { + await requestJson( + config, + 'POST', + `/conversations/${conversationId}/messages/${messageId}/reactions`, + body, + ); +}; + +export const removeReaction = async ( + config: ApiClientConfig, + conversationId: string, + messageId: string, + emoji: string, +): Promise => { + await requestJson( + config, + 'DELETE', + `/conversations/${conversationId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`, + ); +}; diff --git a/packages/core/src/api/presence.ts b/packages/core/src/api/presence.ts new file mode 100644 index 0000000..c952b59 --- /dev/null +++ b/packages/core/src/api/presence.ts @@ -0,0 +1,21 @@ +import { presenceListSchema } from '../schemas/index'; +import type { Presence } from '../types/index'; +import { compileValidator, parse, requestJson } from './http'; +import type { ApiClientConfig } from './http'; + +const presenceListV = compileValidator(presenceListSchema); + +export const getPresence = async ( + config: ApiClientConfig, + userIds: string[], +): Promise => { + if (userIds.length === 0) { + return []; + } + const qs = encodeURIComponent(userIds.join(',')); + return parse(presenceListV, await requestJson(config, 'GET', `/presence?userIds=${qs}`)); +}; + +export const heartbeat = async (config: ApiClientConfig): Promise => { + await requestJson(config, 'POST', '/me/heartbeat'); +}; diff --git a/packages/core/src/api/rooms.ts b/packages/core/src/api/rooms.ts deleted file mode 100644 index b69317a..0000000 --- a/packages/core/src/api/rooms.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - roomSchema, - roomListSchema, - roomMemberSchema, - roomMemberListSchema, -} from '../schemas/index'; -import type { CreateRoomBody, AddMemberBody } from '../schemas/index'; -import type { Room, RoomMember } from '../types/index'; -import { compileValidator, parse, requestJson } from './http'; -import type { ApiClientConfig } from './http'; - -const roomV = compileValidator(roomSchema); -const roomListV = compileValidator(roomListSchema); -const memberV = compileValidator(roomMemberSchema); -const memberListV = compileValidator(roomMemberListSchema); - -export const createRoom = async (config: ApiClientConfig, body: CreateRoomBody): Promise => - parse(roomV, await requestJson(config, 'POST', '/rooms', body)); - -export const listRooms = async (config: ApiClientConfig): Promise => - parse(roomListV, await requestJson(config, 'GET', '/rooms')); - -export const getRoom = async (config: ApiClientConfig, roomId: string): Promise => - parse(roomV, await requestJson(config, 'GET', `/rooms/${roomId}`)); - -export const listMembers = async ( - config: ApiClientConfig, - roomId: string, -): Promise => - parse(memberListV, await requestJson(config, 'GET', `/rooms/${roomId}/members`)); - -export const addMember = async ( - config: ApiClientConfig, - roomId: string, - body: AddMemberBody, -): Promise => - parse(memberV, await requestJson(config, 'POST', `/rooms/${roomId}/members`, body)); - -export const removeMember = async ( - config: ApiClientConfig, - roomId: string, - userId: string, -): Promise => { - await requestJson(config, 'DELETE', `/rooms/${roomId}/members/${userId}`); -}; diff --git a/packages/core/src/api/users.ts b/packages/core/src/api/users.ts new file mode 100644 index 0000000..ea5be01 --- /dev/null +++ b/packages/core/src/api/users.ts @@ -0,0 +1,15 @@ +import { publicUserListSchema } from '../schemas/index'; +import type { PublicUser } from '../types/index'; +import { compileValidator, parse, requestJson } from './http'; +import type { ApiClientConfig } from './http'; + +const publicUserListV = compileValidator(publicUserListSchema); + +export const searchUsers = async ( + config: ApiClientConfig, + query: string, +): Promise => + parse( + publicUserListV, + await requestJson(config, 'GET', `/users/search?q=${encodeURIComponent(query)}`), + ); diff --git a/packages/core/src/channels/index.ts b/packages/core/src/channels/index.ts index 8be0a65..50c949d 100644 --- a/packages/core/src/channels/index.ts +++ b/packages/core/src/channels/index.ts @@ -1,28 +1,30 @@ // Channel-name builders — the single source of truth for Centrifugo channel // names, shared by backend (publish/subscribe-proxy) and every client. // -// room: — one channel per room; all members subscribe. -// user: — one personal channel per user; DM + notification delivery. +// conv: — one channel per GROUP conversation; members subscribe. +// user: — one personal channel per user. Direct-message delivery, +// notifications, "added to a chat", etc. -export type RoomId = string; +export type ConversationId = string; export type UserId = string; -const ROOM_PREFIX = 'room:'; +const CONV_PREFIX = 'conv:'; const USER_PREFIX = 'user:'; -export const roomChannel = (roomId: RoomId): string => `${ROOM_PREFIX}${roomId}`; +export const conversationChannel = (conversationId: ConversationId): string => + `${CONV_PREFIX}${conversationId}`; export const userChannel = (userId: UserId): string => `${USER_PREFIX}${userId}`; -/** True for a `room:` channel name. */ -export const isRoomChannel = (channel: string): boolean => channel.startsWith(ROOM_PREFIX); +/** True for a `conv:` channel name. */ +export const isConversationChannel = (channel: string): boolean => channel.startsWith(CONV_PREFIX); /** True for a `user:` channel name. */ export const isUserChannel = (channel: string): boolean => channel.startsWith(USER_PREFIX); -/** Extract the room id from a `room:` channel, or null. */ -export const roomChannelId = (channel: string): string | null => - channel.startsWith(ROOM_PREFIX) ? channel.slice(ROOM_PREFIX.length) : null; +/** Extract the conversation id from a `conv:` channel, or null. */ +export const conversationChannelId = (channel: string): string | null => + channel.startsWith(CONV_PREFIX) ? channel.slice(CONV_PREFIX.length) : null; /** Extract the user id from a `user:` channel, or null. */ export const userChannelId = (channel: string): string | null => diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index 5beafca..1e9b29e 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -7,6 +7,7 @@ // Bucket C — connection-derived → Centrifugo built-in presence import type { Message } from '../types/message'; +import type { Conversation } from '../types/conversation'; export const EventType = { // Bucket A @@ -17,7 +18,8 @@ export const EventType = { ReactionRemove: 'reaction.remove', ReadReceipt: 'read.receipt', LastSeen: 'last_seen', - RoomMembership: 'room.membership', + ConversationNew: 'conversation.new', + ConversationMembership: 'conversation.membership', ProfileUpdate: 'profile.update', // Bucket B @@ -39,11 +41,55 @@ export interface MessageNewEvent { message: Message; } -export type RoomMembershipAction = 'added' | 'removed'; +// Published to a user's personal channel when a new conversation involving them +// is created (a DM someone started, or a group they were added to) so it appears +// in their list immediately. +export interface ConversationNewEvent { + type: 'conversation.new'; + conversation: Conversation; +} -export interface RoomMembershipEvent { - type: 'room.membership'; - action: RoomMembershipAction; - roomId: string; +export type ConversationMembershipAction = 'added' | 'removed'; + +export interface ConversationMembershipEvent { + type: 'conversation.membership'; + action: ConversationMembershipAction; + conversationId: string; + userId: string; +} + +export interface MessageEditEvent { + type: 'message.edit'; + message: Message; +} + +export interface MessageDeleteEvent { + type: 'message.delete'; + conversationId: string; + messageId: string; +} + +export type ReactionAction = 'reaction.add' | 'reaction.remove'; + +export interface ReactionEvent { + type: ReactionAction; + conversationId: string; + messageId: string; + emoji: string; + userId: string; +} + +export interface ReadReceiptEvent { + type: 'read.receipt'; + conversationId: string; + userId: string; + seq: number; +} + +export type TypingState = 'typing.start' | 'typing.stop'; + +export interface TypingEvent { + type: TypingState; + conversationId: string; userId: string; } diff --git a/packages/core/src/schemas/conversation.ts b/packages/core/src/schemas/conversation.ts new file mode 100644 index 0000000..ffe7830 --- /dev/null +++ b/packages/core/src/schemas/conversation.ts @@ -0,0 +1,62 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +const USERNAME_PATTERN = '^[a-zA-Z0-9_]{3,32}$'; + +export const createDirectBodySchema = { + type: 'object', + additionalProperties: false, + required: ['username'], + properties: { + username: { type: 'string', pattern: USERNAME_PATTERN }, + }, +} as const; + +export const createGroupBodySchema = { + type: 'object', + additionalProperties: false, + required: ['title'], + properties: { + title: { type: 'string', minLength: 1, maxLength: 100 }, + members: { + type: 'array', + maxItems: 200, + items: { type: 'string', pattern: USERNAME_PATTERN }, + }, + }, +} as const; + +export const addMemberBodySchema = { + type: 'object', + additionalProperties: false, + required: ['username'], + properties: { + username: { type: 'string', pattern: USERNAME_PATTERN }, + }, +} as const; + +export const sendMessageBodySchema = { + type: 'object', + additionalProperties: false, + required: ['content', 'clientMsgId'], + properties: { + content: { type: 'string', minLength: 1, maxLength: 16000 }, + clientMsgId: { type: 'string', minLength: 1, maxLength: 64 }, + contentType: { type: 'string', enum: ['text', 'text/ciphertext'] }, + encryption: { type: ['string', 'null'] }, + }, +} as const; + +export const addContactBodySchema = { + type: 'object', + additionalProperties: false, + required: ['username'], + properties: { + username: { type: 'string', pattern: USERNAME_PATTERN }, + }, +} as const; + +export type CreateDirectBody = FromSchema; +export type CreateGroupBody = FromSchema; +export type AddMemberBody = FromSchema; +export type SendMessageBody = FromSchema; +export type AddContactBody = FromSchema; diff --git a/packages/core/src/schemas/entities.ts b/packages/core/src/schemas/entities.ts index b619b58..2688428 100644 --- a/packages/core/src/schemas/entities.ts +++ b/packages/core/src/schemas/entities.ts @@ -90,21 +90,62 @@ export const errorSchema = { }, } as const; -export const roomSchema = { +export const publicUserListSchema = { type: 'array', items: publicUserSchema } as const; + +export const conversationSchema = { type: 'object', additionalProperties: false, - required: ['id', 'name', 'createdBy', 'createdAt'], + required: [ + 'id', + 'type', + 'title', + 'peer', + 'createdBy', + 'createdAt', + 'lastMessageAt', + 'unreadCount', + ], properties: { id: { type: 'string', format: 'uuid' }, - name: { type: 'string' }, + type: { type: 'string', enum: ['direct', 'group'] }, + title: { type: ['string', 'null'] }, + peer: { + oneOf: [publicUserSchema, { type: 'null' }], + }, createdBy: { type: 'string', format: 'uuid' }, createdAt: { type: 'string', format: 'date-time' }, + lastMessageAt: { type: 'string', format: 'date-time' }, + unreadCount: { type: 'integer' }, }, } as const; -export const roomListSchema = { type: 'array', items: roomSchema } as const; +export const reactionSummarySchema = { + type: 'object', + additionalProperties: false, + required: ['emoji', 'count', 'mine'], + properties: { + emoji: { type: 'string' }, + count: { type: 'integer' }, + mine: { type: 'boolean' }, + }, +} as const; -export const roomMemberSchema = { +export const presenceSchema = { + type: 'object', + additionalProperties: false, + required: ['userId', 'online', 'lastSeenAt'], + properties: { + userId: { type: 'string', format: 'uuid' }, + online: { type: 'boolean' }, + lastSeenAt: { type: ['string', 'null'], format: 'date-time' }, + }, +} as const; + +export const presenceListSchema = { type: 'array', items: presenceSchema } as const; + +export const conversationListSchema = { type: 'array', items: conversationSchema } as const; + +export const conversationMemberSchema = { type: 'object', additionalProperties: false, required: ['userId', 'role', 'joinedAt', 'user'], @@ -116,14 +157,30 @@ export const roomMemberSchema = { }, } as const; -export const roomMemberListSchema = { type: 'array', items: roomMemberSchema } as const; +export const conversationMemberListSchema = { + type: 'array', + items: conversationMemberSchema, +} as const; + +export const contactSchema = { + type: 'object', + additionalProperties: false, + required: ['userId', 'user', 'createdAt'], + properties: { + userId: { type: 'string', format: 'uuid' }, + user: publicUserSchema, + createdAt: { type: 'string', format: 'date-time' }, + }, +} as const; + +export const contactListSchema = { type: 'array', items: contactSchema } as const; export const messageSchema = { type: 'object', additionalProperties: false, required: [ 'id', - 'roomId', + 'conversationId', 'senderId', 'sender', 'content', @@ -132,10 +189,13 @@ export const messageSchema = { 'clientMsgId', 'seq', 'createdAt', + 'editedAt', + 'deletedAt', + 'reactions', ], properties: { id: { type: 'string', format: 'uuid' }, - roomId: { type: 'string', format: 'uuid' }, + conversationId: { type: 'string', format: 'uuid' }, senderId: { type: 'string', format: 'uuid' }, sender: publicUserSchema, content: { type: 'string' }, @@ -144,6 +204,9 @@ export const messageSchema = { clientMsgId: { type: 'string' }, seq: { type: 'integer' }, createdAt: { type: 'string', format: 'date-time' }, + editedAt: { type: ['string', 'null'], format: 'date-time' }, + deletedAt: { type: ['string', 'null'], format: 'date-time' }, + reactions: { type: 'array', items: reactionSummarySchema }, }, } as const; diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 65c5791..aec3952 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -1,19 +1,44 @@ export { registerBodySchema, loginBodySchema, updateMeBodySchema } from './auth'; export type { RegisterBody, LoginBody, UpdateMeBody } from './auth'; -export { createRoomBodySchema, addMemberBodySchema, sendMessageBodySchema } from './room'; -export type { CreateRoomBody, AddMemberBody, SendMessageBody } from './room'; +export { + createDirectBodySchema, + createGroupBodySchema, + addMemberBodySchema, + sendMessageBodySchema, + addContactBodySchema, +} from './conversation'; +export type { + CreateDirectBody, + CreateGroupBody, + AddMemberBody, + SendMessageBody, + AddContactBody, +} from './conversation'; +export { + editMessageBodySchema, + reactionBodySchema, + readBodySchema, + typingBodySchema, +} from './live'; +export type { EditMessageBody, ReactionBody, ReadBody, TypingBody } from './live'; export { publicUserSchema, + publicUserListSchema, userSchema, authResultSchema, sessionSchema, sessionListSchema, centrifugoTokenSchema, errorSchema, - roomSchema, - roomListSchema, - roomMemberSchema, - roomMemberListSchema, + conversationSchema, + conversationListSchema, + conversationMemberSchema, + conversationMemberListSchema, + contactSchema, + contactListSchema, messageSchema, messageListSchema, + reactionSummarySchema, + presenceSchema, + presenceListSchema, } from './entities'; diff --git a/packages/core/src/schemas/live.ts b/packages/core/src/schemas/live.ts new file mode 100644 index 0000000..208adec --- /dev/null +++ b/packages/core/src/schemas/live.ts @@ -0,0 +1,42 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const editMessageBodySchema = { + type: 'object', + additionalProperties: false, + required: ['content'], + properties: { + content: { type: 'string', minLength: 1, maxLength: 16000 }, + }, +} as const; + +export const reactionBodySchema = { + type: 'object', + additionalProperties: false, + required: ['emoji'], + properties: { + emoji: { type: 'string', minLength: 1, maxLength: 32 }, + }, +} as const; + +export const readBodySchema = { + type: 'object', + additionalProperties: false, + required: ['seq'], + properties: { + seq: { type: 'integer', minimum: 0 }, + }, +} as const; + +export const typingBodySchema = { + type: 'object', + additionalProperties: false, + required: ['state'], + properties: { + state: { type: 'string', enum: ['start', 'stop'] }, + }, +} as const; + +export type EditMessageBody = FromSchema; +export type ReactionBody = FromSchema; +export type ReadBody = FromSchema; +export type TypingBody = FromSchema; diff --git a/packages/core/src/schemas/room.ts b/packages/core/src/schemas/room.ts deleted file mode 100644 index 3023ee5..0000000 --- a/packages/core/src/schemas/room.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { FromSchema } from 'json-schema-to-ts'; - -export const createRoomBodySchema = { - type: 'object', - additionalProperties: false, - required: ['name'], - properties: { - name: { type: 'string', minLength: 1, maxLength: 100 }, - }, -} as const; - -export const addMemberBodySchema = { - type: 'object', - additionalProperties: false, - required: ['username'], - properties: { - username: { type: 'string', pattern: '^[a-zA-Z0-9_]{3,32}$' }, - }, -} as const; - -export const sendMessageBodySchema = { - type: 'object', - additionalProperties: false, - required: ['content', 'clientMsgId'], - properties: { - // Plaintext today; base64 ciphertext once E2EE lands (hence the generous max). - content: { type: 'string', minLength: 1, maxLength: 16000 }, - clientMsgId: { type: 'string', minLength: 1, maxLength: 64 }, - contentType: { type: 'string', enum: ['text', 'text/ciphertext'] }, - encryption: { type: ['string', 'null'] }, - }, -} as const; - -export type CreateRoomBody = FromSchema; -export type AddMemberBody = FromSchema; -export type SendMessageBody = FromSchema; diff --git a/packages/core/src/types/contact.ts b/packages/core/src/types/contact.ts new file mode 100644 index 0000000..47fba0e --- /dev/null +++ b/packages/core/src/types/contact.ts @@ -0,0 +1,7 @@ +import type { PublicUser } from './user'; + +export interface Contact { + userId: string; + user: PublicUser; + createdAt: string; +} diff --git a/packages/core/src/types/conversation.ts b/packages/core/src/types/conversation.ts new file mode 100644 index 0000000..2f7b31a --- /dev/null +++ b/packages/core/src/types/conversation.ts @@ -0,0 +1,23 @@ +import type { PublicUser } from './user'; + +export type ConversationType = 'direct' | 'group'; + +export interface Conversation { + id: string; + type: ConversationType; + /** Title for a group; null for a direct conversation. */ + title: string | null; + /** The other participant for a direct conversation; null for a group. */ + peer: PublicUser | null; + createdBy: string; + createdAt: string; + lastMessageAt: string; + unreadCount: number; +} + +export interface ConversationMember { + userId: string; + role: string; + joinedAt: string; + user: PublicUser; +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index a4d89e1..1750ff1 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -3,5 +3,7 @@ export const CORE_VERSION = '0.1.0'; export type { User, PublicUser } from './user'; export type { AuthResult, Session, CentrifugoToken } from './auth'; -export type { Room, RoomMember } from './room'; -export type { Message } from './message'; +export type { Conversation, ConversationType, ConversationMember } from './conversation'; +export type { Contact } from './contact'; +export type { Message, ReactionSummary } from './message'; +export type { Presence } from './presence'; diff --git a/packages/core/src/types/message.ts b/packages/core/src/types/message.ts index 2cb53e2..62968ba 100644 --- a/packages/core/src/types/message.ts +++ b/packages/core/src/types/message.ts @@ -1,11 +1,17 @@ import type { PublicUser } from './user'; +export interface ReactionSummary { + emoji: string; + count: number; + /** Whether the requesting user reacted with this emoji. */ + mine: boolean; +} + // A chat message. `content` is a crypto-agnostic envelope: plaintext today, -// base64 ciphertext once E2EE lands — the server never needs to interpret it -// beyond storing/relaying. `seq` is the server-assigned monotonic order. +// base64 ciphertext once E2EE lands. `seq` is the server-assigned monotonic order. export interface Message { id: string; - roomId: string; + conversationId: string; senderId: string; sender: PublicUser; content: string; @@ -14,4 +20,7 @@ export interface Message { clientMsgId: string; seq: number; createdAt: string; + editedAt: string | null; + deletedAt: string | null; + reactions: ReactionSummary[]; } diff --git a/packages/core/src/types/presence.ts b/packages/core/src/types/presence.ts new file mode 100644 index 0000000..4a43a93 --- /dev/null +++ b/packages/core/src/types/presence.ts @@ -0,0 +1,5 @@ +export interface Presence { + userId: string; + online: boolean; + lastSeenAt: string | null; +} diff --git a/packages/core/src/types/room.ts b/packages/core/src/types/room.ts deleted file mode 100644 index f2ace06..0000000 --- a/packages/core/src/types/room.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { PublicUser } from './user'; - -export interface Room { - id: string; - name: string; - createdBy: string; - createdAt: string; -} - -export interface RoomMember { - userId: string; - role: string; - joinedAt: string; - user: PublicUser; -} diff --git a/packages/web/src/app/App.tsx b/packages/web/src/app/App.tsx index ffa4952..89b9c2a 100644 --- a/packages/web/src/app/App.tsx +++ b/packages/web/src/app/App.tsx @@ -1,11 +1,14 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; -import type { PublicUser, User } from '@altricade/core'; +import type { Conversation, PublicUser, User } from '@altricade/core'; +import { getPresence, heartbeat } from '@altricade/core/api'; import { SessionProvider, useSession } from '../entities/session'; import { AuthForm } from '../features/auth'; import { RealtimeProvider, useRealtime } from '../features/realtime'; -import { useRooms, RoomSidebar } from '../features/rooms'; +import { useConversations, ConversationSidebar } from '../features/conversations'; +import { ContactsPanel } from '../features/contacts'; import { ChatView } from '../features/messaging'; +import { apiConfig } from '../shared/api'; import { useTheme } from '../shared/theme'; import type { ThemePreference } from '../shared/theme'; @@ -39,8 +42,54 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re avatarUrl: user.avatarUrl, }; const { state } = useRealtime(); - const { rooms, createRoom } = useRooms(user.id); - const [currentRoomId, setCurrentRoomId] = useState(null); + const { conversations, startDirect, createGroupChat } = useConversations(user.id); + const [current, setCurrent] = useState(null); + const [onlineMap, setOnlineMap] = useState>({}); + + const peerKey = conversations + .flatMap((conversation) => (conversation.peer === null ? [] : [conversation.peer.id])) + .join(','); + + // Poll presence for the people we have DMs with. + useEffect(() => { + const ids = peerKey.split(',').filter((id) => id.length > 0); + if (ids.length === 0) { + return undefined; + } + let cancelled = false; + const poll = async (): Promise => { + try { + const presences = await getPresence(apiConfig, ids); + if (!cancelled) { + const map: Record = {}; + for (const presence of presences) { + map[presence.userId] = presence.online; + } + setOnlineMap(map); + } + } catch { + // presence is best-effort + } + }; + void poll(); + const interval = setInterval(() => { + void poll(); + }, 20000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [peerKey]); + + // Keep-alive heartbeat so our own last-seen stays fresh while connected. + useEffect(() => { + const interval = setInterval(() => { + void heartbeat(apiConfig); + }, 45000); + return () => { + clearInterval(interval); + }; + }, []); return (
@@ -56,20 +105,30 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
- { - const room = await createRoom(name); - setCurrentRoomId(room.id); - }} - /> - {currentRoomId !== null ? ( - +
+ { + setCurrent(await startDirect(username)); + }} + onCreateGroup={async (title, members) => { + setCurrent(await createGroupChat(title, members)); + }} + /> + { + setCurrent(await startDirect(username)); + }} + /> +
+ {current !== null ? ( + ) : (
-

Select a room, or create one to start chatting.

+

Search for a user or pick a contact to start chatting.

)}
diff --git a/packages/web/src/app/index.css b/packages/web/src/app/index.css index 3e2d990..d5e67be 100644 --- a/packages/web/src/app/index.css +++ b/packages/web/src/app/index.css @@ -278,3 +278,176 @@ body { background: var(--color-surface); color: var(--color-text); } + +.sidebar-column { + width: 280px; + border-right: 1px solid var(--color-border); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.sidebar-column .sidebar { + width: auto; + border-right: none; + border-bottom: 1px solid var(--color-border); +} + +.sidebar-heading { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-textMuted); + margin: 1rem 0 0.5rem; +} + +.search-input { + width: 100%; + padding: 0.5rem; + border-radius: 8px; + border: 1px solid var(--color-border); + background: var(--color-background); + color: var(--color-text); +} + +.search-results { + list-style: none; + padding: 0; + margin: 0.5rem 0; + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; +} + +.search-results button, +.contact-list button { + width: 100%; + text-align: left; + padding: 0.5rem 0.75rem; + border: none; + background: none; + color: var(--color-text); + cursor: pointer; +} + +.muted { + color: var(--color-textMuted); +} + +.contacts { + padding: 1rem; +} + +.contact-list { + list-style: none; + padding: 0; + margin: 0; +} + +.contact-list li { + display: flex; + align-items: center; + justify-content: space-between; +} + +.contact-remove { + border: none; + background: none; + color: var(--color-textMuted); + cursor: pointer; +} + +.chat-title { + margin: 0 0 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.group-form { + flex-direction: column; + align-items: stretch; +} + +.online-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #30a46c; + margin-right: 0.4rem; +} + +.unread-badge { + display: inline-block; + min-width: 1.2rem; + padding: 0 0.35rem; + margin-left: 0.4rem; + border-radius: 999px; + background: var(--color-accent); + color: #fff; + font-size: 0.7rem; + text-align: center; +} + +.msg-row { + display: flex; + align-items: baseline; + gap: 0.4rem; +} + +.read-mark { + margin-left: auto; + font-size: 0.75rem; + opacity: 0.8; +} + +.msg-actions { + display: none; + gap: 0.25rem; + margin-top: 0.2rem; +} + +.message-list li:hover .msg-actions { + display: flex; +} + +.react-btn, +.link-btn { + border: none; + background: none; + cursor: pointer; + font-size: 0.8rem; + padding: 0 0.15rem; + color: inherit; +} + +.link-btn { + color: var(--color-textMuted); + text-decoration: underline; +} + +.reactions { + display: flex; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.reaction { + border: 1px solid var(--color-border); + background: var(--color-background); + color: var(--color-text); + border-radius: 999px; + padding: 0 0.5rem; + font-size: 0.8rem; + cursor: pointer; +} + +.reaction.mine-reaction { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.typing { + font-style: italic; + padding: 0 0.25rem; +} diff --git a/packages/web/src/features/contacts/index.ts b/packages/web/src/features/contacts/index.ts new file mode 100644 index 0000000..f900a0a --- /dev/null +++ b/packages/web/src/features/contacts/index.ts @@ -0,0 +1,3 @@ +export { useContacts } from './model'; +export type { UseContacts } from './model'; +export { ContactsPanel } from './ui/ContactsPanel'; diff --git a/packages/web/src/features/contacts/model.ts b/packages/web/src/features/contacts/model.ts new file mode 100644 index 0000000..83f73f4 --- /dev/null +++ b/packages/web/src/features/contacts/model.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { Contact } from '@altricade/core'; +import { listContacts, addContact, removeContact } from '@altricade/core/api'; +import { apiConfig } from '../../shared/api'; + +export interface UseContacts { + contacts: Contact[]; + add: (username: string) => Promise; + remove: (userId: string) => Promise; +} + +export const useContacts = (): UseContacts => { + const [contacts, setContacts] = useState([]); + + const reload = useCallback(async (): Promise => { + setContacts(await listContacts(apiConfig)); + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + const add = useCallback(async (username: string): Promise => { + const contact = await addContact(apiConfig, { username }); + setContacts((prev) => [contact, ...prev.filter((item) => item.userId !== contact.userId)]); + }, []); + + const remove = useCallback(async (userId: string): Promise => { + await removeContact(apiConfig, userId); + setContacts((prev) => prev.filter((item) => item.userId !== userId)); + }, []); + + return { contacts, add, remove }; +}; diff --git a/packages/web/src/features/contacts/ui/ContactsPanel.tsx b/packages/web/src/features/contacts/ui/ContactsPanel.tsx new file mode 100644 index 0000000..c34eaa6 --- /dev/null +++ b/packages/web/src/features/contacts/ui/ContactsPanel.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import type { ReactElement, SyntheticEvent } from 'react'; +import { ApiError } from '@altricade/core/api'; +import { useContacts } from '../model'; + +interface Props { + onStartDirect: (username: string) => Promise; +} + +export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => { + const { contacts, add, remove } = useContacts(); + const [name, setName] = useState(''); + const [error, setError] = useState(null); + + const submit = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + const trimmed = name.trim(); + if (trimmed === '') { + return; + } + setError(null); + try { + await add(trimmed); + setName(''); + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : 'Could not add contact'); + } + }; + + return ( +
+

Contacts

+
{ + void submit(event); + }} + > + { + setName(event.target.value); + }} + /> + +
+
    + {contacts.map((contact) => ( +
  • + + +
  • + ))} +
+ {error !== null ?

{error}

: null} +
+ ); +}; diff --git a/packages/web/src/features/conversations/index.ts b/packages/web/src/features/conversations/index.ts new file mode 100644 index 0000000..c5f274c --- /dev/null +++ b/packages/web/src/features/conversations/index.ts @@ -0,0 +1,3 @@ +export { useConversations } from './model'; +export type { UseConversations } from './model'; +export { ConversationSidebar } from './ui/ConversationSidebar'; diff --git a/packages/web/src/features/conversations/model.ts b/packages/web/src/features/conversations/model.ts new file mode 100644 index 0000000..f3179ac --- /dev/null +++ b/packages/web/src/features/conversations/model.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { Conversation } from '@altricade/core'; +import { userChannel } from '@altricade/core'; +import { listConversations, createDirect, createGroup } from '@altricade/core/api'; +import { apiConfig } from '../../shared/api'; +import { useRealtime } from '../realtime'; + +export interface UseConversations { + conversations: Conversation[]; + loading: boolean; + startDirect: (username: string) => Promise; + createGroupChat: (title: string, members: string[]) => Promise; +} + +const isConversationNew = ( + data: unknown, +): data is { type: 'conversation.new'; conversation: Conversation } => { + if (typeof data !== 'object' || data === null) { + return false; + } + return 'type' in data && data.type === 'conversation.new' && 'conversation' in data; +}; + +const upsert = (list: Conversation[], conversation: Conversation): Conversation[] => { + const rest = list.filter((item) => item.id !== conversation.id); + return [conversation, ...rest]; +}; + +export const useConversations = (userId: string): UseConversations => { + const { subscribe } = useRealtime(); + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + const load = async (): Promise => { + try { + const list = await listConversations(apiConfig); + if (!cancelled) { + setConversations(list); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + void load(); + return () => { + cancelled = true; + }; + }, []); + + // New conversations (a DM someone started with me, or a group I was added to) + // arrive on my personal channel. + useEffect(() => { + return subscribe(userChannel(userId), (event) => { + if (isConversationNew(event.data)) { + const { conversation } = event.data; + setConversations((prev) => upsert(prev, conversation)); + } + }); + }, [subscribe, userId]); + + const startDirect = useCallback(async (username: string): Promise => { + const conversation = await createDirect(apiConfig, { username }); + setConversations((prev) => upsert(prev, conversation)); + return conversation; + }, []); + + const createGroupChat = useCallback( + async (title: string, members: string[]): Promise => { + const conversation = await createGroup(apiConfig, { title, members }); + setConversations((prev) => upsert(prev, conversation)); + return conversation; + }, + [], + ); + + return { conversations, loading, startDirect, createGroupChat }; +}; diff --git a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx new file mode 100644 index 0000000..1f3feb5 --- /dev/null +++ b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import type { ReactElement, SyntheticEvent } from 'react'; +import type { Conversation, PublicUser } from '@altricade/core'; +import { searchUsers, ApiError } from '@altricade/core/api'; +import { apiConfig } from '../../../shared/api'; + +interface Props { + conversations: Conversation[]; + currentId: string | null; + onlineMap: Record; + onSelect: (conversation: Conversation) => void; + onStartDirect: (username: string) => Promise; + onCreateGroup: (title: string, members: string[]) => Promise; +} + +const label = (conversation: Conversation): string => { + if (conversation.type === 'group') { + return conversation.title ?? 'Group'; + } + return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`; +}; + +export const ConversationSidebar = ({ + conversations, + currentId, + onlineMap, + onSelect, + onStartDirect, + onCreateGroup, +}: Props): ReactElement => { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [groupTitle, setGroupTitle] = useState(''); + const [groupMembers, setGroupMembers] = useState(''); + const [error, setError] = useState(null); + + const runSearch = async (value: string): Promise => { + setQuery(value); + if (value.trim().length === 0) { + setResults([]); + return; + } + try { + setResults(await searchUsers(apiConfig, value.trim())); + } catch { + setResults([]); + } + }; + + const start = async (username: string): Promise => { + setError(null); + try { + await onStartDirect(username); + setQuery(''); + setResults([]); + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : 'Could not start chat'); + } + }; + + const submitGroup = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + const title = groupTitle.trim(); + if (title === '') { + return; + } + const members = groupMembers + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0); + setError(null); + try { + await onCreateGroup(title, members); + setGroupTitle(''); + setGroupMembers(''); + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : 'Could not create group'); + } + }; + + return ( + + ); +}; diff --git a/packages/web/src/features/messaging/index.ts b/packages/web/src/features/messaging/index.ts index c0dc515..3ab0c60 100644 --- a/packages/web/src/features/messaging/index.ts +++ b/packages/web/src/features/messaging/index.ts @@ -1,3 +1,3 @@ -export { useRoomMessages } from './model'; -export type { UseRoomMessages } from './model'; +export { useConversationMessages } from './model'; +export type { UseConversationMessages } from './model'; export { ChatView } from './ui/ChatView'; diff --git a/packages/web/src/features/messaging/model.ts b/packages/web/src/features/messaging/model.ts index 8d9249f..50532cd 100644 --- a/packages/web/src/features/messaging/model.ts +++ b/packages/web/src/features/messaging/model.ts @@ -1,48 +1,173 @@ -import { useCallback, useEffect, useState } from 'react'; -import type { Message, MessageNewEvent, PublicUser } from '@altricade/core'; -import { roomChannel, mergeMessages, OPTIMISTIC_SEQ } from '@altricade/core'; -import { getHistory, sendMessage } from '@altricade/core/api'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { + Conversation, + Message, + MessageNewEvent, + MessageEditEvent, + MessageDeleteEvent, + ReactionEvent, + ReadReceiptEvent, + TypingEvent, + PublicUser, + ReactionSummary, +} from '@altricade/core'; +import { conversationChannel, userChannel, mergeMessages, OPTIMISTIC_SEQ } from '@altricade/core'; +import { + getHistory, + sendMessage, + editMessage as apiEdit, + deleteMessage as apiDelete, + addReaction, + removeReaction, + markRead, + sendTyping, +} from '@altricade/core/api'; import { apiConfig } from '../../shared/api'; import { useRealtime } from '../realtime'; -export interface UseRoomMessages { +export interface UseConversationMessages { messages: Message[]; - send: (content: string) => Promise; loading: boolean; + typingUserIds: string[]; + /** For a direct conversation: the peer's last-read seq (drives ✓✓). */ + peerReadSeq: number; + send: (content: string) => Promise; + edit: (messageId: string, content: string) => Promise; + remove: (messageId: string) => Promise; + toggleReaction: (message: Message, emoji: string) => Promise; + notifyTyping: () => void; } -const isMessageNew = (data: unknown): data is MessageNewEvent => { - if (typeof data !== 'object' || data === null) { - return false; +const hasType = (data: unknown): data is { type: string } => + typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string'; + +const isMessageEvent = (d: unknown): d is MessageNewEvent | MessageEditEvent => + hasType(d) && (d.type === 'message.new' || d.type === 'message.edit') && 'message' in d; +const isDeleteEvent = (d: unknown): d is MessageDeleteEvent => + hasType(d) && d.type === 'message.delete' && 'messageId' in d && 'conversationId' in d; +const isReactionEvent = (d: unknown): d is ReactionEvent => + hasType(d) && (d.type === 'reaction.add' || d.type === 'reaction.remove'); +const isReadEvent = (d: unknown): d is ReadReceiptEvent => hasType(d) && d.type === 'read.receipt'; +const isTypingEvent = (d: unknown): d is TypingEvent => + hasType(d) && (d.type === 'typing.start' || d.type === 'typing.stop'); + +const applyReaction = ( + reactions: ReactionSummary[], + emoji: string, + delta: 1 | -1, + fromMe: boolean, +): ReactionSummary[] => { + const existing = reactions.find((r) => r.emoji === emoji); + if (existing === undefined) { + return delta === 1 ? [...reactions, { emoji, count: 1, mine: fromMe }] : reactions; } - if (!('type' in data) || data.type !== 'message.new') { - return false; - } - return 'message' in data; + return reactions + .map((r) => { + if (r.emoji !== emoji) { + return r; + } + const mine = fromMe ? delta === 1 : r.mine; + return { emoji, count: r.count + delta, mine }; + }) + .filter((r) => r.count > 0); }; -export const useRoomMessages = (roomId: string, me: PublicUser): UseRoomMessages => { +export const useConversationMessages = ( + conversation: Conversation, + me: PublicUser, +): UseConversationMessages => { const { subscribe } = useRealtime(); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); + const [typingUserIds, setTypingUserIds] = useState([]); + const [peerReadSeq, setPeerReadSeq] = useState(0); + const typingTimers = useRef>>(new Map()); + const lastTypingSent = useRef(0); + + const conversationId = conversation.id; + const channel = + conversation.type === 'group' ? conversationChannel(conversationId) : userChannel(me.id); - // Reconnect-safe ordering: subscribe FIRST (live events buffer into state), - // THEN load history, then merge/dedupe — so nothing slips through the gap. useEffect(() => { let cancelled = false; setMessages([]); setLoading(true); + setTypingUserIds([]); + setPeerReadSeq(0); + const timers = typingTimers.current; - const unsubscribe = subscribe(roomChannel(roomId), (event) => { - if (isMessageNew(event.data)) { - const incoming = event.data.message; - setMessages((prev) => mergeMessages(prev, [incoming])); + const clearTyping = (userId: string): void => { + setTypingUserIds((prev) => prev.filter((id) => id !== userId)); + }; + + const unsubscribe = subscribe(channel, (event) => { + const data = event.data; + + if (isMessageEvent(data)) { + if (data.message.conversationId === conversationId) { + const { message } = data; + setMessages((prev) => mergeMessages(prev, [message])); + } + return; + } + if (isDeleteEvent(data)) { + if (data.conversationId === conversationId) { + const { messageId } = data; + setMessages((prev) => + prev.map((m) => + m.id === messageId ? { ...m, content: '', deletedAt: new Date().toISOString() } : m, + ), + ); + } + return; + } + if (isReactionEvent(data)) { + if (data.conversationId === conversationId) { + const { messageId, emoji, userId } = data; + const delta = data.type === 'reaction.add' ? 1 : -1; + setMessages((prev) => + prev.map((m) => + m.id === messageId + ? { ...m, reactions: applyReaction(m.reactions, emoji, delta, userId === me.id) } + : m, + ), + ); + } + return; + } + if (isReadEvent(data)) { + if (data.conversationId === conversationId && data.userId !== me.id) { + const { seq } = data; + setPeerReadSeq((prev) => Math.max(prev, seq)); + } + return; + } + if (isTypingEvent(data)) { + if (data.conversationId !== conversationId || data.userId === me.id) { + return; + } + const { userId } = data; + if (data.type === 'typing.stop') { + clearTyping(userId); + return; + } + setTypingUserIds((prev) => (prev.includes(userId) ? prev : [...prev, userId])); + const existing = timers.get(userId); + if (existing !== undefined) { + clearTimeout(existing); + } + timers.set( + userId, + setTimeout(() => { + clearTyping(userId); + }, 4000), + ); } }); const load = async (): Promise => { try { - const history = await getHistory(apiConfig, roomId, { limit: 50 }); + const history = await getHistory(apiConfig, conversationId, { limit: 50 }); if (!cancelled) { setMessages((prev) => mergeMessages(prev, history)); } @@ -57,15 +182,29 @@ export const useRoomMessages = (roomId: string, me: PublicUser): UseRoomMessages return () => { cancelled = true; unsubscribe(); + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); }; - }, [roomId, subscribe]); + }, [conversationId, channel, subscribe, me.id]); + + // Mark the conversation read up to the newest confirmed message. + useEffect(() => { + const maxSeq = messages + .filter((m) => m.seq !== OPTIMISTIC_SEQ) + .reduce((max, m) => Math.max(max, m.seq), 0); + if (maxSeq > 0) { + void markRead(apiConfig, conversationId, maxSeq); + } + }, [messages, conversationId]); const send = useCallback( async (content: string): Promise => { const clientMsgId = crypto.randomUUID(); const optimistic: Message = { id: `optimistic:${clientMsgId}`, - roomId, + conversationId, senderId: me.id, sender: me, content, @@ -74,13 +213,61 @@ export const useRoomMessages = (roomId: string, me: PublicUser): UseRoomMessages clientMsgId, seq: OPTIMISTIC_SEQ, createdAt: new Date().toISOString(), + editedAt: null, + deletedAt: null, + reactions: [], }; setMessages((prev) => mergeMessages(prev, [optimistic])); - const confirmed = await sendMessage(apiConfig, roomId, { content, clientMsgId }); + const confirmed = await sendMessage(apiConfig, conversationId, { content, clientMsgId }); setMessages((prev) => mergeMessages(prev, [confirmed])); }, - [roomId, me], + [conversationId, me], ); - return { messages, send, loading }; + const edit = useCallback( + async (messageId: string, content: string): Promise => { + const updated = await apiEdit(apiConfig, conversationId, messageId, { content }); + setMessages((prev) => mergeMessages(prev, [updated])); + }, + [conversationId], + ); + + const remove = useCallback( + async (messageId: string): Promise => { + await apiDelete(apiConfig, conversationId, messageId); + }, + [conversationId], + ); + + const toggleReaction = useCallback( + async (message: Message, emoji: string): Promise => { + const mine = message.reactions.some((r) => r.emoji === emoji && r.mine); + if (mine) { + await removeReaction(apiConfig, conversationId, message.id, emoji); + } else { + await addReaction(apiConfig, conversationId, message.id, { emoji }); + } + }, + [conversationId], + ); + + const notifyTyping = useCallback((): void => { + const now = Date.now(); + if (now - lastTypingSent.current > 2500) { + lastTypingSent.current = now; + void sendTyping(apiConfig, conversationId, 'start'); + } + }, [conversationId]); + + return { + messages, + loading, + typingUserIds, + peerReadSeq, + send, + edit, + remove, + toggleReaction, + notifyTyping, + }; }; diff --git a/packages/web/src/features/messaging/ui/ChatView.tsx b/packages/web/src/features/messaging/ui/ChatView.tsx index 08aa28e..156ffda 100644 --- a/packages/web/src/features/messaging/ui/ChatView.tsx +++ b/packages/web/src/features/messaging/ui/ChatView.tsx @@ -1,15 +1,25 @@ import { useState } from 'react'; import type { ReactElement, SyntheticEvent } from 'react'; -import type { PublicUser } from '@altricade/core'; -import { useRoomMessages } from '../model'; +import type { Conversation, Message, PublicUser } from '@altricade/core'; +import { useConversationMessages } from '../model'; interface Props { - roomId: string; + conversation: Conversation; me: PublicUser; } -export const ChatView = ({ roomId, me }: Props): ReactElement => { - const { messages, send, loading } = useRoomMessages(roomId, me); +const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉']; + +const headerTitle = (conversation: Conversation): string => { + if (conversation.type === 'group') { + return conversation.title ?? 'Group'; + } + return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`; +}; + +export const ChatView = ({ conversation, me }: Props): ReactElement => { + const { messages, loading, typingUserIds, peerReadSeq, send, edit, remove, toggleReaction, notifyTyping } = + useConversationMessages(conversation, me); const [text, setText] = useState(''); const submit = async (event: SyntheticEvent): Promise => { @@ -22,17 +32,89 @@ export const ChatView = ({ roomId, me }: Props): ReactElement => { await send(trimmed); }; + const onEdit = (message: Message): void => { + const next = window.prompt('Edit message', message.content); + if (next !== null && next.trim() !== '') { + void edit(message.id, next.trim()); + } + }; + + const readMark = (message: Message): string => { + if (conversation.type !== 'direct' || message.senderId !== me.id || message.deletedAt !== null) { + return ''; + } + return peerReadSeq >= message.seq ? '✓✓' : '✓'; + }; + return (
+

{headerTitle(conversation)}

{loading ?

Loading…

: null}
    {messages.map((message) => (
  • - @{message.sender.username} - {message.content} +
    + @{message.sender.username} + + {message.deletedAt !== null ? message deleted : message.content} + + {message.editedAt !== null && message.deletedAt === null ? ( + (edited) + ) : null} + {readMark(message)} +
    + {message.deletedAt === null ? ( +
    + {QUICK_REACTIONS.map((emoji) => ( + + ))} + {message.senderId === me.id ? ( + <> + + + + ) : null} +
    + ) : null} + {message.reactions.length > 0 ? ( +
    + {message.reactions.map((r) => ( + + ))} +
    + ) : null}
  • ))}
+ {typingUserIds.length > 0 ?

typing…

: null}
{ @@ -44,6 +126,7 @@ export const ChatView = ({ roomId, me }: Props): ReactElement => { placeholder="Write a message…" onChange={(event) => { setText(event.target.value); + notifyTyping(); }} /> diff --git a/packages/web/src/features/rooms/index.ts b/packages/web/src/features/rooms/index.ts deleted file mode 100644 index 75d2c99..0000000 --- a/packages/web/src/features/rooms/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useRooms } from './model'; -export type { UseRooms } from './model'; -export { RoomSidebar } from './ui/RoomSidebar'; diff --git a/packages/web/src/features/rooms/model.ts b/packages/web/src/features/rooms/model.ts deleted file mode 100644 index 66fdeef..0000000 --- a/packages/web/src/features/rooms/model.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import type { Room } from '@altricade/core'; -import { userChannel } from '@altricade/core'; -import { listRooms, createRoom as apiCreateRoom } from '@altricade/core/api'; -import { apiConfig } from '../../shared/api'; -import { useRealtime } from '../realtime'; - -export interface UseRooms { - rooms: Room[]; - loading: boolean; - error: string | null; - createRoom: (name: string) => Promise; - reload: () => Promise; -} - -export const useRooms = (userId: string): UseRooms => { - const { subscribe } = useRealtime(); - const [rooms, setRooms] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const reload = useCallback(async (): Promise => { - try { - setRooms(await listRooms(apiConfig)); - } catch { - setError('Failed to load rooms'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void reload(); - }, [reload]); - - // A room.membership event on the personal channel means we were added/removed. - useEffect(() => { - return subscribe(userChannel(userId), () => { - void reload(); - }); - }, [subscribe, userId, reload]); - - const createRoom = useCallback(async (name: string): Promise => { - const room = await apiCreateRoom(apiConfig, { name }); - setRooms((prev) => [room, ...prev]); - return room; - }, []); - - return { rooms, loading, error, createRoom, reload }; -}; diff --git a/packages/web/src/features/rooms/ui/RoomSidebar.tsx b/packages/web/src/features/rooms/ui/RoomSidebar.tsx deleted file mode 100644 index 51f2e42..0000000 --- a/packages/web/src/features/rooms/ui/RoomSidebar.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useState } from 'react'; -import type { ReactElement, SyntheticEvent } from 'react'; -import type { Room } from '@altricade/core'; -import { ApiError, addMember } from '@altricade/core/api'; -import { apiConfig } from '../../../shared/api'; - -interface Props { - rooms: Room[]; - currentRoomId: string | null; - onSelect: (roomId: string) => void; - onCreate: (name: string) => Promise; -} - -export const RoomSidebar = ({ rooms, currentRoomId, onSelect, onCreate }: Props): ReactElement => { - const [name, setName] = useState(''); - const [memberName, setMemberName] = useState(''); - const [error, setError] = useState(null); - - const submitCreate = async (event: SyntheticEvent): Promise => { - event.preventDefault(); - const trimmed = name.trim(); - if (trimmed === '') { - return; - } - setError(null); - try { - await onCreate(trimmed); - setName(''); - } catch { - setError('Could not create room'); - } - }; - - const submitAddMember = async (event: SyntheticEvent): Promise => { - event.preventDefault(); - const trimmed = memberName.trim(); - if (currentRoomId === null || trimmed === '') { - return; - } - setError(null); - try { - await addMember(apiConfig, currentRoomId, { username: trimmed }); - setMemberName(''); - } catch (caught) { - setError(caught instanceof ApiError ? caught.message : 'Could not add member'); - } - }; - - return ( - - ); -};