simple chat init
This commit is contained in:
parent
96d496bf26
commit
e7f37be62c
43 changed files with 1601 additions and 108 deletions
52
packages/backend/migrations/1720000000002_rooms_messages.cjs
Normal file
52
packages/backend/migrations/1720000000002_rooms_messages.cjs
Normal 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');
|
||||
};
|
||||
|
|
@ -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')}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
6
packages/backend/src/modules/messages/index.ts
Normal file
6
packages/backend/src/modules/messages/index.ts
Normal 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';
|
||||
20
packages/backend/src/modules/messages/messages.mapper.ts
Normal file
20
packages/backend/src/modules/messages/messages.mapper.ts
Normal 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(),
|
||||
});
|
||||
100
packages/backend/src/modules/messages/messages.repository.ts
Normal file
100
packages/backend/src/modules/messages/messages.repository.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
};
|
||||
69
packages/backend/src/modules/messages/messages.routes.ts
Normal file
69
packages/backend/src/modules/messages/messages.routes.ts
Normal 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();
|
||||
};
|
||||
81
packages/backend/src/modules/messages/messages.service.ts
Normal file
81
packages/backend/src/modules/messages/messages.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
|
|
|||
6
packages/backend/src/modules/rooms/index.ts
Normal file
6
packages/backend/src/modules/rooms/index.ts
Normal 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';
|
||||
21
packages/backend/src/modules/rooms/rooms.mapper.ts
Normal file
21
packages/backend/src/modules/rooms/rooms.mapper.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
103
packages/backend/src/modules/rooms/rooms.repository.ts
Normal file
103
packages/backend/src/modules/rooms/rooms.repository.ts
Normal 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(),
|
||||
});
|
||||
144
packages/backend/src/modules/rooms/rooms.routes.ts
Normal file
144
packages/backend/src/modules/rooms/rooms.routes.ts
Normal 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();
|
||||
};
|
||||
101
packages/backend/src/modules/rooms/rooms.service.ts
Normal file
101
packages/backend/src/modules/rooms/rooms.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
3
packages/backend/src/shared/publisher.ts
Normal file
3
packages/backend/src/shared/publisher.ts
Normal 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>;
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
37
packages/core/src/api/messages.ts
Normal file
37
packages/core/src/api/messages.ts
Normal 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));
|
||||
};
|
||||
45
packages/core/src/api/rooms.ts
Normal file
45
packages/core/src/api/rooms.ts
Normal 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}`);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ export * from './channels/index';
|
|||
export * from './events/index';
|
||||
export * from './types/index';
|
||||
export * from './schemas/index';
|
||||
export * from './messages/index';
|
||||
|
|
|
|||
39
packages/core/src/messages/index.ts
Normal file
39
packages/core/src/messages/index.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
36
packages/core/src/schemas/room.ts
Normal file
36
packages/core/src/schemas/room.ts
Normal 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>;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
// Placeholder message shape — the full model (server-assigned seq, dedupe id,
|
||||
// media refs, edits, reactions) is defined in Phases 3–5.
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
15
packages/core/src/types/room.ts
Normal file
15
packages/core/src/types/room.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
3
packages/web/src/features/messaging/index.ts
Normal file
3
packages/web/src/features/messaging/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useRoomMessages } from './model';
|
||||
export type { UseRoomMessages } from './model';
|
||||
export { ChatView } from './ui/ChatView';
|
||||
86
packages/web/src/features/messaging/model.ts
Normal file
86
packages/web/src/features/messaging/model.ts
Normal 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 };
|
||||
};
|
||||
53
packages/web/src/features/messaging/ui/ChatView.tsx
Normal file
53
packages/web/src/features/messaging/ui/ChatView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export { RealtimeProvider, useRealtime } from './model';
|
||||
export type { RealtimeContextValue } from './model';
|
||||
export { ConnectionPanel } from './ui/ConnectionPanel';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
3
packages/web/src/features/rooms/index.ts
Normal file
3
packages/web/src/features/rooms/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useRooms } from './model';
|
||||
export type { UseRooms } from './model';
|
||||
export { RoomSidebar } from './ui/RoomSidebar';
|
||||
50
packages/web/src/features/rooms/model.ts
Normal file
50
packages/web/src/features/rooms/model.ts
Normal 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 };
|
||||
};
|
||||
104
packages/web/src/features/rooms/ui/RoomSidebar.tsx
Normal file
104
packages/web/src/features/rooms/ui/RoomSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue