init presence

This commit is contained in:
Заид Омар Медхат | Zaid Omar Medhat 2026-07-10 16:54:40 +05:00
parent e7f37be62c
commit 71e018d802
70 changed files with 3179 additions and 794 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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');
};

View file

@ -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']);
};

View file

@ -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<Fastif
const publish: Publisher = (channel, data) => 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<Fastif
}),
);
app.decorate(
'roomsService',
createRoomsService({ rooms: roomsRepository, users: usersRepository, publish }),
'conversationsService',
createConversationsService({
conversations: conversationsRepository,
users: usersRepository,
readState: readStateRepository,
deliver,
publish,
}),
);
app.decorate(
'messagesService',
createMessagesService({ messages: messagesRepository, rooms: roomsRepository, publish }),
createMessagesService({
messages: messagesRepository,
conversations: conversationsRepository,
deliver,
}),
);
app.decorate(
'contactsService',
createContactsService({ contacts: contactsRepository, users: usersRepository }),
);
app.decorate(
'presenceService',
createPresenceService({
users: usersRepository,
isOnline: async (userId) => (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<Fastif
await app.register(healthRoutes);
await app.register(authRoutes, { prefix: '/auth' });
await app.register(usersRoutes);
await app.register(roomsRoutes);
await app.register(conversationsRoutes);
await app.register(messagesRoutes);
await app.register(contactsRoutes);
await app.register(presenceRoutes);
await app.register(realtimeRoutes);
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);
app.log.info(`core wired — example channel: ${conversationChannel('demo')}`);
return app;
};

View file

@ -28,15 +28,18 @@ export interface RefreshTokensTable {
revoked_at: Date | null;
}
export interface RoomsTable {
export interface ConversationsTable {
id: Generated<string>;
name: string;
type: Generated<string>;
title: string | null;
direct_key: string | null;
created_by: string;
created_at: Generated<Date>;
last_message_at: Generated<Date>;
}
export interface RoomMembersTable {
room_id: string;
export interface ConversationMembersTable {
conversation_id: string;
user_id: string;
role: Generated<string>;
joined_at: Generated<Date>;
@ -46,19 +49,44 @@ export interface MessagesTable {
id: Generated<string>;
// int8 — parsed to a JS number by the pg type parser in the db plugin.
seq: Generated<number>;
room_id: string;
conversation_id: string;
sender_id: string;
client_msg_id: string;
content: string;
content_type: Generated<string>;
encryption: string | null;
created_at: Generated<Date>;
edited_at: Date | null;
deleted_at: Date | null;
}
export interface ContactsTable {
owner_id: string;
contact_id: string;
created_at: Generated<Date>;
}
export interface ReactionsTable {
message_id: string;
user_id: string;
emoji: string;
created_at: Generated<Date>;
}
export interface ReadStateTable {
conversation_id: string;
user_id: string;
last_read_seq: Generated<number>;
updated_at: ColumnType<Date, Date | undefined, Date | undefined>;
}
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;
}

View file

@ -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,

View file

@ -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<void>;
remove(ownerId: string, contactId: string): Promise<void>;
listForOwner(ownerId: string): Promise<ContactWithUser[]>;
getOne(ownerId: string, contactId: string): Promise<ContactWithUser | undefined>;
}
export const createContactsRepository = (db: Kysely<Database>): 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(),
};
};

View file

@ -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<void> => {
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();
};

View file

@ -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<Contact[]>;
add(ownerId: string, username: string): Promise<Contact>;
remove(ownerId: string, contactId: string): Promise<void>;
}
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;
}
}

View file

@ -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';

View file

@ -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),
});

View file

@ -0,0 +1,251 @@
import type { Kysely, Selectable } from 'kysely';
import type { Database, ConversationsTable } from '../../db/schema';
export type ConversationRow = Selectable<ConversationsTable>;
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<ConversationRow>;
findOrCreateDirect(userA: string, userB: string): Promise<FindOrCreate>;
findById(id: string): Promise<ConversationRow | undefined>;
listForUser(userId: string): Promise<ConversationRow[]>;
peersForDirect(userId: string, conversationIds: string[]): Promise<Map<string, MemberWithUser>>;
getPeer(conversationId: string, userId: string): Promise<MemberWithUser | undefined>;
isMember(conversationId: string, userId: string): Promise<boolean>;
getRole(conversationId: string, userId: string): Promise<string | undefined>;
addMember(conversationId: string, userId: string, role: string): Promise<void>;
removeMember(conversationId: string, userId: string): Promise<void>;
listMembers(conversationId: string): Promise<MemberWithUser[]>;
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
touchLastMessage(conversationId: string): Promise<void>;
}
export const createConversationsRepository = (db: Kysely<Database>): 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<string, MemberWithUser>();
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();
},
};
};

View file

@ -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<void> => {
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();
};

View file

@ -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<Conversation>;
createGroup(actorId: string, title: string, memberUsernames: string[]): Promise<Conversation>;
list(userId: string): Promise<Conversation[]>;
get(conversationId: string, userId: string): Promise<Conversation>;
listMembers(conversationId: string, userId: string): Promise<ConversationMember[]>;
addMember(conversationId: string, actorId: string, username: string): Promise<ConversationMember[]>;
removeMember(conversationId: string, actorId: string, targetUserId: string): Promise<void>;
isMember(conversationId: string, userId: string): Promise<boolean>;
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
markRead(conversationId: string, userId: string, seq: number): Promise<void>;
setTyping(conversationId: string, userId: string, state: 'start' | 'stop'): Promise<void>;
}
export const createConversationsService = (
deps: ConversationsServiceDeps,
): ConversationsService => {
const { conversations, users, readState, deliver, publish } = deps;
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
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<void> => {
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;
}
}

View file

@ -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<void>;
// 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);
}
};
};

View file

@ -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';

View file

@ -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<void>;
unreadCounts(userId: string): Promise<Map<string, number>>;
unreadFor(conversationId: string, userId: string): Promise<number>;
}
export const createReadStateRepository = (db: Kysely<Database>): 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<string, number>();
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;
},
});

View file

@ -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,
});

View file

@ -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<string | undefined>;
getWithSenderById(id: string): Promise<MessageWithSenderRow | undefined>;
listHistory(
roomId: string,
conversationId: string,
beforeSeq: number | null,
limit: number,
): Promise<MessageWithSenderRow[]>;
getConversationId(messageId: string): Promise<string | undefined>;
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<void>;
removeReaction(messageId: string, userId: string, emoji: string): Promise<void>;
reactionsFor(messageIds: string[], userId: string): Promise<Map<string, ReactionSummary[]>>;
}
export const createMessagesRepository = (db: Kysely<Database>): MessagesRepository => {
@ -48,13 +62,15 @@ export const createMessagesRepository = (db: Kysely<Database>): 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<Database>): 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<Database>): 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<string, ReactionSummary[]>();
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<number>().as('count'),
sql<boolean>`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;
},
};
};

View file

@ -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<void> => {
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<void> => {
);
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<void> => {
},
);
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();
};

View file

@ -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<SentMessage>;
send(conversationId: string, senderId: string, input: SendMessageBody): Promise<SentMessage>;
history(
roomId: string,
conversationId: string,
userId: string,
beforeSeq: number | null,
limit: number,
): Promise<Message[]>;
edit(
conversationId: string,
messageId: string,
userId: string,
content: string,
): Promise<Message>;
remove(conversationId: string, messageId: string, userId: string): Promise<void>;
addReaction(
conversationId: string,
messageId: string,
userId: string,
emoji: string,
): Promise<void>;
removeReaction(
conversationId: string,
messageId: string,
userId: string,
emoji: string,
): Promise<void>;
}
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
const { messages, rooms, publish } = deps;
const { messages, conversations, deliver } = deps;
const assertMember = async (roomId: string, userId: string): Promise<void> => {
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<void> => {
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<void> => {
if ((await messages.getConversationId(messageId)) !== conversationId) {
throw new HttpError(404, 'not_found', 'Message not found');
}
};
const loadMessage = async (messageId: string, userId: string): Promise<Message> => {
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);
},
};
};

View file

@ -0,0 +1,3 @@
export { createPresenceService } from './presence.service';
export type { PresenceService, PresenceServiceDeps } from './presence.service';
export { presenceRoutes } from './presence.routes';

View file

@ -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<void> => {
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();
};

View file

@ -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<boolean>;
}
export interface PresenceService {
getPresence(userIds: string[]): Promise<Presence[]>;
}
export const createPresenceService = (deps: PresenceServiceDeps): PresenceService => ({
getPresence: async (userIds) => {
const rows = await deps.users.findManyByIds(userIds);
const byId = new Map<string, UserRow>(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;
}
}

View file

@ -30,7 +30,10 @@ export const realtimeRoutes = (app: FastifyInstance): Promise<void> => {
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,
);

View file

@ -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<boolean>;
isConversationMember: (conversationId: string, userId: string) => Promise<boolean>;
}
const deny = (errorMessage: string): SubscribeDecision => ({
@ -16,7 +16,7 @@ const deny = (errorMessage: string): SubscribeDecision => ({
// Subscription authorization for the Centrifugo subscribe-proxy.
// user:<id> → only that user's own personal channel.
// room:<id> → only current members (live DB check → kicked users lose access).
// conv:<id> → 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');

View file

@ -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';

View file

@ -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,
},
});

View file

@ -1,103 +0,0 @@
import type { Kysely, Selectable } from 'kysely';
import type { Database, RoomsTable } from '../../db/schema';
export type RoomRow = Selectable<RoomsTable>;
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<RoomRow>;
findById(id: string): Promise<RoomRow | undefined>;
listForUser(userId: string): Promise<RoomRow[]>;
isMember(roomId: string, userId: string): Promise<boolean>;
getRole(roomId: string, userId: string): Promise<string | undefined>;
addMember(roomId: string, userId: string, role: string): Promise<void>;
removeMember(roomId: string, userId: string): Promise<void>;
listMembers(roomId: string): Promise<RoomMemberWithUser[]>;
}
export const createRoomsRepository = (db: Kysely<Database>): 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(),
});

View file

@ -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<void> => {
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();
};

View file

@ -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<Room>;
get(roomId: string, userId: string): Promise<Room>;
listMine(userId: string): Promise<Room[]>;
listMembers(roomId: string, userId: string): Promise<RoomMember[]>;
addMember(roomId: string, actorId: string, username: string): Promise<RoomMember>;
removeMember(roomId: string, actorId: string, targetUserId: string): Promise<void>;
isMember(roomId: string, userId: string): Promise<boolean>;
assertMember(roomId: string, userId: string): Promise<void>;
}
export const createRoomsService = (deps: RoomsServiceDeps): RoomsService => {
const { rooms, users, publish } = deps;
const assertMember = async (roomId: string, userId: string): Promise<void> => {
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<void> => {
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;
}
}

View file

@ -21,6 +21,8 @@ export interface UsersRepository {
create(input: NewUser): Promise<UserRow>;
findByUsername(username: string): Promise<UserRow | undefined>;
findById(id: string): Promise<UserRow | undefined>;
findManyByIds(ids: string[]): Promise<UserRow[]>;
searchByPrefix(query: string, excludeUserId: string, limit: number): Promise<UserRow[]>;
updateProfile(id: string, patch: ProfilePatch): Promise<UserRow | undefined>;
touchLastSeen(id: string): Promise<void>;
}
@ -44,6 +46,23 @@ export const createUsersRepository = (db: Kysely<Database>): 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<UsersTable> = { updated_at: new Date() };
if (patch.displayName !== undefined) {

View file

@ -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<void> => {
},
);
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',
{

View file

@ -7,8 +7,15 @@ export interface UsersService {
getMe(userId: string): Promise<User>;
getPublicProfile(username: string): Promise<PublicUser>;
updateProfile(userId: string, patch: UpdateMeBody): Promise<User>;
search(query: string, excludeUserId: string): Promise<PublicUser[]>;
heartbeat(userId: string): Promise<void>;
}
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' {

View file

@ -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<void>;
/** Number of clients currently present in a channel (0 if none / on error). */
presenceStats(channel: string): Promise<number>;
}
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);

View file

@ -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<Contact>(contactSchema);
const contactListV = compileValidator<Contact[]>(contactListSchema);
export const listContacts = async (config: ApiClientConfig): Promise<Contact[]> =>
parse(contactListV, await requestJson(config, 'GET', '/contacts'));
export const addContact = async (
config: ApiClientConfig,
body: AddContactBody,
): Promise<Contact> => parse(contactV, await requestJson(config, 'POST', '/contacts', body));
export const removeContact = async (config: ApiClientConfig, userId: string): Promise<void> => {
await requestJson(config, 'DELETE', `/contacts/${userId}`);
};

View file

@ -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<Conversation>(conversationSchema);
const conversationListV = compileValidator<Conversation[]>(conversationListSchema);
const memberListV = compileValidator<ConversationMember[]>(conversationMemberListSchema);
export const listConversations = async (config: ApiClientConfig): Promise<Conversation[]> =>
parse(conversationListV, await requestJson(config, 'GET', '/conversations'));
export const getConversation = async (
config: ApiClientConfig,
id: string,
): Promise<Conversation> =>
parse(conversationV, await requestJson(config, 'GET', `/conversations/${id}`));
export const createDirect = async (
config: ApiClientConfig,
body: CreateDirectBody,
): Promise<Conversation> =>
parse(conversationV, await requestJson(config, 'POST', '/conversations/direct', body));
export const createGroup = async (
config: ApiClientConfig,
body: CreateGroupBody,
): Promise<Conversation> =>
parse(conversationV, await requestJson(config, 'POST', '/conversations', body));
export const listMembers = async (
config: ApiClientConfig,
id: string,
): Promise<ConversationMember[]> =>
parse(memberListV, await requestJson(config, 'GET', `/conversations/${id}/members`));
export const addMember = async (
config: ApiClientConfig,
id: string,
body: AddMemberBody,
): Promise<ConversationMember[]> =>
parse(memberListV, await requestJson(config, 'POST', `/conversations/${id}/members`, body));
export const removeMember = async (
config: ApiClientConfig,
id: string,
userId: string,
): Promise<void> => {
await requestJson(config, 'DELETE', `/conversations/${id}/members/${userId}`);
};
export const markRead = async (
config: ApiClientConfig,
id: string,
seq: number,
): Promise<void> => {
await requestJson(config, 'POST', `/conversations/${id}/read`, { seq });
};
export const sendTyping = async (
config: ApiClientConfig,
id: string,
state: 'start' | 'stop',
): Promise<void> => {
await requestJson(config, 'POST', `/conversations/${id}/typing`, { state });
};

View file

@ -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';

View file

@ -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<Message[]>(messageListSchema);
export const sendMessage = async (
config: ApiClientConfig,
roomId: string,
conversationId: string,
body: SendMessageBody,
): Promise<Message> =>
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<Message[]> => {
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<Message> =>
parse(
messageV,
await requestJson(config, 'PATCH', `/conversations/${conversationId}/messages/${messageId}`, body),
);
export const deleteMessage = async (
config: ApiClientConfig,
conversationId: string,
messageId: string,
): Promise<void> => {
await requestJson(config, 'DELETE', `/conversations/${conversationId}/messages/${messageId}`);
};
export const addReaction = async (
config: ApiClientConfig,
conversationId: string,
messageId: string,
body: ReactionBody,
): Promise<void> => {
await requestJson(
config,
'POST',
`/conversations/${conversationId}/messages/${messageId}/reactions`,
body,
);
};
export const removeReaction = async (
config: ApiClientConfig,
conversationId: string,
messageId: string,
emoji: string,
): Promise<void> => {
await requestJson(
config,
'DELETE',
`/conversations/${conversationId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`,
);
};

View file

@ -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<Presence[]>(presenceListSchema);
export const getPresence = async (
config: ApiClientConfig,
userIds: string[],
): Promise<Presence[]> => {
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<void> => {
await requestJson(config, 'POST', '/me/heartbeat');
};

View file

@ -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<Room>(roomSchema);
const roomListV = compileValidator<Room[]>(roomListSchema);
const memberV = compileValidator<RoomMember>(roomMemberSchema);
const memberListV = compileValidator<RoomMember[]>(roomMemberListSchema);
export const createRoom = async (config: ApiClientConfig, body: CreateRoomBody): Promise<Room> =>
parse(roomV, await requestJson(config, 'POST', '/rooms', body));
export const listRooms = async (config: ApiClientConfig): Promise<Room[]> =>
parse(roomListV, await requestJson(config, 'GET', '/rooms'));
export const getRoom = async (config: ApiClientConfig, roomId: string): Promise<Room> =>
parse(roomV, await requestJson(config, 'GET', `/rooms/${roomId}`));
export const listMembers = async (
config: ApiClientConfig,
roomId: string,
): Promise<RoomMember[]> =>
parse(memberListV, await requestJson(config, 'GET', `/rooms/${roomId}/members`));
export const addMember = async (
config: ApiClientConfig,
roomId: string,
body: AddMemberBody,
): Promise<RoomMember> =>
parse(memberV, await requestJson(config, 'POST', `/rooms/${roomId}/members`, body));
export const removeMember = async (
config: ApiClientConfig,
roomId: string,
userId: string,
): Promise<void> => {
await requestJson(config, 'DELETE', `/rooms/${roomId}/members/${userId}`);
};

View file

@ -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<PublicUser[]>(publicUserListSchema);
export const searchUsers = async (
config: ApiClientConfig,
query: string,
): Promise<PublicUser[]> =>
parse(
publicUserListV,
await requestJson(config, 'GET', `/users/search?q=${encodeURIComponent(query)}`),
);

View file

@ -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:<roomId> — one channel per room; all members subscribe.
// user:<userId> — one personal channel per user; DM + notification delivery.
// conv:<conversationId> — one channel per GROUP conversation; members subscribe.
// user:<userId> — 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:<id>` channel name. */
export const isRoomChannel = (channel: string): boolean => channel.startsWith(ROOM_PREFIX);
/** True for a `conv:<id>` channel name. */
export const isConversationChannel = (channel: string): boolean => channel.startsWith(CONV_PREFIX);
/** True for a `user:<id>` channel name. */
export const isUserChannel = (channel: string): boolean => channel.startsWith(USER_PREFIX);
/** Extract the room id from a `room:<id>` 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:<id>` 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:<id>` channel, or null. */
export const userChannelId = (channel: string): string | null =>

View file

@ -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;
}

View file

@ -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<typeof createDirectBodySchema>;
export type CreateGroupBody = FromSchema<typeof createGroupBodySchema>;
export type AddMemberBody = FromSchema<typeof addMemberBodySchema>;
export type SendMessageBody = FromSchema<typeof sendMessageBodySchema>;
export type AddContactBody = FromSchema<typeof addContactBodySchema>;

View file

@ -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;

View file

@ -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';

View file

@ -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<typeof editMessageBodySchema>;
export type ReactionBody = FromSchema<typeof reactionBodySchema>;
export type ReadBody = FromSchema<typeof readBodySchema>;
export type TypingBody = FromSchema<typeof typingBodySchema>;

View file

@ -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<typeof createRoomBodySchema>;
export type AddMemberBody = FromSchema<typeof addMemberBodySchema>;
export type SendMessageBody = FromSchema<typeof sendMessageBodySchema>;

View file

@ -0,0 +1,7 @@
import type { PublicUser } from './user';
export interface Contact {
userId: string;
user: PublicUser;
createdAt: string;
}

View file

@ -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;
}

View file

@ -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';

View file

@ -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[];
}

View file

@ -0,0 +1,5 @@
export interface Presence {
userId: string;
online: boolean;
lastSeenAt: string | null;
}

View file

@ -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;
}

View file

@ -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<string | null>(null);
const { conversations, startDirect, createGroupChat } = useConversations(user.id);
const [current, setCurrent] = useState<Conversation | null>(null);
const [onlineMap, setOnlineMap] = useState<Record<string, boolean>>({});
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<void> => {
try {
const presences = await getPresence(apiConfig, ids);
if (!cancelled) {
const map: Record<string, boolean> = {};
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 (
<div className="layout">
@ -56,20 +105,30 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
</span>
</header>
<div className="workspace">
<RoomSidebar
rooms={rooms}
currentRoomId={currentRoomId}
onSelect={setCurrentRoomId}
onCreate={async (name) => {
const room = await createRoom(name);
setCurrentRoomId(room.id);
}}
/>
{currentRoomId !== null ? (
<ChatView roomId={currentRoomId} me={me} />
<div className="sidebar-column">
<ConversationSidebar
conversations={conversations}
currentId={current?.id ?? null}
onlineMap={onlineMap}
onSelect={setCurrent}
onStartDirect={async (username) => {
setCurrent(await startDirect(username));
}}
onCreateGroup={async (title, members) => {
setCurrent(await createGroupChat(title, members));
}}
/>
<ContactsPanel
onStartDirect={async (username) => {
setCurrent(await startDirect(username));
}}
/>
</div>
{current !== null ? (
<ChatView conversation={current} me={me} />
) : (
<section className="chat chat-empty">
<p>Select a room, or create one to start chatting.</p>
<p>Search for a user or pick a contact to start chatting.</p>
</section>
)}
</div>

View file

@ -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;
}

View file

@ -0,0 +1,3 @@
export { useContacts } from './model';
export type { UseContacts } from './model';
export { ContactsPanel } from './ui/ContactsPanel';

View file

@ -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<void>;
remove: (userId: string) => Promise<void>;
}
export const useContacts = (): UseContacts => {
const [contacts, setContacts] = useState<Contact[]>([]);
const reload = useCallback(async (): Promise<void> => {
setContacts(await listContacts(apiConfig));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const add = useCallback(async (username: string): Promise<void> => {
const contact = await addContact(apiConfig, { username });
setContacts((prev) => [contact, ...prev.filter((item) => item.userId !== contact.userId)]);
}, []);
const remove = useCallback(async (userId: string): Promise<void> => {
await removeContact(apiConfig, userId);
setContacts((prev) => prev.filter((item) => item.userId !== userId));
}, []);
return { contacts, add, remove };
};

View file

@ -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<void>;
}
export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => {
const { contacts, add, remove } = useContacts();
const [name, setName] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async (event: SyntheticEvent): Promise<void> => {
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 (
<div className="contacts">
<h3 className="sidebar-heading">Contacts</h3>
<form
className="sidebar-form"
onSubmit={(event) => {
void submit(event);
}}
>
<input
placeholder="add contact username"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
<button type="submit">Add</button>
</form>
<ul className="contact-list">
{contacts.map((contact) => (
<li key={contact.userId}>
<button
type="button"
onClick={() => {
void onStartDirect(contact.user.username);
}}
>
@{contact.user.username}
</button>
<button
type="button"
className="contact-remove"
aria-label="remove contact"
onClick={() => {
void remove(contact.userId);
}}
>
</button>
</li>
))}
</ul>
{error !== null ? <p className="auth-error">{error}</p> : null}
</div>
);
};

View file

@ -0,0 +1,3 @@
export { useConversations } from './model';
export type { UseConversations } from './model';
export { ConversationSidebar } from './ui/ConversationSidebar';

View file

@ -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<Conversation>;
createGroupChat: (title: string, members: string[]) => Promise<Conversation>;
}
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<Conversation[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const load = async (): Promise<void> => {
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<Conversation> => {
const conversation = await createDirect(apiConfig, { username });
setConversations((prev) => upsert(prev, conversation));
return conversation;
}, []);
const createGroupChat = useCallback(
async (title: string, members: string[]): Promise<Conversation> => {
const conversation = await createGroup(apiConfig, { title, members });
setConversations((prev) => upsert(prev, conversation));
return conversation;
},
[],
);
return { conversations, loading, startDirect, createGroupChat };
};

View file

@ -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<string, boolean>;
onSelect: (conversation: Conversation) => void;
onStartDirect: (username: string) => Promise<void>;
onCreateGroup: (title: string, members: string[]) => Promise<void>;
}
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<PublicUser[]>([]);
const [groupTitle, setGroupTitle] = useState('');
const [groupMembers, setGroupMembers] = useState('');
const [error, setError] = useState<string | null>(null);
const runSearch = async (value: string): Promise<void> => {
setQuery(value);
if (value.trim().length === 0) {
setResults([]);
return;
}
try {
setResults(await searchUsers(apiConfig, value.trim()));
} catch {
setResults([]);
}
};
const start = async (username: string): Promise<void> => {
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<void> => {
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 (
<aside className="sidebar">
<input
className="search-input"
placeholder="Search users to chat…"
value={query}
onChange={(event) => {
void runSearch(event.target.value);
}}
/>
{results.length > 0 ? (
<ul className="search-results">
{results.map((user) => (
<li key={user.id}>
<button
type="button"
onClick={() => {
void start(user.username);
}}
>
{user.displayName} <span className="muted">@{user.username}</span>
</button>
</li>
))}
</ul>
) : null}
<h3 className="sidebar-heading">Conversations</h3>
<ul className="room-list">
{conversations.map((conversation) => {
const online =
conversation.peer !== null && onlineMap[conversation.peer.id] === true;
return (
<li key={conversation.id}>
<button
type="button"
aria-pressed={conversation.id === currentId}
onClick={() => {
onSelect(conversation);
}}
>
{online ? <span className="online-dot" aria-label="online" /> : null}
{label(conversation)}
{conversation.unreadCount > 0 ? (
<span className="unread-badge">{conversation.unreadCount}</span>
) : null}
</button>
</li>
);
})}
</ul>
<form
className="sidebar-form group-form"
onSubmit={(event) => {
void submitGroup(event);
}}
>
<h3 className="sidebar-heading">New group</h3>
<input
placeholder="group title"
value={groupTitle}
onChange={(event) => {
setGroupTitle(event.target.value);
}}
/>
<input
placeholder="members (comma-separated usernames)"
value={groupMembers}
onChange={(event) => {
setGroupMembers(event.target.value);
}}
/>
<button type="submit">Create group</button>
</form>
{error !== null ? <p className="auth-error">{error}</p> : null}
</aside>
);
};

View file

@ -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';

View file

@ -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<void>;
loading: boolean;
typingUserIds: string[];
/** For a direct conversation: the peer's last-read seq (drives ✓✓). */
peerReadSeq: number;
send: (content: string) => Promise<void>;
edit: (messageId: string, content: string) => Promise<void>;
remove: (messageId: string) => Promise<void>;
toggleReaction: (message: Message, emoji: string) => Promise<void>;
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<Message[]>([]);
const [loading, setLoading] = useState(true);
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
const [peerReadSeq, setPeerReadSeq] = useState(0);
const typingTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(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<void> => {
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<void> => {
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<void> => {
const updated = await apiEdit(apiConfig, conversationId, messageId, { content });
setMessages((prev) => mergeMessages(prev, [updated]));
},
[conversationId],
);
const remove = useCallback(
async (messageId: string): Promise<void> => {
await apiDelete(apiConfig, conversationId, messageId);
},
[conversationId],
);
const toggleReaction = useCallback(
async (message: Message, emoji: string): Promise<void> => {
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,
};
};

View file

@ -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<void> => {
@ -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 (
<section className="chat">
<h2 className="chat-title">{headerTitle(conversation)}</h2>
{loading ? <p className="chat-loading">Loading</p> : null}
<ul className="message-list">
{messages.map((message) => (
<li key={message.id} className={message.senderId === me.id ? 'mine' : ''}>
<span className="msg-author">@{message.sender.username}</span>
<span className="msg-body">{message.content}</span>
<div className="msg-row">
<span className="msg-author">@{message.sender.username}</span>
<span className="msg-body">
{message.deletedAt !== null ? <em className="muted">message deleted</em> : message.content}
</span>
{message.editedAt !== null && message.deletedAt === null ? (
<span className="muted"> (edited)</span>
) : null}
<span className="read-mark">{readMark(message)}</span>
</div>
{message.deletedAt === null ? (
<div className="msg-actions">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
className="react-btn"
onClick={() => {
void toggleReaction(message, emoji);
}}
>
{emoji}
</button>
))}
{message.senderId === me.id ? (
<>
<button type="button" className="link-btn" onClick={() => { onEdit(message); }}>
edit
</button>
<button
type="button"
className="link-btn"
onClick={() => {
void remove(message.id);
}}
>
delete
</button>
</>
) : null}
</div>
) : null}
{message.reactions.length > 0 ? (
<div className="reactions">
{message.reactions.map((r) => (
<button
key={r.emoji}
type="button"
className={r.mine ? 'reaction mine-reaction' : 'reaction'}
onClick={() => {
void toggleReaction(message, r.emoji);
}}
>
{r.emoji} {r.count}
</button>
))}
</div>
) : null}
</li>
))}
</ul>
{typingUserIds.length > 0 ? <p className="typing muted">typing</p> : null}
<form
className="composer"
onSubmit={(event) => {
@ -44,6 +126,7 @@ export const ChatView = ({ roomId, me }: Props): ReactElement => {
placeholder="Write a message…"
onChange={(event) => {
setText(event.target.value);
notifyTyping();
}}
/>
<button type="submit">Send</button>

View file

@ -1,3 +0,0 @@
export { useRooms } from './model';
export type { UseRooms } from './model';
export { RoomSidebar } from './ui/RoomSidebar';

View file

@ -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<Room>;
reload: () => Promise<void>;
}
export const useRooms = (userId: string): UseRooms => {
const { subscribe } = useRealtime();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async (): Promise<void> => {
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<Room> => {
const room = await apiCreateRoom(apiConfig, { name });
setRooms((prev) => [room, ...prev]);
return room;
}, []);
return { rooms, loading, error, createRoom, reload };
};

View file

@ -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<void>;
}
export const RoomSidebar = ({ rooms, currentRoomId, onSelect, onCreate }: Props): ReactElement => {
const [name, setName] = useState('');
const [memberName, setMemberName] = useState('');
const [error, setError] = useState<string | null>(null);
const submitCreate = async (event: SyntheticEvent): Promise<void> => {
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<void> => {
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 (
<aside className="sidebar">
<form
className="sidebar-form"
onSubmit={(event) => {
void submitCreate(event);
}}
>
<input
placeholder="new room name"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
<button type="submit">Create</button>
</form>
<ul className="room-list">
{rooms.map((room) => (
<li key={room.id}>
<button
type="button"
aria-pressed={room.id === currentRoomId}
onClick={() => {
onSelect(room.id);
}}
>
{room.name}
</button>
</li>
))}
</ul>
{currentRoomId !== null ? (
<form
className="sidebar-form"
onSubmit={(event) => {
void submitAddMember(event);
}}
>
<input
placeholder="add member username"
value={memberName}
onChange={(event) => {
setMemberName(event.target.value);
}}
/>
<button type="submit">Add</button>
</form>
) : null}
{error !== null ? <p className="auth-error">{error}</p> : null}
</aside>
);
};