simple chat init

This commit is contained in:
Заид Омар Медхат | Zaid Omar Medhat 2026-07-10 15:32:34 +05:00
parent 96d496bf26
commit e7f37be62c
43 changed files with 1601 additions and 108 deletions

View file

@ -0,0 +1,52 @@
// Phase 3 — rooms, membership, and messages.
// Messages use a crypto-agnostic content envelope so E2EE can be layered on
// later with no schema change (content holds plaintext now, ciphertext later).
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
pgm.createTable('rooms', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
name: { type: 'text', notNull: true },
created_by: { type: 'uuid', notNull: true, references: 'users', onDelete: 'RESTRICT' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.createTable('room_members', {
room_id: { type: 'uuid', notNull: true, references: 'rooms', onDelete: 'CASCADE' },
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
role: { type: 'text', notNull: true, default: 'member' },
joined_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.addConstraint('room_members', 'room_members_pkey', {
primaryKey: ['room_id', 'user_id'],
});
pgm.createIndex('room_members', 'user_id');
pgm.createTable('messages', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
// Server-assigned monotonic ordering.
seq: { type: 'bigserial', notNull: true },
room_id: { type: 'uuid', notNull: true, references: 'rooms', onDelete: 'CASCADE' },
sender_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'RESTRICT' },
// Client-generated idempotency / dedupe key.
client_msg_id: { type: 'text', notNull: true },
// Opaque content envelope (see file header).
content: { type: 'text', notNull: true },
content_type: { type: 'text', notNull: true, default: 'text' },
encryption: { type: 'text' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.createIndex('messages', 'seq', { unique: true });
pgm.createIndex('messages', ['room_id', 'seq']);
// Idempotent sends: one message per (room, sender, client_msg_id).
pgm.addConstraint('messages', 'messages_dedupe_uq', {
unique: ['room_id', 'sender_id', 'client_msg_id'],
});
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
pgm.dropTable('messages');
pgm.dropTable('room_members');
pgm.dropTable('rooms');
};

View file

@ -15,7 +15,10 @@ 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 { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages';
import { realtimeRoutes } from './modules/realtime';
import type { Publisher } from './shared/publisher';
declare module 'fastify' {
interface FastifyInstance {
@ -70,8 +73,12 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
await app.register(swaggerPlugin);
// Modules: build repositories + services now that `db` is available, decorate.
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 messagesRepository = createMessagesRepository(app.db);
app.decorate('usersService', createUsersService(usersRepository));
app.decorate(
'authService',
@ -87,6 +94,14 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
},
}),
);
app.decorate(
'roomsService',
createRoomsService({ rooms: roomsRepository, users: usersRepository, publish }),
);
app.decorate(
'messagesService',
createMessagesService({ messages: messagesRepository, rooms: roomsRepository, publish }),
);
// Auth preHandler decorator must exist before routes that use it register.
await app.register(authPlugin);
@ -95,6 +110,8 @@ 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(messagesRoutes);
await app.register(realtimeRoutes);
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);

View file

@ -28,7 +28,37 @@ export interface RefreshTokensTable {
revoked_at: Date | null;
}
export interface RoomsTable {
id: Generated<string>;
name: string;
created_by: string;
created_at: Generated<Date>;
}
export interface RoomMembersTable {
room_id: string;
user_id: string;
role: Generated<string>;
joined_at: Generated<Date>;
}
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;
sender_id: string;
client_msg_id: string;
content: string;
content_type: Generated<string>;
encryption: string | null;
created_at: Generated<Date>;
}
export interface Database {
users: UsersTable;
refresh_tokens: RefreshTokensTable;
rooms: RoomsTable;
room_members: RoomMembersTable;
messages: MessagesTable;
}

View file

@ -0,0 +1,6 @@
export { createMessagesRepository } from './messages.repository';
export type { MessagesRepository, MessageWithSenderRow, NewMessage } from './messages.repository';
export { createMessagesService } from './messages.service';
export type { MessagesService, MessagesServiceDeps, SentMessage } from './messages.service';
export { toMessage } from './messages.mapper';
export { messagesRoutes } from './messages.routes';

View file

@ -0,0 +1,20 @@
import type { Message } from '@altricade/core';
import type { MessageWithSenderRow } from './messages.repository';
export const toMessage = (row: MessageWithSenderRow): Message => ({
id: row.id,
roomId: row.room_id,
senderId: row.sender_id,
sender: {
id: row.sender_id,
username: row.sender_username,
displayName: row.sender_display_name,
avatarUrl: row.sender_avatar_ref,
},
content: row.content,
contentType: row.content_type,
encryption: row.encryption,
clientMsgId: row.client_msg_id,
seq: row.seq,
createdAt: row.created_at.toISOString(),
});

View file

@ -0,0 +1,100 @@
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
export interface MessageWithSenderRow {
id: string;
seq: number;
room_id: string;
sender_id: string;
client_msg_id: string;
content: string;
content_type: string;
encryption: string | null;
created_at: Date;
sender_username: string;
sender_display_name: string;
sender_avatar_ref: string | null;
}
export interface NewMessage {
roomId: string;
senderId: string;
clientMsgId: string;
content: string;
contentType: string;
encryption: string | null;
}
export interface MessagesRepository {
insert(input: NewMessage): Promise<{ id: string } | undefined>;
findIdByDedupe(
roomId: string,
senderId: string,
clientMsgId: string,
): Promise<string | undefined>;
getWithSenderById(id: string): Promise<MessageWithSenderRow | undefined>;
listHistory(
roomId: string,
beforeSeq: number | null,
limit: number,
): Promise<MessageWithSenderRow[]>;
}
export const createMessagesRepository = (db: Kysely<Database>): MessagesRepository => {
const withSender = () =>
db
.selectFrom('messages')
.innerJoin('users', 'users.id', 'messages.sender_id')
.select([
'messages.id as id',
'messages.seq as seq',
'messages.room_id as room_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',
'users.username as sender_username',
'users.display_name as sender_display_name',
'users.avatar_ref as sender_avatar_ref',
]);
return {
insert: (input) =>
db
.insertInto('messages')
.values({
room_id: input.roomId,
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())
.returning('id')
.executeTakeFirst(),
findIdByDedupe: async (roomId, senderId, clientMsgId) => {
const row = await db
.selectFrom('messages')
.select('id')
.where('room_id', '=', roomId)
.where('sender_id', '=', senderId)
.where('client_msg_id', '=', clientMsgId)
.executeTakeFirst();
return row?.id;
},
getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(),
listHistory: (roomId, beforeSeq, limit) => {
let query = withSender().where('messages.room_id', '=', roomId);
if (beforeSeq !== null) {
query = query.where('messages.seq', '<', beforeSeq);
}
return query.orderBy('messages.seq', 'desc').limit(limit).execute();
},
};
};

View file

@ -0,0 +1,69 @@
import type { FastifyInstance } from 'fastify';
import { sendMessageBodySchema, messageSchema, messageListSchema, errorSchema } from '@altricade/core';
import type { SendMessageBody } from '@altricade/core';
const bearerAuth = [{ bearerAuth: [] }];
const roomParamsSchema = {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
} as const;
const historyQuerySchema = {
type: 'object',
properties: {
before: { type: 'integer', minimum: 1 },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
},
} as const;
export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
app.get<{ Params: { id: string }; Querystring: { before?: number; limit: number } }>(
'/rooms/:id/messages',
{
schema: {
tags: ['messages'],
summary: 'Load room message history (newest first)',
security: bearerAuth,
params: roomParamsSchema,
querystring: historyQuerySchema,
response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
const before = request.query.before ?? null;
const history = await app.messagesService.history(
request.params.id,
user.id,
before,
request.query.limit,
);
return reply.send(history);
},
);
app.post<{ Params: { id: string }; Body: SendMessageBody }>(
'/rooms/:id/messages',
{
schema: {
tags: ['messages'],
summary: 'Send a message to a room',
security: bearerAuth,
params: roomParamsSchema,
body: sendMessageBodySchema,
response: { 200: messageSchema, 201: messageSchema, 401: errorSchema, 403: errorSchema },
},
preHandler: app.authenticate,
},
async (request, reply) => {
const user = request.authUser;
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
const result = await app.messagesService.send(request.params.id, user.id, request.body);
return reply.code(result.created ? 201 : 200).send(result.message);
},
);
return Promise.resolve();
};

View file

@ -0,0 +1,81 @@
import type { Message, MessageNewEvent, SendMessageBody } from '@altricade/core';
import { roomChannel } from '@altricade/core';
import { HttpError } from '../../shared/http-error';
import type { Publisher } from '../../shared/publisher';
import type { RoomsRepository } from '../rooms';
import type { MessagesRepository } from './messages.repository';
import { toMessage } from './messages.mapper';
export interface MessagesServiceDeps {
messages: MessagesRepository;
rooms: RoomsRepository;
publish: Publisher;
}
export interface SentMessage {
message: Message;
created: boolean;
}
export interface MessagesService {
send(roomId: string, senderId: string, input: SendMessageBody): Promise<SentMessage>;
history(
roomId: string,
userId: string,
beforeSeq: number | null,
limit: number,
): Promise<Message[]>;
}
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
const { messages, rooms, 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');
}
};
return {
send: async (roomId, senderId, input) => {
await assertMember(roomId, senderId);
const inserted = await messages.insert({
roomId,
senderId,
clientMsgId: input.clientMsgId,
content: input.content,
contentType: input.contentType ?? 'text',
encryption: input.encryption ?? null,
});
const id = inserted?.id ?? (await messages.findIdByDedupe(roomId, senderId, input.clientMsgId));
if (id === undefined) {
throw new HttpError(500, 'internal_error', 'Message could not be persisted');
}
const row = await messages.getWithSenderById(id);
if (row === undefined) {
throw new HttpError(500, 'internal_error', 'Message not found after insert');
}
const message = toMessage(row);
// Publish only for a genuinely new message (not an idempotent replay).
if (inserted !== undefined) {
const event: MessageNewEvent = { type: 'message.new', message };
await publish(roomChannel(roomId), 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);
},
};
};
declare module 'fastify' {
interface FastifyInstance {
messagesService: MessagesService;
}
}

View file

@ -1,3 +1,3 @@
export { authorizeSubscription } from './realtime.service';
export type { SubscribeDecision } from './realtime.service';
export type { SubscribeDecision, SubscribeDeps } from './realtime.service';
export { realtimeRoutes } from './realtime.routes';

View file

@ -27,9 +27,13 @@ export const realtimeRoutes = (app: FastifyInstance): Promise<void> => {
app.post<{ Body: { user?: string; channel: string } }>(
'/centrifugo/subscribe',
{ schema: { body: subscribeProxyBodySchema } },
(request, reply) => {
async (request, reply) => {
const { user, channel } = request.body;
const decision = authorizeSubscription(user ?? '', channel);
const decision = await authorizeSubscription(
{ isRoomMember: (roomId, userId) => app.roomsService.isMember(roomId, userId) },
user ?? '',
channel,
);
if (!decision.allowed) {
return reply.send({ error: { code: decision.errorCode, message: decision.errorMessage } });
}

View file

@ -1,21 +1,38 @@
import { isUserChannel, isRoomChannel, userChannel } from '@altricade/core';
import { isUserChannel, roomChannelId, 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>;
}
const deny = (errorMessage: string): SubscribeDecision => ({
allowed: false,
errorCode: 103,
errorMessage,
});
// Subscription authorization for the Centrifugo subscribe-proxy.
// user:<id> → allowed only for that user's own personal channel.
// room:<id> → denied for now; DB membership check lands in Phase 3.
export const authorizeSubscription = (userId: string, channel: string): SubscribeDecision => {
// user:<id> → only that user's own personal channel.
// room:<id> → only current members (live DB check → kicked users lose access).
export const authorizeSubscription = async (
deps: SubscribeDeps,
userId: string,
channel: string,
): Promise<SubscribeDecision> => {
if (isUserChannel(channel)) {
if (channel === userChannel(userId)) {
return { allowed: true };
return channel === userChannel(userId) ? { allowed: true } : deny('permission denied');
}
const roomId = roomChannelId(channel);
if (roomId !== null) {
if (userId === '') {
return deny('permission denied');
}
return { allowed: false, errorCode: 103, errorMessage: 'permission denied' };
return (await deps.isRoomMember(roomId, userId)) ? { allowed: true } : deny('not a member');
}
if (isRoomChannel(channel)) {
return { allowed: false, errorCode: 103, errorMessage: 'room membership not available yet' };
}
return { allowed: false, errorCode: 103, errorMessage: 'unknown channel' };
return deny('unknown channel');
};

View file

@ -0,0 +1,6 @@
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

@ -0,0 +1,21 @@
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

@ -0,0 +1,103 @@
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

@ -0,0 +1,144 @@
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

@ -0,0 +1,101 @@
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

@ -3,6 +3,10 @@ import pg from 'pg';
import { Kysely, PostgresDialect } from 'kysely';
import type { Database } from '../db/schema';
// Parse int8 (OID 20) as a JS number. Safe well past any realistic message
// count (Number.MAX_SAFE_INTEGER ≈ 9e15); revisit if a counter could exceed it.
pg.types.setTypeParser(20, (value) => Number(value));
declare module 'fastify' {
interface FastifyInstance {
db: Kysely<Database>;

View file

@ -0,0 +1,3 @@
// Injected into services that need to publish realtime events, so they depend on
// a function rather than reaching into the Centrifugo plugin decoration directly.
export type Publisher = (channel: string, data: unknown) => Promise<void>;

View file

@ -9,6 +9,7 @@
"./events": "./src/events/index.ts",
"./types": "./src/types/index.ts",
"./schemas": "./src/schemas/index.ts",
"./messages": "./src/messages/index.ts",
"./api": "./src/api/index.ts",
"./realtime": "./src/realtime/index.ts"
},

View file

@ -13,3 +13,6 @@ export {
getCentrifugoToken,
} from './auth';
export { sendEcho } from './realtime';
export { createRoom, listRooms, getRoom, listMembers, addMember, removeMember } from './rooms';
export { sendMessage, getHistory } from './messages';
export type { HistoryOptions } from './messages';

View file

@ -0,0 +1,37 @@
import { messageSchema, messageListSchema } from '../schemas/index';
import type { SendMessageBody } from '../schemas/index';
import type { Message } from '../types/index';
import { compileValidator, parse, requestJson } from './http';
import type { ApiClientConfig } from './http';
const messageV = compileValidator<Message>(messageSchema);
const messageListV = compileValidator<Message[]>(messageListSchema);
export const sendMessage = async (
config: ApiClientConfig,
roomId: string,
body: SendMessageBody,
): Promise<Message> =>
parse(messageV, await requestJson(config, 'POST', `/rooms/${roomId}/messages`, body));
export interface HistoryOptions {
before?: number;
limit?: number;
}
export const getHistory = async (
config: ApiClientConfig,
roomId: string,
options: HistoryOptions = {},
): Promise<Message[]> => {
const params = new URLSearchParams();
if (options.before !== undefined) {
params.set('before', String(options.before));
}
if (options.limit !== undefined) {
params.set('limit', String(options.limit));
}
const qs = params.toString();
const path = qs.length > 0 ? `/rooms/${roomId}/messages?${qs}` : `/rooms/${roomId}/messages`;
return parse(messageListV, await requestJson(config, 'GET', path));
};

View file

@ -0,0 +1,45 @@
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

@ -19,3 +19,11 @@ export const isRoomChannel = (channel: string): boolean => channel.startsWith(RO
/** 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 user id from a `user:<id>` channel, or null. */
export const userChannelId = (channel: string): string | null =>
channel.startsWith(USER_PREFIX) ? channel.slice(USER_PREFIX.length) : null;

View file

@ -6,6 +6,8 @@
// Bucket B — ephemeral, throwaway → published, not persisted
// Bucket C — connection-derived → Centrifugo built-in presence
import type { Message } from '../types/message';
export const EventType = {
// Bucket A
MessageNew: 'message.new',
@ -29,3 +31,19 @@ export const EventType = {
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
// --- Realtime event payloads (published by the backend, handled by clients) ---
export interface MessageNewEvent {
type: 'message.new';
message: Message;
}
export type RoomMembershipAction = 'added' | 'removed';
export interface RoomMembershipEvent {
type: 'room.membership';
action: RoomMembershipAction;
roomId: string;
userId: string;
}

View file

@ -3,3 +3,4 @@ export * from './channels/index';
export * from './events/index';
export * from './types/index';
export * from './schemas/index';
export * from './messages/index';

View file

@ -0,0 +1,39 @@
// Client-side message dedupe + ordering. Messages arrive both live (over the
// socket) and via history load (REST), and can overlap; clients also render
// optimistic sends before the server confirms them. This merges all of that
// into one ordered, duplicate-free list.
export interface OrderedMessage {
id: string;
clientMsgId: string;
seq: number;
}
// Sentinel seq for an optimistic (not-yet-confirmed) message so it sorts last
// until the server's confirmation (with a real seq) replaces it.
export const OPTIMISTIC_SEQ = Number.MAX_SAFE_INTEGER;
export const mergeMessages = <T extends OrderedMessage>(
current: readonly T[],
incoming: readonly T[],
): T[] => {
// Any optimistic entry whose clientMsgId is confirmed in `incoming` is dropped
// in favor of the confirmed copy.
const confirmedClientIds = new Set(
incoming.map((message) => message.clientMsgId).filter((id) => id.length > 0),
);
const byId = new Map<string, T>();
for (const message of current) {
if (message.clientMsgId.length > 0 && confirmedClientIds.has(message.clientMsgId)) {
continue;
}
byId.set(message.id, message);
}
// Incoming wins on id collisions (history/live overlap).
for (const message of incoming) {
byId.set(message.id, message);
}
return [...byId.values()].sort((a, b) => a.seq - b.seq);
};

View file

@ -89,3 +89,62 @@ export const errorSchema = {
message: { type: 'string' },
},
} as const;
export const roomSchema = {
type: 'object',
additionalProperties: false,
required: ['id', 'name', 'createdBy', 'createdAt'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
createdBy: { type: 'string', format: 'uuid' },
createdAt: { type: 'string', format: 'date-time' },
},
} as const;
export const roomListSchema = { type: 'array', items: roomSchema } as const;
export const roomMemberSchema = {
type: 'object',
additionalProperties: false,
required: ['userId', 'role', 'joinedAt', 'user'],
properties: {
userId: { type: 'string', format: 'uuid' },
role: { type: 'string' },
joinedAt: { type: 'string', format: 'date-time' },
user: publicUserSchema,
},
} as const;
export const roomMemberListSchema = { type: 'array', items: roomMemberSchema } as const;
export const messageSchema = {
type: 'object',
additionalProperties: false,
required: [
'id',
'roomId',
'senderId',
'sender',
'content',
'contentType',
'encryption',
'clientMsgId',
'seq',
'createdAt',
],
properties: {
id: { type: 'string', format: 'uuid' },
roomId: { type: 'string', format: 'uuid' },
senderId: { type: 'string', format: 'uuid' },
sender: publicUserSchema,
content: { type: 'string' },
contentType: { type: 'string' },
encryption: { type: ['string', 'null'] },
clientMsgId: { type: 'string' },
seq: { type: 'integer' },
createdAt: { type: 'string', format: 'date-time' },
},
} as const;
export const messageListSchema = { type: 'array', items: messageSchema } as const;

View file

@ -1,5 +1,7 @@
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 {
publicUserSchema,
userSchema,
@ -8,4 +10,10 @@ export {
sessionListSchema,
centrifugoTokenSchema,
errorSchema,
roomSchema,
roomListSchema,
roomMemberSchema,
roomMemberListSchema,
messageSchema,
messageListSchema,
} from './entities';

View file

@ -0,0 +1,36 @@
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

@ -3,4 +3,5 @@ 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';

View file

@ -1,9 +1,17 @@
// Placeholder message shape — the full model (server-assigned seq, dedupe id,
// media refs, edits, reactions) is defined in Phases 35.
import type { PublicUser } from './user';
// 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.
export interface Message {
id: string;
conversationId: string;
roomId: string;
senderId: string;
body: string;
sender: PublicUser;
content: string;
contentType: string;
encryption: string | null;
clientMsgId: string;
seq: number;
createdAt: string;
}

View file

@ -0,0 +1,15 @@
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,8 +1,11 @@
import { useState } from 'react';
import type { ReactElement } from 'react';
import type { User } from '@altricade/core';
import type { PublicUser, User } from '@altricade/core';
import { SessionProvider, useSession } from '../entities/session';
import { AuthForm } from '../features/auth';
import { RealtimeProvider, ConnectionPanel } from '../features/realtime';
import { RealtimeProvider, useRealtime } from '../features/realtime';
import { useRooms, RoomSidebar } from '../features/rooms';
import { ChatView } from '../features/messaging';
import { useTheme } from '../shared/theme';
import type { ThemePreference } from '../shared/theme';
@ -28,21 +31,51 @@ const ThemeSwitch = (): ReactElement => {
);
};
const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => (
<main className="app">
<h1>Altricade</h1>
<p>
Signed in as <strong>@{user.username}</strong> ({user.displayName})
</p>
<ConnectionPanel />
<div className="actions">
<button type="button" onClick={onLogout}>
Log out
</button>
const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => {
const me: PublicUser = {
id: user.id,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
};
const { state } = useRealtime();
const { rooms, createRoom } = useRooms(user.id);
const [currentRoomId, setCurrentRoomId] = useState<string | null>(null);
return (
<div className="layout">
<header className="topbar">
<span>
<strong>@{user.username}</strong> · socket: {state}
</span>
<span className="topbar-actions">
<ThemeSwitch />
<button type="button" onClick={onLogout}>
Log out
</button>
</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} />
) : (
<section className="chat chat-empty">
<p>Select a room, or create one to start chatting.</p>
</section>
)}
</div>
</div>
<ThemeSwitch />
</main>
);
);
};
const Shell = (): ReactElement => {
const { status, user, logout } = useSession();

View file

@ -144,3 +144,137 @@ body {
.actions {
margin: 1rem 0;
}
/* Chat layout */
.layout {
display: flex;
flex-direction: column;
height: 100vh;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.workspace {
display: flex;
flex: 1;
min-height: 0;
}
.sidebar {
width: 260px;
border-right: 1px solid var(--color-border);
padding: 1rem;
overflow-y: auto;
}
.sidebar-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.sidebar-form input {
flex: 1;
min-width: 0;
padding: 0.4rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-background);
color: var(--color-text);
}
.room-list {
list-style: none;
padding: 0;
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.room-list button {
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
border-radius: 8px;
border: 1px solid transparent;
background: none;
color: var(--color-text);
cursor: pointer;
}
.room-list button[aria-pressed='true'] {
background: var(--color-surface);
border-color: var(--color-accent);
color: var(--color-accent);
}
.chat {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 1rem;
}
.chat-empty {
align-items: center;
justify-content: center;
color: var(--color-textMuted);
}
.message-list {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.message-list li {
padding: 0.4rem 0.6rem;
border-radius: 8px;
background: var(--color-surface);
}
.message-list li.mine {
align-self: flex-end;
background: var(--color-accent);
color: #fff;
}
.msg-author {
font-weight: 600;
margin-right: 0.5rem;
}
.composer {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.composer input {
flex: 1;
padding: 0.6rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
}

View file

@ -0,0 +1,3 @@
export { useRoomMessages } from './model';
export type { UseRoomMessages } from './model';
export { ChatView } from './ui/ChatView';

View file

@ -0,0 +1,86 @@
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 { apiConfig } from '../../shared/api';
import { useRealtime } from '../realtime';
export interface UseRoomMessages {
messages: Message[];
send: (content: string) => Promise<void>;
loading: boolean;
}
const isMessageNew = (data: unknown): data is MessageNewEvent => {
if (typeof data !== 'object' || data === null) {
return false;
}
if (!('type' in data) || data.type !== 'message.new') {
return false;
}
return 'message' in data;
};
export const useRoomMessages = (roomId: string, me: PublicUser): UseRoomMessages => {
const { subscribe } = useRealtime();
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
// 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);
const unsubscribe = subscribe(roomChannel(roomId), (event) => {
if (isMessageNew(event.data)) {
const incoming = event.data.message;
setMessages((prev) => mergeMessages(prev, [incoming]));
}
});
const load = async (): Promise<void> => {
try {
const history = await getHistory(apiConfig, roomId, { limit: 50 });
if (!cancelled) {
setMessages((prev) => mergeMessages(prev, history));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
unsubscribe();
};
}, [roomId, subscribe]);
const send = useCallback(
async (content: string): Promise<void> => {
const clientMsgId = crypto.randomUUID();
const optimistic: Message = {
id: `optimistic:${clientMsgId}`,
roomId,
senderId: me.id,
sender: me,
content,
contentType: 'text',
encryption: null,
clientMsgId,
seq: OPTIMISTIC_SEQ,
createdAt: new Date().toISOString(),
};
setMessages((prev) => mergeMessages(prev, [optimistic]));
const confirmed = await sendMessage(apiConfig, roomId, { content, clientMsgId });
setMessages((prev) => mergeMessages(prev, [confirmed]));
},
[roomId, me],
);
return { messages, send, loading };
};

View file

@ -0,0 +1,53 @@
import { useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react';
import type { PublicUser } from '@altricade/core';
import { useRoomMessages } from '../model';
interface Props {
roomId: string;
me: PublicUser;
}
export const ChatView = ({ roomId, me }: Props): ReactElement => {
const { messages, send, loading } = useRoomMessages(roomId, me);
const [text, setText] = useState('');
const submit = async (event: SyntheticEvent): Promise<void> => {
event.preventDefault();
const trimmed = text.trim();
if (trimmed === '') {
return;
}
setText('');
await send(trimmed);
};
return (
<section className="chat">
{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>
</li>
))}
</ul>
<form
className="composer"
onSubmit={(event) => {
void submit(event);
}}
>
<input
value={text}
placeholder="Write a message…"
onChange={(event) => {
setText(event.target.value);
}}
/>
<button type="submit">Send</button>
</form>
</section>
);
};

View file

@ -1,3 +1,2 @@
export { RealtimeProvider, useRealtime } from './model';
export type { RealtimeContextValue } from './model';
export { ConnectionPanel } from './ui/ConnectionPanel';

View file

@ -1,22 +1,22 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { RealtimeClient } from '@altricade/core/realtime';
import type { ConnectionState, RealtimeEvent } from '@altricade/core/realtime';
import { getCentrifugoToken } from '@altricade/core/api';
import { userChannel } from '@altricade/core';
import { apiConfig } from '../../shared/api';
import { env } from '../../shared/config';
import { useSession } from '../../entities/session';
type EventHandler = (event: RealtimeEvent) => void;
export interface RealtimeContextValue {
state: ConnectionState;
events: RealtimeEvent[];
channel: string | null;
/** Subscribe to a channel; returns an unsubscribe cleanup. */
subscribe: (channel: string, handler: EventHandler) => () => void;
}
const RealtimeContext = createContext<RealtimeContextValue | null>(null);
// Turn a same-origin path into an absolute ws/wss URL (Centrifugo needs a full URL).
const resolveWsUrl = (raw: string): string => {
if (raw.startsWith('ws://') || raw.startsWith('wss://')) {
return raw;
@ -26,15 +26,13 @@ const resolveWsUrl = (raw: string): string => {
};
export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactElement => {
const { status, user } = useSession();
const { status } = useSession();
const [state, setState] = useState<ConnectionState>('disconnected');
const [events, setEvents] = useState<RealtimeEvent[]>([]);
const userId = user?.id ?? null;
const channel = userId === null ? null : userChannel(userId);
const clientRef = useRef<RealtimeClient | null>(null);
const handlersRef = useRef<Map<string, Set<EventHandler>>>(new Map());
useEffect(() => {
if (status !== 'authenticated' || userId === null) {
if (status !== 'authenticated') {
return undefined;
}
const client = new RealtimeClient({
@ -42,20 +40,48 @@ export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactEl
getToken: async () => (await getCentrifugoToken(apiConfig)).token,
onState: setState,
onEvent: (event) => {
setEvents((prev) => [...prev, event]);
const handlers = handlersRef.current.get(event.channel);
if (handlers !== undefined) {
for (const handler of handlers) {
handler(event);
}
}
},
});
clientRef.current = client;
client.connect();
client.subscribe(userChannel(userId));
for (const channel of handlersRef.current.keys()) {
client.subscribe(channel);
}
return () => {
client.disconnect();
clientRef.current = null;
};
}, [status, userId]);
}, [status]);
const value = useMemo<RealtimeContextValue>(
() => ({ state, events, channel }),
[state, events, channel],
);
const subscribe = useCallback((channel: string, handler: EventHandler): (() => void) => {
let handlers = handlersRef.current.get(channel);
if (handlers === undefined) {
handlers = new Set();
handlersRef.current.set(channel, handlers);
}
handlers.add(handler);
clientRef.current?.subscribe(channel);
return () => {
const current = handlersRef.current.get(channel);
if (current === undefined) {
return;
}
current.delete(handler);
if (current.size === 0) {
handlersRef.current.delete(channel);
clientRef.current?.unsubscribe(channel);
}
};
}, []);
const value = useMemo<RealtimeContextValue>(() => ({ state, subscribe }), [state, subscribe]);
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
};

View file

@ -1,55 +0,0 @@
import { useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react';
import { sendEcho, ApiError } from '@altricade/core/api';
import { apiConfig } from '../../../shared/api';
import { useRealtime } from '../model';
export const ConnectionPanel = (): ReactElement => {
const { state, events, channel } = useRealtime();
const [text, setText] = useState('hello');
const [error, setError] = useState<string | null>(null);
const echo = async (event: SyntheticEvent): Promise<void> => {
event.preventDefault();
setError(null);
try {
await sendEcho(apiConfig, text);
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : 'Echo failed');
}
};
return (
<section className="realtime">
<dl>
<dt>socket</dt>
<dd>{state}</dd>
<dt>channel</dt>
<dd>{channel ?? '—'}</dd>
</dl>
<form
className="echo-form"
onSubmit={(event) => {
void echo(event);
}}
>
<input
value={text}
onChange={(event) => {
setText(event.target.value);
}}
/>
<button type="submit" disabled={state !== 'connected'}>
Send echo
</button>
</form>
{error !== null ? <p className="auth-error">{error}</p> : null}
<h3>Events on {channel ?? 'your channel'}</h3>
<ul className="event-log">
{events.map((event, index) => (
<li key={index}>{JSON.stringify(event.data)}</li>
))}
</ul>
</section>
);
};

View file

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

View file

@ -0,0 +1,50 @@
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

@ -0,0 +1,104 @@
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>
);
};