init presence
This commit is contained in:
parent
e7f37be62c
commit
71e018d802
70 changed files with 3179 additions and 794 deletions
10
README.md
10
README.md
|
|
@ -70,9 +70,13 @@ Open the Swagger UI, then:
|
|||
2. Click **Authorize** (top right), paste the access token, and authorize.
|
||||
3. Protected endpoints (`/me`, `/auth/sessions`, `/auth/centrifugo-token`, …) now work from "Try it out".
|
||||
|
||||
> Dev note: the backend runs in Docker with `node_modules` baked into the image.
|
||||
> After changing backend dependencies, recreate the container so it picks them up:
|
||||
> `docker compose up -d --build --force-recreate backend`.
|
||||
> Dev notes:
|
||||
> - The backend runs in Docker with `node_modules` baked into the image. After
|
||||
> changing backend dependencies, recreate the container so it picks them up:
|
||||
> `docker compose up -d --build --force-recreate backend`.
|
||||
> - nginx resolves the `backend` upstream once at startup, so after recreating the
|
||||
> backend container, restart nginx too: `docker compose restart nginx`
|
||||
> (otherwise you'll get 502s from a stale cached IP).
|
||||
|
||||
## Quality gates (enforced mechanically — a violation fails the build)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"channel": {
|
||||
"namespaces": [
|
||||
{
|
||||
"name": "room",
|
||||
"name": "conv",
|
||||
"presence": true,
|
||||
"join_leave": true,
|
||||
"force_push_join_leave": true,
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
{
|
||||
"name": "user",
|
||||
"presence": true,
|
||||
"join_leave": true,
|
||||
"history_size": 100,
|
||||
"history_ttl": "300s",
|
||||
"force_recovery": true,
|
||||
|
|
|
|||
46
packages/backend/migrations/1720000000003_conversations.cjs
Normal file
46
packages/backend/migrations/1720000000003_conversations.cjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Phase 4 — reshape rooms into a unified "conversations" model (direct + group)
|
||||
// and add contacts. Renames preserve existing group data (existing rooms become
|
||||
// type='group').
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.up = (pgm) => {
|
||||
// rooms -> conversations
|
||||
pgm.renameTable('rooms', 'conversations');
|
||||
pgm.renameColumn('conversations', 'name', 'title');
|
||||
pgm.alterColumn('conversations', 'title', { notNull: false });
|
||||
pgm.addColumns('conversations', {
|
||||
type: { type: 'text', notNull: true, default: 'group' },
|
||||
// Canonical sorted user-pair for a direct conversation; unique so a 1:1 is
|
||||
// never duplicated. NULL for groups (Postgres allows many NULLs in UNIQUE).
|
||||
direct_key: { type: 'text' },
|
||||
last_message_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||
});
|
||||
pgm.addConstraint('conversations', 'conversations_direct_key_uq', { unique: ['direct_key'] });
|
||||
|
||||
// room_members -> conversation_members
|
||||
pgm.renameTable('room_members', 'conversation_members');
|
||||
pgm.renameColumn('conversation_members', 'room_id', 'conversation_id');
|
||||
|
||||
// messages.room_id -> conversation_id (indexes + dedupe constraint follow the rename)
|
||||
pgm.renameColumn('messages', 'room_id', 'conversation_id');
|
||||
|
||||
pgm.createTable('contacts', {
|
||||
owner_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
|
||||
contact_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
|
||||
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||
});
|
||||
pgm.addConstraint('contacts', 'contacts_pkey', { primaryKey: ['owner_id', 'contact_id'] });
|
||||
};
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropTable('contacts');
|
||||
pgm.renameColumn('messages', 'conversation_id', 'room_id');
|
||||
pgm.renameColumn('conversation_members', 'conversation_id', 'room_id');
|
||||
pgm.renameTable('conversation_members', 'room_members');
|
||||
pgm.dropConstraint('conversations', 'conversations_direct_key_uq');
|
||||
pgm.dropColumns('conversations', ['type', 'direct_key', 'last_message_at']);
|
||||
pgm.alterColumn('conversations', 'title', { notNull: true });
|
||||
pgm.renameColumn('conversations', 'title', 'name');
|
||||
pgm.renameTable('conversations', 'rooms');
|
||||
};
|
||||
42
packages/backend/migrations/1720000000004_live.cjs
Normal file
42
packages/backend/migrations/1720000000004_live.cjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Phase 5 — live features: message edits/deletes, reactions, read state.
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.up = (pgm) => {
|
||||
pgm.addColumns('messages', {
|
||||
edited_at: { type: 'timestamptz' },
|
||||
deleted_at: { type: 'timestamptz' },
|
||||
});
|
||||
|
||||
pgm.createTable('reactions', {
|
||||
message_id: { type: 'uuid', notNull: true, references: 'messages', onDelete: 'CASCADE' },
|
||||
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
|
||||
emoji: { type: 'text', notNull: true },
|
||||
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||
});
|
||||
pgm.addConstraint('reactions', 'reactions_pkey', {
|
||||
primaryKey: ['message_id', 'user_id', 'emoji'],
|
||||
});
|
||||
pgm.createIndex('reactions', 'message_id');
|
||||
|
||||
pgm.createTable('read_state', {
|
||||
conversation_id: {
|
||||
type: 'uuid',
|
||||
notNull: true,
|
||||
references: 'conversations',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' },
|
||||
last_read_seq: { type: 'bigint', notNull: true, default: 0 },
|
||||
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
|
||||
});
|
||||
pgm.addConstraint('read_state', 'read_state_pkey', {
|
||||
primaryKey: ['conversation_id', 'user_id'],
|
||||
});
|
||||
};
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropTable('read_state');
|
||||
pgm.dropTable('reactions');
|
||||
pgm.dropColumns('messages', ['edited_at', 'deleted_at']);
|
||||
};
|
||||
|
|
@ -2,7 +2,7 @@ import Fastify from 'fastify';
|
|||
import type { FastifyError, FastifyInstance } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import { roomChannel } from '@altricade/core';
|
||||
import { conversationChannel, userChannel } from '@altricade/core';
|
||||
import { loadConfig } from './config';
|
||||
import type { AppConfig } from './config';
|
||||
import { HttpError } from './shared/http-error';
|
||||
|
|
@ -15,8 +15,16 @@ import { swaggerPlugin } from './plugins/swagger';
|
|||
import { healthRoutes } from './routes/health';
|
||||
import { createUsersRepository, createUsersService, usersRoutes } from './modules/users';
|
||||
import { createRefreshTokensRepository, createAuthService, authPlugin, authRoutes } from './modules/auth';
|
||||
import { createRoomsRepository, createRoomsService, roomsRoutes } from './modules/rooms';
|
||||
import {
|
||||
createConversationsRepository,
|
||||
createConversationsService,
|
||||
createReadStateRepository,
|
||||
createDeliver,
|
||||
conversationsRoutes,
|
||||
} from './modules/conversations';
|
||||
import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages';
|
||||
import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts';
|
||||
import { createPresenceService, presenceRoutes } from './modules/presence';
|
||||
import { realtimeRoutes } from './modules/realtime';
|
||||
import type { Publisher } from './shared/publisher';
|
||||
|
||||
|
|
@ -76,8 +84,11 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
|||
const publish: Publisher = (channel, data) => app.centrifugo.publish(channel, data);
|
||||
const usersRepository = createUsersRepository(app.db);
|
||||
const refreshTokens = createRefreshTokensRepository(app.db);
|
||||
const roomsRepository = createRoomsRepository(app.db);
|
||||
const conversationsRepository = createConversationsRepository(app.db);
|
||||
const readStateRepository = createReadStateRepository(app.db);
|
||||
const messagesRepository = createMessagesRepository(app.db);
|
||||
const contactsRepository = createContactsRepository(app.db);
|
||||
const deliver = createDeliver(conversationsRepository, publish);
|
||||
|
||||
app.decorate('usersService', createUsersService(usersRepository));
|
||||
app.decorate(
|
||||
|
|
@ -95,12 +106,33 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
|||
}),
|
||||
);
|
||||
app.decorate(
|
||||
'roomsService',
|
||||
createRoomsService({ rooms: roomsRepository, users: usersRepository, publish }),
|
||||
'conversationsService',
|
||||
createConversationsService({
|
||||
conversations: conversationsRepository,
|
||||
users: usersRepository,
|
||||
readState: readStateRepository,
|
||||
deliver,
|
||||
publish,
|
||||
}),
|
||||
);
|
||||
app.decorate(
|
||||
'messagesService',
|
||||
createMessagesService({ messages: messagesRepository, rooms: roomsRepository, publish }),
|
||||
createMessagesService({
|
||||
messages: messagesRepository,
|
||||
conversations: conversationsRepository,
|
||||
deliver,
|
||||
}),
|
||||
);
|
||||
app.decorate(
|
||||
'contactsService',
|
||||
createContactsService({ contacts: contactsRepository, users: usersRepository }),
|
||||
);
|
||||
app.decorate(
|
||||
'presenceService',
|
||||
createPresenceService({
|
||||
users: usersRepository,
|
||||
isOnline: async (userId) => (await app.centrifugo.presenceStats(userChannel(userId))) > 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Auth preHandler decorator must exist before routes that use it register.
|
||||
|
|
@ -110,11 +142,13 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
|||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes, { prefix: '/auth' });
|
||||
await app.register(usersRoutes);
|
||||
await app.register(roomsRoutes);
|
||||
await app.register(conversationsRoutes);
|
||||
await app.register(messagesRoutes);
|
||||
await app.register(contactsRoutes);
|
||||
await app.register(presenceRoutes);
|
||||
await app.register(realtimeRoutes);
|
||||
|
||||
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);
|
||||
app.log.info(`core wired — example channel: ${conversationChannel('demo')}`);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,15 +28,18 @@ export interface RefreshTokensTable {
|
|||
revoked_at: Date | null;
|
||||
}
|
||||
|
||||
export interface RoomsTable {
|
||||
export interface ConversationsTable {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
type: Generated<string>;
|
||||
title: string | null;
|
||||
direct_key: string | null;
|
||||
created_by: string;
|
||||
created_at: Generated<Date>;
|
||||
last_message_at: Generated<Date>;
|
||||
}
|
||||
|
||||
export interface RoomMembersTable {
|
||||
room_id: string;
|
||||
export interface ConversationMembersTable {
|
||||
conversation_id: string;
|
||||
user_id: string;
|
||||
role: Generated<string>;
|
||||
joined_at: Generated<Date>;
|
||||
|
|
@ -46,19 +49,44 @@ export interface MessagesTable {
|
|||
id: Generated<string>;
|
||||
// int8 — parsed to a JS number by the pg type parser in the db plugin.
|
||||
seq: Generated<number>;
|
||||
room_id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string;
|
||||
client_msg_id: string;
|
||||
content: string;
|
||||
content_type: Generated<string>;
|
||||
encryption: string | null;
|
||||
created_at: Generated<Date>;
|
||||
edited_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
}
|
||||
|
||||
export interface ContactsTable {
|
||||
owner_id: string;
|
||||
contact_id: string;
|
||||
created_at: Generated<Date>;
|
||||
}
|
||||
|
||||
export interface ReactionsTable {
|
||||
message_id: string;
|
||||
user_id: string;
|
||||
emoji: string;
|
||||
created_at: Generated<Date>;
|
||||
}
|
||||
|
||||
export interface ReadStateTable {
|
||||
conversation_id: string;
|
||||
user_id: string;
|
||||
last_read_seq: Generated<number>;
|
||||
updated_at: ColumnType<Date, Date | undefined, Date | undefined>;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
users: UsersTable;
|
||||
refresh_tokens: RefreshTokensTable;
|
||||
rooms: RoomsTable;
|
||||
room_members: RoomMembersTable;
|
||||
conversations: ConversationsTable;
|
||||
conversation_members: ConversationMembersTable;
|
||||
messages: MessagesTable;
|
||||
contacts: ContactsTable;
|
||||
reactions: ReactionsTable;
|
||||
read_state: ReadStateTable;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,8 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
|||
},
|
||||
|
||||
centrifugoToken: async (userId) => {
|
||||
// The client fetches this to (re)connect — a good moment to mark them seen.
|
||||
await users.touchLastSeen(userId);
|
||||
const token = await signCentrifugoToken(
|
||||
config.centrifugoSecret,
|
||||
config.centrifugoTtlSeconds,
|
||||
|
|
|
|||
52
packages/backend/src/modules/contacts/contacts.repository.ts
Normal file
52
packages/backend/src/modules/contacts/contacts.repository.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { Kysely } from 'kysely';
|
||||
import type { Database } from '../../db/schema';
|
||||
|
||||
export interface ContactWithUser {
|
||||
contact_id: string;
|
||||
created_at: Date;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_ref: string | null;
|
||||
}
|
||||
|
||||
export interface ContactsRepository {
|
||||
add(ownerId: string, contactId: string): Promise<void>;
|
||||
remove(ownerId: string, contactId: string): Promise<void>;
|
||||
listForOwner(ownerId: string): Promise<ContactWithUser[]>;
|
||||
getOne(ownerId: string, contactId: string): Promise<ContactWithUser | undefined>;
|
||||
}
|
||||
|
||||
export const createContactsRepository = (db: Kysely<Database>): ContactsRepository => {
|
||||
const withUser = (ownerId: string) =>
|
||||
db
|
||||
.selectFrom('contacts')
|
||||
.innerJoin('users', 'users.id', 'contacts.contact_id')
|
||||
.where('contacts.owner_id', '=', ownerId)
|
||||
.select([
|
||||
'contacts.contact_id as contact_id',
|
||||
'contacts.created_at as created_at',
|
||||
'users.username as username',
|
||||
'users.display_name as display_name',
|
||||
'users.avatar_ref as avatar_ref',
|
||||
]);
|
||||
|
||||
return {
|
||||
add: async (ownerId, contactId) => {
|
||||
await db
|
||||
.insertInto('contacts')
|
||||
.values({ owner_id: ownerId, contact_id: contactId })
|
||||
.onConflict((oc) => oc.columns(['owner_id', 'contact_id']).doNothing())
|
||||
.execute();
|
||||
},
|
||||
remove: async (ownerId, contactId) => {
|
||||
await db
|
||||
.deleteFrom('contacts')
|
||||
.where('owner_id', '=', ownerId)
|
||||
.where('contact_id', '=', contactId)
|
||||
.execute();
|
||||
},
|
||||
listForOwner: (ownerId) => withUser(ownerId).orderBy('users.username', 'asc').execute(),
|
||||
getOne: (ownerId, contactId) =>
|
||||
withUser(ownerId).where('contacts.contact_id', '=', contactId).executeTakeFirst(),
|
||||
};
|
||||
};
|
||||
72
packages/backend/src/modules/contacts/contacts.routes.ts
Normal file
72
packages/backend/src/modules/contacts/contacts.routes.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { addContactBodySchema, contactSchema, contactListSchema, errorSchema } from '@altricade/core';
|
||||
import type { AddContactBody } from '@altricade/core';
|
||||
|
||||
const bearerAuth = [{ bearerAuth: [] }];
|
||||
const userParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['userId'],
|
||||
properties: { userId: { type: 'string', format: 'uuid' } },
|
||||
} as const;
|
||||
|
||||
export const contactsRoutes = (app: FastifyInstance): Promise<void> => {
|
||||
app.get(
|
||||
'/contacts',
|
||||
{
|
||||
schema: {
|
||||
tags: ['contacts'],
|
||||
summary: 'List my contacts',
|
||||
security: bearerAuth,
|
||||
response: { 200: contactListSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
return reply.send(await app.contactsService.list(user.id));
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: AddContactBody }>(
|
||||
'/contacts',
|
||||
{
|
||||
schema: {
|
||||
tags: ['contacts'],
|
||||
summary: 'Add a contact by username',
|
||||
security: bearerAuth,
|
||||
body: addContactBodySchema,
|
||||
response: { 201: contactSchema, 400: errorSchema, 401: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const contact = await app.contactsService.add(user.id, request.body.username);
|
||||
return reply.code(201).send(contact);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { userId: string } }>(
|
||||
'/contacts/:userId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['contacts'],
|
||||
summary: 'Remove a contact',
|
||||
security: bearerAuth,
|
||||
params: userParamsSchema,
|
||||
response: { 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
await app.contactsService.remove(user.id, request.params.userId);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
60
packages/backend/src/modules/contacts/contacts.service.ts
Normal file
60
packages/backend/src/modules/contacts/contacts.service.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { Contact } from '@altricade/core';
|
||||
import { HttpError } from '../../shared/http-error';
|
||||
import type { UsersRepository } from '../users';
|
||||
import type { ContactsRepository, ContactWithUser } from './contacts.repository';
|
||||
|
||||
export interface ContactsServiceDeps {
|
||||
contacts: ContactsRepository;
|
||||
users: UsersRepository;
|
||||
}
|
||||
|
||||
export interface ContactsService {
|
||||
list(ownerId: string): Promise<Contact[]>;
|
||||
add(ownerId: string, username: string): Promise<Contact>;
|
||||
remove(ownerId: string, contactId: string): Promise<void>;
|
||||
}
|
||||
|
||||
const toContact = (row: ContactWithUser): Contact => ({
|
||||
userId: row.contact_id,
|
||||
user: {
|
||||
id: row.contact_id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
avatarUrl: row.avatar_ref,
|
||||
},
|
||||
createdAt: row.created_at.toISOString(),
|
||||
});
|
||||
|
||||
export const createContactsService = (deps: ContactsServiceDeps): ContactsService => {
|
||||
const { contacts, users } = deps;
|
||||
|
||||
return {
|
||||
list: async (ownerId) => (await contacts.listForOwner(ownerId)).map(toContact),
|
||||
|
||||
add: async (ownerId, username) => {
|
||||
const target = await users.findByUsername(username);
|
||||
if (target === undefined) {
|
||||
throw new HttpError(404, 'user_not_found', 'No such user');
|
||||
}
|
||||
if (target.id === ownerId) {
|
||||
throw new HttpError(400, 'invalid_target', 'Cannot add yourself as a contact');
|
||||
}
|
||||
await contacts.add(ownerId, target.id);
|
||||
const row = await contacts.getOne(ownerId, target.id);
|
||||
if (row === undefined) {
|
||||
throw new HttpError(500, 'internal_error', 'Contact not found after add');
|
||||
}
|
||||
return toContact(row);
|
||||
},
|
||||
|
||||
remove: async (ownerId, contactId) => {
|
||||
await contacts.remove(ownerId, contactId);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
contactsService: ContactsService;
|
||||
}
|
||||
}
|
||||
5
packages/backend/src/modules/contacts/index.ts
Normal file
5
packages/backend/src/modules/contacts/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { createContactsRepository } from './contacts.repository';
|
||||
export type { ContactsRepository, ContactWithUser } from './contacts.repository';
|
||||
export { createContactsService } from './contacts.service';
|
||||
export type { ContactsService, ContactsServiceDeps } from './contacts.service';
|
||||
export { contactsRoutes } from './contacts.routes';
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import type { Conversation, ConversationMember, PublicUser } from '@altricade/core';
|
||||
import type { ConversationRow, MemberWithUser } from './conversations.repository';
|
||||
|
||||
export const memberToPublicUser = (member: MemberWithUser): PublicUser => ({
|
||||
id: member.user_id,
|
||||
username: member.username,
|
||||
displayName: member.display_name,
|
||||
avatarUrl: member.avatar_ref,
|
||||
});
|
||||
|
||||
export const toConversation = (
|
||||
row: ConversationRow,
|
||||
peer: PublicUser | null,
|
||||
unreadCount = 0,
|
||||
): Conversation => ({
|
||||
id: row.id,
|
||||
type: row.type === 'direct' ? 'direct' : 'group',
|
||||
title: row.title,
|
||||
peer,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
lastMessageAt: row.last_message_at.toISOString(),
|
||||
unreadCount,
|
||||
});
|
||||
|
||||
export const toMember = (member: MemberWithUser): ConversationMember => ({
|
||||
userId: member.user_id,
|
||||
role: member.role,
|
||||
joinedAt: member.joined_at.toISOString(),
|
||||
user: memberToPublicUser(member),
|
||||
});
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import type { Kysely, Selectable } from 'kysely';
|
||||
import type { Database, ConversationsTable } from '../../db/schema';
|
||||
|
||||
export type ConversationRow = Selectable<ConversationsTable>;
|
||||
|
||||
export interface MemberWithUser {
|
||||
user_id: string;
|
||||
role: string;
|
||||
joined_at: Date;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_ref: string | null;
|
||||
}
|
||||
|
||||
export interface DeliveryInfo {
|
||||
type: string;
|
||||
memberIds: string[];
|
||||
}
|
||||
|
||||
export interface FindOrCreate {
|
||||
conversation: ConversationRow;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
const directKeyFor = (a: string, b: string): string => (a < b ? `${a}:${b}` : `${b}:${a}`);
|
||||
|
||||
export interface ConversationsRepository {
|
||||
createGroup(title: string, ownerId: string, memberIds: string[]): Promise<ConversationRow>;
|
||||
findOrCreateDirect(userA: string, userB: string): Promise<FindOrCreate>;
|
||||
findById(id: string): Promise<ConversationRow | undefined>;
|
||||
listForUser(userId: string): Promise<ConversationRow[]>;
|
||||
peersForDirect(userId: string, conversationIds: string[]): Promise<Map<string, MemberWithUser>>;
|
||||
getPeer(conversationId: string, userId: string): Promise<MemberWithUser | undefined>;
|
||||
isMember(conversationId: string, userId: string): Promise<boolean>;
|
||||
getRole(conversationId: string, userId: string): Promise<string | undefined>;
|
||||
addMember(conversationId: string, userId: string, role: string): Promise<void>;
|
||||
removeMember(conversationId: string, userId: string): Promise<void>;
|
||||
listMembers(conversationId: string): Promise<MemberWithUser[]>;
|
||||
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
||||
touchLastMessage(conversationId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const createConversationsRepository = (db: Kysely<Database>): ConversationsRepository => {
|
||||
const membersWithUser = (conversationId: string) =>
|
||||
db
|
||||
.selectFrom('conversation_members')
|
||||
.innerJoin('users', 'users.id', 'conversation_members.user_id')
|
||||
.where('conversation_members.conversation_id', '=', conversationId)
|
||||
.select([
|
||||
'conversation_members.user_id as user_id',
|
||||
'conversation_members.role as role',
|
||||
'conversation_members.joined_at as joined_at',
|
||||
'users.username as username',
|
||||
'users.display_name as display_name',
|
||||
'users.avatar_ref as avatar_ref',
|
||||
]);
|
||||
|
||||
return {
|
||||
createGroup: (title, ownerId, memberIds) =>
|
||||
db.transaction().execute(async (trx) => {
|
||||
const conversation = await trx
|
||||
.insertInto('conversations')
|
||||
.values({ type: 'group', title, created_by: ownerId })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const uniqueOthers = memberIds.filter((id) => id !== ownerId);
|
||||
const rows = [
|
||||
{ conversation_id: conversation.id, user_id: ownerId, role: 'owner' },
|
||||
...uniqueOthers.map((id) => ({
|
||||
conversation_id: conversation.id,
|
||||
user_id: id,
|
||||
role: 'member',
|
||||
})),
|
||||
];
|
||||
await trx
|
||||
.insertInto('conversation_members')
|
||||
.values(rows)
|
||||
.onConflict((oc) => oc.columns(['conversation_id', 'user_id']).doNothing())
|
||||
.execute();
|
||||
return conversation;
|
||||
}),
|
||||
|
||||
findOrCreateDirect: async (userA, userB) => {
|
||||
const directKey = directKeyFor(userA, userB);
|
||||
const existing = await db
|
||||
.selectFrom('conversations')
|
||||
.selectAll()
|
||||
.where('direct_key', '=', directKey)
|
||||
.executeTakeFirst();
|
||||
if (existing !== undefined) {
|
||||
return { conversation: existing, created: false };
|
||||
}
|
||||
try {
|
||||
const conversation = await db.transaction().execute(async (trx) => {
|
||||
const created = await trx
|
||||
.insertInto('conversations')
|
||||
.values({ type: 'direct', direct_key: directKey, created_by: userA })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await trx
|
||||
.insertInto('conversation_members')
|
||||
.values([
|
||||
{ conversation_id: created.id, user_id: userA, role: 'member' },
|
||||
{ conversation_id: created.id, user_id: userB, role: 'member' },
|
||||
])
|
||||
.execute();
|
||||
return created;
|
||||
});
|
||||
return { conversation, created: true };
|
||||
} catch {
|
||||
// Lost a race to create it; fetch the winner.
|
||||
const winner = await db
|
||||
.selectFrom('conversations')
|
||||
.selectAll()
|
||||
.where('direct_key', '=', directKey)
|
||||
.executeTakeFirstOrThrow();
|
||||
return { conversation: winner, created: false };
|
||||
}
|
||||
},
|
||||
|
||||
findById: (id) =>
|
||||
db.selectFrom('conversations').selectAll().where('id', '=', id).executeTakeFirst(),
|
||||
|
||||
listForUser: (userId) =>
|
||||
db
|
||||
.selectFrom('conversations')
|
||||
.innerJoin(
|
||||
'conversation_members',
|
||||
'conversation_members.conversation_id',
|
||||
'conversations.id',
|
||||
)
|
||||
.where('conversation_members.user_id', '=', userId)
|
||||
.selectAll('conversations')
|
||||
.orderBy('conversations.last_message_at', 'desc')
|
||||
.execute(),
|
||||
|
||||
peersForDirect: async (userId, conversationIds) => {
|
||||
const map = new Map<string, MemberWithUser>();
|
||||
if (conversationIds.length === 0) {
|
||||
return map;
|
||||
}
|
||||
const rows = await db
|
||||
.selectFrom('conversation_members')
|
||||
.innerJoin('users', 'users.id', 'conversation_members.user_id')
|
||||
.where('conversation_members.conversation_id', 'in', conversationIds)
|
||||
.where('conversation_members.user_id', '!=', userId)
|
||||
.select([
|
||||
'conversation_members.conversation_id as conversation_id',
|
||||
'conversation_members.user_id as user_id',
|
||||
'conversation_members.role as role',
|
||||
'conversation_members.joined_at as joined_at',
|
||||
'users.username as username',
|
||||
'users.display_name as display_name',
|
||||
'users.avatar_ref as avatar_ref',
|
||||
])
|
||||
.execute();
|
||||
for (const row of rows) {
|
||||
map.set(row.conversation_id, {
|
||||
user_id: row.user_id,
|
||||
role: row.role,
|
||||
joined_at: row.joined_at,
|
||||
username: row.username,
|
||||
display_name: row.display_name,
|
||||
avatar_ref: row.avatar_ref,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
},
|
||||
|
||||
getPeer: async (conversationId, userId) => {
|
||||
const row = await db
|
||||
.selectFrom('conversation_members')
|
||||
.innerJoin('users', 'users.id', 'conversation_members.user_id')
|
||||
.where('conversation_members.conversation_id', '=', conversationId)
|
||||
.where('conversation_members.user_id', '!=', userId)
|
||||
.select([
|
||||
'conversation_members.user_id as user_id',
|
||||
'conversation_members.role as role',
|
||||
'conversation_members.joined_at as joined_at',
|
||||
'users.username as username',
|
||||
'users.display_name as display_name',
|
||||
'users.avatar_ref as avatar_ref',
|
||||
])
|
||||
.executeTakeFirst();
|
||||
return row;
|
||||
},
|
||||
|
||||
isMember: async (conversationId, userId) => {
|
||||
const row = await db
|
||||
.selectFrom('conversation_members')
|
||||
.select('user_id')
|
||||
.where('conversation_id', '=', conversationId)
|
||||
.where('user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return row !== undefined;
|
||||
},
|
||||
|
||||
getRole: async (conversationId, userId) => {
|
||||
const row = await db
|
||||
.selectFrom('conversation_members')
|
||||
.select('role')
|
||||
.where('conversation_id', '=', conversationId)
|
||||
.where('user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return row?.role;
|
||||
},
|
||||
|
||||
addMember: async (conversationId, userId, role) => {
|
||||
await db
|
||||
.insertInto('conversation_members')
|
||||
.values({ conversation_id: conversationId, user_id: userId, role })
|
||||
.onConflict((oc) => oc.columns(['conversation_id', 'user_id']).doNothing())
|
||||
.execute();
|
||||
},
|
||||
|
||||
removeMember: async (conversationId, userId) => {
|
||||
await db
|
||||
.deleteFrom('conversation_members')
|
||||
.where('conversation_id', '=', conversationId)
|
||||
.where('user_id', '=', userId)
|
||||
.execute();
|
||||
},
|
||||
|
||||
listMembers: (conversationId) => membersWithUser(conversationId).orderBy('joined_at', 'asc').execute(),
|
||||
|
||||
getDeliveryInfo: async (conversationId) => {
|
||||
const conversation = await db
|
||||
.selectFrom('conversations')
|
||||
.select('type')
|
||||
.where('id', '=', conversationId)
|
||||
.executeTakeFirst();
|
||||
if (conversation === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const members = await db
|
||||
.selectFrom('conversation_members')
|
||||
.select('user_id')
|
||||
.where('conversation_id', '=', conversationId)
|
||||
.execute();
|
||||
return { type: conversation.type, memberIds: members.map((m) => m.user_id) };
|
||||
},
|
||||
|
||||
touchLastMessage: async (conversationId) => {
|
||||
await db
|
||||
.updateTable('conversations')
|
||||
.set({ last_message_at: new Date() })
|
||||
.where('id', '=', conversationId)
|
||||
.execute();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
createDirectBodySchema,
|
||||
createGroupBodySchema,
|
||||
addMemberBodySchema,
|
||||
readBodySchema,
|
||||
typingBodySchema,
|
||||
conversationSchema,
|
||||
conversationListSchema,
|
||||
conversationMemberListSchema,
|
||||
errorSchema,
|
||||
} from '@altricade/core';
|
||||
import type {
|
||||
CreateDirectBody,
|
||||
CreateGroupBody,
|
||||
AddMemberBody,
|
||||
ReadBody,
|
||||
TypingBody,
|
||||
} from '@altricade/core';
|
||||
|
||||
const bearerAuth = [{ bearerAuth: [] }];
|
||||
const idParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
} as const;
|
||||
const memberParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['id', 'userId'],
|
||||
properties: { id: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' } },
|
||||
} as const;
|
||||
|
||||
export const conversationsRoutes = (app: FastifyInstance): Promise<void> => {
|
||||
app.get(
|
||||
'/conversations',
|
||||
{
|
||||
schema: {
|
||||
tags: ['conversations'],
|
||||
summary: 'List my conversations (DMs + groups, newest first)',
|
||||
security: bearerAuth,
|
||||
response: { 200: conversationListSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
return reply.send(await app.conversationsService.list(user.id));
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: CreateDirectBody }>(
|
||||
'/conversations/direct',
|
||||
{
|
||||
schema: {
|
||||
tags: ['conversations'],
|
||||
summary: 'Start (or reopen) a direct conversation with a user',
|
||||
security: bearerAuth,
|
||||
body: createDirectBodySchema,
|
||||
response: { 201: conversationSchema, 400: errorSchema, 401: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const conversation = await app.conversationsService.createDirect(user.id, request.body.username);
|
||||
return reply.code(201).send(conversation);
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: CreateGroupBody }>(
|
||||
'/conversations',
|
||||
{
|
||||
schema: {
|
||||
tags: ['conversations'],
|
||||
summary: 'Create a group conversation',
|
||||
security: bearerAuth,
|
||||
body: createGroupBodySchema,
|
||||
response: { 201: conversationSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const conversation = await app.conversationsService.createGroup(
|
||||
user.id,
|
||||
request.body.title,
|
||||
request.body.members ?? [],
|
||||
);
|
||||
return reply.code(201).send(conversation);
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/conversations/:id',
|
||||
{
|
||||
schema: {
|
||||
tags: ['conversations'],
|
||||
summary: 'Get a conversation (member only)',
|
||||
security: bearerAuth,
|
||||
params: idParamsSchema,
|
||||
response: { 200: conversationSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
return reply.send(await app.conversationsService.get(request.params.id, user.id));
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/conversations/:id/members',
|
||||
{
|
||||
schema: {
|
||||
tags: ['conversations'],
|
||||
summary: 'List conversation members (member only)',
|
||||
security: bearerAuth,
|
||||
params: idParamsSchema,
|
||||
response: { 200: conversationMemberListSchema, 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
return reply.send(await app.conversationsService.listMembers(request.params.id, user.id));
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: AddMemberBody }>(
|
||||
'/conversations/:id/members',
|
||||
{
|
||||
schema: {
|
||||
tags: ['conversations'],
|
||||
summary: 'Add a member to a group (owner only)',
|
||||
security: bearerAuth,
|
||||
params: idParamsSchema,
|
||||
body: addMemberBodySchema,
|
||||
response: { 201: conversationMemberListSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const members = await app.conversationsService.addMember(
|
||||
request.params.id,
|
||||
user.id,
|
||||
request.body.username,
|
||||
);
|
||||
return reply.code(201).send(members);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string; userId: string } }>(
|
||||
'/conversations/:id/members/:userId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['conversations'],
|
||||
summary: 'Remove a member (owner) or leave (self)',
|
||||
security: bearerAuth,
|
||||
params: memberParamsSchema,
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
await app.conversationsService.removeMember(request.params.id, user.id, request.params.userId);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: ReadBody }>(
|
||||
'/conversations/:id/read',
|
||||
{
|
||||
schema: {
|
||||
tags: ['live'],
|
||||
summary: 'Mark read up to a sequence number',
|
||||
security: bearerAuth,
|
||||
params: idParamsSchema,
|
||||
body: readBodySchema,
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
await app.conversationsService.markRead(request.params.id, user.id, request.body.seq);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: TypingBody }>(
|
||||
'/conversations/:id/typing',
|
||||
{
|
||||
schema: {
|
||||
tags: ['live'],
|
||||
summary: 'Send a typing indicator (ephemeral)',
|
||||
security: bearerAuth,
|
||||
params: idParamsSchema,
|
||||
body: typingBodySchema,
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
await app.conversationsService.setTyping(request.params.id, user.id, request.body.state);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import type {
|
||||
Conversation,
|
||||
ConversationMember,
|
||||
ConversationNewEvent,
|
||||
ConversationMembershipEvent,
|
||||
ReadReceiptEvent,
|
||||
TypingEvent,
|
||||
} from '@altricade/core';
|
||||
import { userChannel } from '@altricade/core';
|
||||
import { HttpError } from '../../shared/http-error';
|
||||
import type { Publisher } from '../../shared/publisher';
|
||||
import { toPublicUser } from '../users';
|
||||
import type { UsersRepository } from '../users';
|
||||
import type { ConversationsRepository, DeliveryInfo } from './conversations.repository';
|
||||
import type { ReadStateRepository } from './read-state.repository';
|
||||
import type { Deliver } from './delivery';
|
||||
import { toConversation, toMember, memberToPublicUser } from './conversations.mapper';
|
||||
|
||||
export interface ConversationsServiceDeps {
|
||||
conversations: ConversationsRepository;
|
||||
users: UsersRepository;
|
||||
readState: ReadStateRepository;
|
||||
deliver: Deliver;
|
||||
publish: Publisher;
|
||||
}
|
||||
|
||||
export interface ConversationsService {
|
||||
createDirect(actorId: string, username: string): Promise<Conversation>;
|
||||
createGroup(actorId: string, title: string, memberUsernames: string[]): Promise<Conversation>;
|
||||
list(userId: string): Promise<Conversation[]>;
|
||||
get(conversationId: string, userId: string): Promise<Conversation>;
|
||||
listMembers(conversationId: string, userId: string): Promise<ConversationMember[]>;
|
||||
addMember(conversationId: string, actorId: string, username: string): Promise<ConversationMember[]>;
|
||||
removeMember(conversationId: string, actorId: string, targetUserId: string): Promise<void>;
|
||||
isMember(conversationId: string, userId: string): Promise<boolean>;
|
||||
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
||||
markRead(conversationId: string, userId: string, seq: number): Promise<void>;
|
||||
setTyping(conversationId: string, userId: string, state: 'start' | 'stop'): Promise<void>;
|
||||
}
|
||||
|
||||
export const createConversationsService = (
|
||||
deps: ConversationsServiceDeps,
|
||||
): ConversationsService => {
|
||||
const { conversations, users, readState, deliver, publish } = deps;
|
||||
|
||||
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
|
||||
if (!(await conversations.isMember(conversationId, userId))) {
|
||||
throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation');
|
||||
}
|
||||
};
|
||||
|
||||
const assertOwner = async (conversationId: string, userId: string): Promise<void> => {
|
||||
if ((await conversations.getRole(conversationId, userId)) !== 'owner') {
|
||||
throw new HttpError(403, 'not_owner', 'Only the group owner can do this');
|
||||
}
|
||||
};
|
||||
|
||||
const conversationNew = (conversation: Conversation): ConversationNewEvent => ({
|
||||
type: 'conversation.new',
|
||||
conversation,
|
||||
});
|
||||
|
||||
const membership = (
|
||||
action: ConversationMembershipEvent['action'],
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): ConversationMembershipEvent => ({ type: 'conversation.membership', action, conversationId, userId });
|
||||
|
||||
return {
|
||||
createDirect: async (actorId, username) => {
|
||||
const target = await users.findByUsername(username);
|
||||
if (target === undefined) {
|
||||
throw new HttpError(404, 'user_not_found', 'No such user');
|
||||
}
|
||||
if (target.id === actorId) {
|
||||
throw new HttpError(400, 'invalid_target', 'Cannot start a direct chat with yourself');
|
||||
}
|
||||
const targetPublic = toPublicUser(target);
|
||||
const { conversation, created } = await conversations.findOrCreateDirect(actorId, target.id);
|
||||
|
||||
if (created) {
|
||||
const actorRow = await users.findById(actorId);
|
||||
const actorPublic = actorRow === undefined ? null : toPublicUser(actorRow);
|
||||
await publish(
|
||||
userChannel(target.id),
|
||||
conversationNew(toConversation(conversation, actorPublic)),
|
||||
);
|
||||
await publish(
|
||||
userChannel(actorId),
|
||||
conversationNew(toConversation(conversation, targetPublic)),
|
||||
);
|
||||
}
|
||||
return toConversation(conversation, targetPublic);
|
||||
},
|
||||
|
||||
createGroup: async (actorId, title, memberUsernames) => {
|
||||
const memberIds: string[] = [];
|
||||
for (const username of memberUsernames) {
|
||||
const found = await users.findByUsername(username);
|
||||
if (found !== undefined && found.id !== actorId) {
|
||||
memberIds.push(found.id);
|
||||
}
|
||||
}
|
||||
const conversation = toConversation(
|
||||
await conversations.createGroup(title, actorId, memberIds),
|
||||
null,
|
||||
);
|
||||
for (const userId of [actorId, ...memberIds]) {
|
||||
await publish(userChannel(userId), conversationNew(conversation));
|
||||
}
|
||||
return conversation;
|
||||
},
|
||||
|
||||
list: async (userId) => {
|
||||
const rows = await conversations.listForUser(userId);
|
||||
const directIds = rows.filter((row) => row.type === 'direct').map((row) => row.id);
|
||||
const peers = await conversations.peersForDirect(userId, directIds);
|
||||
const unread = await readState.unreadCounts(userId);
|
||||
return rows.map((row) => {
|
||||
const count = unread.get(row.id) ?? 0;
|
||||
if (row.type !== 'direct') {
|
||||
return toConversation(row, null, count);
|
||||
}
|
||||
const peer = peers.get(row.id);
|
||||
return toConversation(row, peer === undefined ? null : memberToPublicUser(peer), count);
|
||||
});
|
||||
},
|
||||
|
||||
get: async (conversationId, userId) => {
|
||||
await assertMember(conversationId, userId);
|
||||
const conversation = await conversations.findById(conversationId);
|
||||
if (conversation === undefined) {
|
||||
throw new HttpError(404, 'not_found', 'Conversation not found');
|
||||
}
|
||||
const count = await readState.unreadFor(conversationId, userId);
|
||||
if (conversation.type !== 'direct') {
|
||||
return toConversation(conversation, null, count);
|
||||
}
|
||||
const peer = await conversations.getPeer(conversationId, userId);
|
||||
return toConversation(
|
||||
conversation,
|
||||
peer === undefined ? null : memberToPublicUser(peer),
|
||||
count,
|
||||
);
|
||||
},
|
||||
|
||||
listMembers: async (conversationId, userId) => {
|
||||
await assertMember(conversationId, userId);
|
||||
return (await conversations.listMembers(conversationId)).map(toMember);
|
||||
},
|
||||
|
||||
addMember: async (conversationId, actorId, username) => {
|
||||
await assertOwner(conversationId, actorId);
|
||||
const target = await users.findByUsername(username);
|
||||
if (target === undefined) {
|
||||
throw new HttpError(404, 'user_not_found', 'No such user');
|
||||
}
|
||||
await conversations.addMember(conversationId, target.id, 'member');
|
||||
const conversation = await conversations.findById(conversationId);
|
||||
if (conversation !== undefined) {
|
||||
await publish(userChannel(target.id), conversationNew(toConversation(conversation, null)));
|
||||
}
|
||||
await publish(userChannel(target.id), membership('added', conversationId, target.id));
|
||||
return (await conversations.listMembers(conversationId)).map(toMember);
|
||||
},
|
||||
|
||||
removeMember: async (conversationId, actorId, targetUserId) => {
|
||||
if (actorId !== targetUserId) {
|
||||
await assertOwner(conversationId, actorId);
|
||||
}
|
||||
await conversations.removeMember(conversationId, targetUserId);
|
||||
await publish(userChannel(targetUserId), membership('removed', conversationId, targetUserId));
|
||||
},
|
||||
|
||||
isMember: (conversationId, userId) => conversations.isMember(conversationId, userId),
|
||||
|
||||
getDeliveryInfo: (conversationId) => conversations.getDeliveryInfo(conversationId),
|
||||
|
||||
markRead: async (conversationId, userId, seq) => {
|
||||
await assertMember(conversationId, userId);
|
||||
await readState.setRead(conversationId, userId, seq);
|
||||
const event: ReadReceiptEvent = { type: 'read.receipt', conversationId, userId, seq };
|
||||
await deliver(conversationId, event);
|
||||
},
|
||||
|
||||
setTyping: async (conversationId, userId, state) => {
|
||||
await assertMember(conversationId, userId);
|
||||
const event: TypingEvent = {
|
||||
type: state === 'start' ? 'typing.start' : 'typing.stop',
|
||||
conversationId,
|
||||
userId,
|
||||
};
|
||||
await deliver(conversationId, event);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
conversationsService: ConversationsService;
|
||||
}
|
||||
}
|
||||
27
packages/backend/src/modules/conversations/delivery.ts
Normal file
27
packages/backend/src/modules/conversations/delivery.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { conversationChannel, userChannel } from '@altricade/core';
|
||||
import type { Publisher } from '../../shared/publisher';
|
||||
import type { ConversationsRepository } from './conversations.repository';
|
||||
|
||||
export type Deliver = (conversationId: string, event: unknown) => Promise<void>;
|
||||
|
||||
// Route an event to a conversation's audience: group → the conv channel;
|
||||
// direct → both participants' personal channels. Shared by messages, reactions,
|
||||
// read receipts, and typing so delivery lives in exactly one place.
|
||||
export const createDeliver = (
|
||||
conversations: ConversationsRepository,
|
||||
publish: Publisher,
|
||||
): Deliver => {
|
||||
return async (conversationId, event) => {
|
||||
const info = await conversations.getDeliveryInfo(conversationId);
|
||||
if (info === undefined) {
|
||||
return;
|
||||
}
|
||||
if (info.type === 'group') {
|
||||
await publish(conversationChannel(conversationId), event);
|
||||
return;
|
||||
}
|
||||
for (const memberId of info.memberIds) {
|
||||
await publish(userChannel(memberId), event);
|
||||
}
|
||||
};
|
||||
};
|
||||
15
packages/backend/src/modules/conversations/index.ts
Normal file
15
packages/backend/src/modules/conversations/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export { createConversationsRepository } from './conversations.repository';
|
||||
export type {
|
||||
ConversationsRepository,
|
||||
ConversationRow,
|
||||
MemberWithUser,
|
||||
DeliveryInfo,
|
||||
} from './conversations.repository';
|
||||
export { createConversationsService } from './conversations.service';
|
||||
export type { ConversationsService, ConversationsServiceDeps } from './conversations.service';
|
||||
export { toConversation, toMember, memberToPublicUser } from './conversations.mapper';
|
||||
export { createDeliver } from './delivery';
|
||||
export type { Deliver } from './delivery';
|
||||
export { createReadStateRepository } from './read-state.repository';
|
||||
export type { ReadStateRepository } from './read-state.repository';
|
||||
export { conversationsRoutes } from './conversations.routes';
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { sql } from 'kysely';
|
||||
import type { Kysely } from 'kysely';
|
||||
import type { Database } from '../../db/schema';
|
||||
|
||||
export interface ReadStateRepository {
|
||||
setRead(conversationId: string, userId: string, seq: number): Promise<void>;
|
||||
unreadCounts(userId: string): Promise<Map<string, number>>;
|
||||
unreadFor(conversationId: string, userId: string): Promise<number>;
|
||||
}
|
||||
|
||||
export const createReadStateRepository = (db: Kysely<Database>): ReadStateRepository => ({
|
||||
setRead: async (conversationId, userId, seq) => {
|
||||
await db
|
||||
.insertInto('read_state')
|
||||
.values({
|
||||
conversation_id: conversationId,
|
||||
user_id: userId,
|
||||
last_read_seq: seq,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['conversation_id', 'user_id']).doUpdateSet({
|
||||
last_read_seq: sql`greatest(read_state.last_read_seq, excluded.last_read_seq)`,
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
},
|
||||
|
||||
unreadCounts: async (userId) => {
|
||||
const result = await sql<{ conversation_id: string; unread: number }>`
|
||||
SELECT cm.conversation_id AS conversation_id,
|
||||
count(m.id)::int AS unread
|
||||
FROM conversation_members cm
|
||||
LEFT JOIN read_state rs
|
||||
ON rs.conversation_id = cm.conversation_id AND rs.user_id = cm.user_id
|
||||
LEFT JOIN messages m
|
||||
ON m.conversation_id = cm.conversation_id
|
||||
AND m.seq > COALESCE(rs.last_read_seq, 0)
|
||||
AND m.sender_id <> cm.user_id
|
||||
AND m.deleted_at IS NULL
|
||||
WHERE cm.user_id = ${userId}
|
||||
GROUP BY cm.conversation_id
|
||||
`.execute(db);
|
||||
const map = new Map<string, number>();
|
||||
for (const row of result.rows) {
|
||||
map.set(row.conversation_id, row.unread);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
|
||||
unreadFor: async (conversationId, userId) => {
|
||||
const result = await sql<{ unread: number }>`
|
||||
SELECT count(m.id)::int AS unread
|
||||
FROM messages m
|
||||
LEFT JOIN read_state rs
|
||||
ON rs.conversation_id = m.conversation_id AND rs.user_id = ${userId}
|
||||
WHERE m.conversation_id = ${conversationId}
|
||||
AND m.seq > COALESCE(rs.last_read_seq, 0)
|
||||
AND m.sender_id <> ${userId}
|
||||
AND m.deleted_at IS NULL
|
||||
`.execute(db);
|
||||
return result.rows[0]?.unread ?? 0;
|
||||
},
|
||||
});
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import type { Message } from '@altricade/core';
|
||||
import type { Message, ReactionSummary } from '@altricade/core';
|
||||
import type { MessageWithSenderRow } from './messages.repository';
|
||||
|
||||
export const toMessage = (row: MessageWithSenderRow): Message => ({
|
||||
export const toMessage = (
|
||||
row: MessageWithSenderRow,
|
||||
reactions: ReactionSummary[] = [],
|
||||
): Message => ({
|
||||
id: row.id,
|
||||
roomId: row.room_id,
|
||||
conversationId: row.conversation_id,
|
||||
senderId: row.sender_id,
|
||||
sender: {
|
||||
id: row.sender_id,
|
||||
|
|
@ -17,4 +20,7 @@ export const toMessage = (row: MessageWithSenderRow): Message => ({
|
|||
clientMsgId: row.client_msg_id,
|
||||
seq: row.seq,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
editedAt: row.edited_at === null ? null : row.edited_at.toISOString(),
|
||||
deletedAt: row.deleted_at === null ? null : row.deleted_at.toISOString(),
|
||||
reactions,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,27 @@
|
|||
import { sql } from 'kysely';
|
||||
import type { Kysely } from 'kysely';
|
||||
import type { ReactionSummary } from '@altricade/core';
|
||||
import type { Database } from '../../db/schema';
|
||||
|
||||
export interface MessageWithSenderRow {
|
||||
id: string;
|
||||
seq: number;
|
||||
room_id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string;
|
||||
client_msg_id: string;
|
||||
content: string;
|
||||
content_type: string;
|
||||
encryption: string | null;
|
||||
created_at: Date;
|
||||
edited_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
sender_username: string;
|
||||
sender_display_name: string;
|
||||
sender_avatar_ref: string | null;
|
||||
}
|
||||
|
||||
export interface NewMessage {
|
||||
roomId: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
clientMsgId: string;
|
||||
content: string;
|
||||
|
|
@ -28,16 +32,26 @@ export interface NewMessage {
|
|||
export interface MessagesRepository {
|
||||
insert(input: NewMessage): Promise<{ id: string } | undefined>;
|
||||
findIdByDedupe(
|
||||
roomId: string,
|
||||
conversationId: string,
|
||||
senderId: string,
|
||||
clientMsgId: string,
|
||||
): Promise<string | undefined>;
|
||||
getWithSenderById(id: string): Promise<MessageWithSenderRow | undefined>;
|
||||
listHistory(
|
||||
roomId: string,
|
||||
conversationId: string,
|
||||
beforeSeq: number | null,
|
||||
limit: number,
|
||||
): Promise<MessageWithSenderRow[]>;
|
||||
getConversationId(messageId: string): Promise<string | undefined>;
|
||||
editContent(
|
||||
messageId: string,
|
||||
senderId: string,
|
||||
content: string,
|
||||
): Promise<{ id: string } | undefined>;
|
||||
softDelete(messageId: string, senderId: string): Promise<{ id: string } | undefined>;
|
||||
addReaction(messageId: string, userId: string, emoji: string): Promise<void>;
|
||||
removeReaction(messageId: string, userId: string, emoji: string): Promise<void>;
|
||||
reactionsFor(messageIds: string[], userId: string): Promise<Map<string, ReactionSummary[]>>;
|
||||
}
|
||||
|
||||
export const createMessagesRepository = (db: Kysely<Database>): MessagesRepository => {
|
||||
|
|
@ -48,13 +62,15 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
|||
.select([
|
||||
'messages.id as id',
|
||||
'messages.seq as seq',
|
||||
'messages.room_id as room_id',
|
||||
'messages.conversation_id as conversation_id',
|
||||
'messages.sender_id as sender_id',
|
||||
'messages.client_msg_id as client_msg_id',
|
||||
'messages.content as content',
|
||||
'messages.content_type as content_type',
|
||||
'messages.encryption as encryption',
|
||||
'messages.created_at as created_at',
|
||||
'messages.edited_at as edited_at',
|
||||
'messages.deleted_at as deleted_at',
|
||||
'users.username as sender_username',
|
||||
'users.display_name as sender_display_name',
|
||||
'users.avatar_ref as sender_avatar_ref',
|
||||
|
|
@ -65,22 +81,24 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
|||
db
|
||||
.insertInto('messages')
|
||||
.values({
|
||||
room_id: input.roomId,
|
||||
conversation_id: input.conversationId,
|
||||
sender_id: input.senderId,
|
||||
client_msg_id: input.clientMsgId,
|
||||
content: input.content,
|
||||
content_type: input.contentType,
|
||||
encryption: input.encryption,
|
||||
})
|
||||
.onConflict((oc) => oc.columns(['room_id', 'sender_id', 'client_msg_id']).doNothing())
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['conversation_id', 'sender_id', 'client_msg_id']).doNothing(),
|
||||
)
|
||||
.returning('id')
|
||||
.executeTakeFirst(),
|
||||
|
||||
findIdByDedupe: async (roomId, senderId, clientMsgId) => {
|
||||
findIdByDedupe: async (conversationId, senderId, clientMsgId) => {
|
||||
const row = await db
|
||||
.selectFrom('messages')
|
||||
.select('id')
|
||||
.where('room_id', '=', roomId)
|
||||
.where('conversation_id', '=', conversationId)
|
||||
.where('sender_id', '=', senderId)
|
||||
.where('client_msg_id', '=', clientMsgId)
|
||||
.executeTakeFirst();
|
||||
|
|
@ -89,12 +107,82 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
|||
|
||||
getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(),
|
||||
|
||||
listHistory: (roomId, beforeSeq, limit) => {
|
||||
let query = withSender().where('messages.room_id', '=', roomId);
|
||||
listHistory: (conversationId, beforeSeq, limit) => {
|
||||
let query = withSender().where('messages.conversation_id', '=', conversationId);
|
||||
if (beforeSeq !== null) {
|
||||
query = query.where('messages.seq', '<', beforeSeq);
|
||||
}
|
||||
return query.orderBy('messages.seq', 'desc').limit(limit).execute();
|
||||
},
|
||||
|
||||
getConversationId: async (messageId) => {
|
||||
const row = await db
|
||||
.selectFrom('messages')
|
||||
.select('conversation_id')
|
||||
.where('id', '=', messageId)
|
||||
.executeTakeFirst();
|
||||
return row?.conversation_id;
|
||||
},
|
||||
|
||||
editContent: (messageId, senderId, content) =>
|
||||
db
|
||||
.updateTable('messages')
|
||||
.set({ content, edited_at: new Date() })
|
||||
.where('id', '=', messageId)
|
||||
.where('sender_id', '=', senderId)
|
||||
.where('deleted_at', 'is', null)
|
||||
.returning('id')
|
||||
.executeTakeFirst(),
|
||||
|
||||
softDelete: (messageId, senderId) =>
|
||||
db
|
||||
.updateTable('messages')
|
||||
.set({ deleted_at: new Date(), content: '' })
|
||||
.where('id', '=', messageId)
|
||||
.where('sender_id', '=', senderId)
|
||||
.where('deleted_at', 'is', null)
|
||||
.returning('id')
|
||||
.executeTakeFirst(),
|
||||
|
||||
addReaction: async (messageId, userId, emoji) => {
|
||||
await db
|
||||
.insertInto('reactions')
|
||||
.values({ message_id: messageId, user_id: userId, emoji })
|
||||
.onConflict((oc) => oc.columns(['message_id', 'user_id', 'emoji']).doNothing())
|
||||
.execute();
|
||||
},
|
||||
|
||||
removeReaction: async (messageId, userId, emoji) => {
|
||||
await db
|
||||
.deleteFrom('reactions')
|
||||
.where('message_id', '=', messageId)
|
||||
.where('user_id', '=', userId)
|
||||
.where('emoji', '=', emoji)
|
||||
.execute();
|
||||
},
|
||||
|
||||
reactionsFor: async (messageIds, userId) => {
|
||||
const map = new Map<string, ReactionSummary[]>();
|
||||
if (messageIds.length === 0) {
|
||||
return map;
|
||||
}
|
||||
const rows = await db
|
||||
.selectFrom('reactions')
|
||||
.where('message_id', 'in', messageIds)
|
||||
.groupBy(['message_id', 'emoji'])
|
||||
.select((eb) => [
|
||||
'message_id',
|
||||
'emoji',
|
||||
eb.fn.countAll<number>().as('count'),
|
||||
sql<boolean>`bool_or(user_id = ${userId})`.as('mine'),
|
||||
])
|
||||
.execute();
|
||||
for (const row of rows) {
|
||||
const list = map.get(row.message_id) ?? [];
|
||||
list.push({ emoji: row.emoji, count: row.count, mine: row.mine });
|
||||
map.set(row.message_id, list);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,37 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { sendMessageBodySchema, messageSchema, messageListSchema, errorSchema } from '@altricade/core';
|
||||
import type { SendMessageBody } from '@altricade/core';
|
||||
import {
|
||||
sendMessageBodySchema,
|
||||
editMessageBodySchema,
|
||||
reactionBodySchema,
|
||||
messageSchema,
|
||||
messageListSchema,
|
||||
errorSchema,
|
||||
} from '@altricade/core';
|
||||
import type { SendMessageBody, EditMessageBody, ReactionBody } from '@altricade/core';
|
||||
|
||||
const bearerAuth = [{ bearerAuth: [] }];
|
||||
const roomParamsSchema = {
|
||||
const idParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
} as const;
|
||||
const messageParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['id', 'messageId'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
messageId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
} as const;
|
||||
const reactionParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['id', 'messageId', 'emoji'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
messageId: { type: 'string', format: 'uuid' },
|
||||
emoji: { type: 'string', minLength: 1, maxLength: 32 },
|
||||
},
|
||||
} as const;
|
||||
const historyQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -18,13 +42,13 @@ const historyQuerySchema = {
|
|||
|
||||
export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
||||
app.get<{ Params: { id: string }; Querystring: { before?: number; limit: number } }>(
|
||||
'/rooms/:id/messages',
|
||||
'/conversations/:id/messages',
|
||||
{
|
||||
schema: {
|
||||
tags: ['messages'],
|
||||
summary: 'Load room message history (newest first)',
|
||||
summary: 'Load conversation message history (newest first)',
|
||||
security: bearerAuth,
|
||||
params: roomParamsSchema,
|
||||
params: idParamsSchema,
|
||||
querystring: historyQuerySchema,
|
||||
response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
|
|
@ -45,13 +69,13 @@ export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
|||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: SendMessageBody }>(
|
||||
'/rooms/:id/messages',
|
||||
'/conversations/:id/messages',
|
||||
{
|
||||
schema: {
|
||||
tags: ['messages'],
|
||||
summary: 'Send a message to a room',
|
||||
summary: 'Send a message to a conversation',
|
||||
security: bearerAuth,
|
||||
params: roomParamsSchema,
|
||||
params: idParamsSchema,
|
||||
body: sendMessageBodySchema,
|
||||
response: { 200: messageSchema, 201: messageSchema, 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
|
|
@ -65,5 +89,102 @@ export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
|||
},
|
||||
);
|
||||
|
||||
app.patch<{ Params: { id: string; messageId: string }; Body: EditMessageBody }>(
|
||||
'/conversations/:id/messages/:messageId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['messages'],
|
||||
summary: 'Edit a message (sender only)',
|
||||
security: bearerAuth,
|
||||
params: messageParamsSchema,
|
||||
body: editMessageBodySchema,
|
||||
response: { 200: messageSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const message = await app.messagesService.edit(
|
||||
request.params.id,
|
||||
request.params.messageId,
|
||||
user.id,
|
||||
request.body.content,
|
||||
);
|
||||
return reply.send(message);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string; messageId: string } }>(
|
||||
'/conversations/:id/messages/:messageId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['messages'],
|
||||
summary: 'Delete a message (sender only, soft delete)',
|
||||
security: bearerAuth,
|
||||
params: messageParamsSchema,
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
await app.messagesService.remove(request.params.id, request.params.messageId, user.id);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string; messageId: string }; Body: ReactionBody }>(
|
||||
'/conversations/:id/messages/:messageId/reactions',
|
||||
{
|
||||
schema: {
|
||||
tags: ['messages'],
|
||||
summary: 'Add a reaction',
|
||||
security: bearerAuth,
|
||||
params: messageParamsSchema,
|
||||
body: reactionBodySchema,
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
await app.messagesService.addReaction(
|
||||
request.params.id,
|
||||
request.params.messageId,
|
||||
user.id,
|
||||
request.body.emoji,
|
||||
);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string; messageId: string; emoji: string } }>(
|
||||
'/conversations/:id/messages/:messageId/reactions/:emoji',
|
||||
{
|
||||
schema: {
|
||||
tags: ['messages'],
|
||||
summary: 'Remove a reaction',
|
||||
security: bearerAuth,
|
||||
params: reactionParamsSchema,
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
await app.messagesService.removeReaction(
|
||||
request.params.id,
|
||||
request.params.messageId,
|
||||
user.id,
|
||||
decodeURIComponent(request.params.emoji),
|
||||
);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import type { Message, MessageNewEvent, SendMessageBody } from '@altricade/core';
|
||||
import { roomChannel } from '@altricade/core';
|
||||
import type {
|
||||
Message,
|
||||
MessageNewEvent,
|
||||
MessageEditEvent,
|
||||
MessageDeleteEvent,
|
||||
ReactionEvent,
|
||||
SendMessageBody,
|
||||
} from '@altricade/core';
|
||||
import { HttpError } from '../../shared/http-error';
|
||||
import type { Publisher } from '../../shared/publisher';
|
||||
import type { RoomsRepository } from '../rooms';
|
||||
import type { ConversationsRepository } from '../conversations';
|
||||
import type { Deliver } from '../conversations';
|
||||
import type { MessagesRepository } from './messages.repository';
|
||||
import { toMessage } from './messages.mapper';
|
||||
|
||||
export interface MessagesServiceDeps {
|
||||
messages: MessagesRepository;
|
||||
rooms: RoomsRepository;
|
||||
publish: Publisher;
|
||||
conversations: ConversationsRepository;
|
||||
deliver: Deliver;
|
||||
}
|
||||
|
||||
export interface SentMessage {
|
||||
|
|
@ -18,30 +24,64 @@ export interface SentMessage {
|
|||
}
|
||||
|
||||
export interface MessagesService {
|
||||
send(roomId: string, senderId: string, input: SendMessageBody): Promise<SentMessage>;
|
||||
send(conversationId: string, senderId: string, input: SendMessageBody): Promise<SentMessage>;
|
||||
history(
|
||||
roomId: string,
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
beforeSeq: number | null,
|
||||
limit: number,
|
||||
): Promise<Message[]>;
|
||||
edit(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
userId: string,
|
||||
content: string,
|
||||
): Promise<Message>;
|
||||
remove(conversationId: string, messageId: string, userId: string): Promise<void>;
|
||||
addReaction(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
userId: string,
|
||||
emoji: string,
|
||||
): Promise<void>;
|
||||
removeReaction(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
userId: string,
|
||||
emoji: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
|
||||
const { messages, rooms, publish } = deps;
|
||||
const { messages, conversations, deliver } = deps;
|
||||
|
||||
const assertMember = async (roomId: string, userId: string): Promise<void> => {
|
||||
if (!(await rooms.isMember(roomId, userId))) {
|
||||
throw new HttpError(403, 'not_a_member', 'You are not a member of this room');
|
||||
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
|
||||
if (!(await conversations.isMember(conversationId, userId))) {
|
||||
throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation');
|
||||
}
|
||||
};
|
||||
|
||||
const assertMessageIn = async (conversationId: string, messageId: string): Promise<void> => {
|
||||
if ((await messages.getConversationId(messageId)) !== conversationId) {
|
||||
throw new HttpError(404, 'not_found', 'Message not found');
|
||||
}
|
||||
};
|
||||
|
||||
const loadMessage = async (messageId: string, userId: string): Promise<Message> => {
|
||||
const row = await messages.getWithSenderById(messageId);
|
||||
if (row === undefined) {
|
||||
throw new HttpError(404, 'not_found', 'Message not found');
|
||||
}
|
||||
const reactions = await messages.reactionsFor([messageId], userId);
|
||||
return toMessage(row, reactions.get(messageId) ?? []);
|
||||
};
|
||||
|
||||
return {
|
||||
send: async (roomId, senderId, input) => {
|
||||
await assertMember(roomId, senderId);
|
||||
send: async (conversationId, senderId, input) => {
|
||||
await assertMember(conversationId, senderId);
|
||||
|
||||
const inserted = await messages.insert({
|
||||
roomId,
|
||||
conversationId,
|
||||
senderId,
|
||||
clientMsgId: input.clientMsgId,
|
||||
content: input.content,
|
||||
|
|
@ -49,7 +89,9 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
encryption: input.encryption ?? null,
|
||||
});
|
||||
|
||||
const id = inserted?.id ?? (await messages.findIdByDedupe(roomId, senderId, input.clientMsgId));
|
||||
const id =
|
||||
inserted?.id ??
|
||||
(await messages.findIdByDedupe(conversationId, senderId, input.clientMsgId));
|
||||
if (id === undefined) {
|
||||
throw new HttpError(500, 'internal_error', 'Message could not be persisted');
|
||||
}
|
||||
|
|
@ -57,19 +99,76 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
if (row === undefined) {
|
||||
throw new HttpError(500, 'internal_error', 'Message not found after insert');
|
||||
}
|
||||
const message = toMessage(row);
|
||||
const message = toMessage(row, []);
|
||||
|
||||
// Publish only for a genuinely new message (not an idempotent replay).
|
||||
if (inserted !== undefined) {
|
||||
await conversations.touchLastMessage(conversationId);
|
||||
const event: MessageNewEvent = { type: 'message.new', message };
|
||||
await publish(roomChannel(roomId), event);
|
||||
await deliver(conversationId, event);
|
||||
}
|
||||
return { message, created: inserted !== undefined };
|
||||
},
|
||||
|
||||
history: async (roomId, userId, beforeSeq, limit) => {
|
||||
await assertMember(roomId, userId);
|
||||
return (await messages.listHistory(roomId, beforeSeq, limit)).map(toMessage);
|
||||
history: async (conversationId, userId, beforeSeq, limit) => {
|
||||
await assertMember(conversationId, userId);
|
||||
const rows = await messages.listHistory(conversationId, beforeSeq, limit);
|
||||
const reactions = await messages.reactionsFor(
|
||||
rows.map((row) => row.id),
|
||||
userId,
|
||||
);
|
||||
return rows.map((row) => toMessage(row, reactions.get(row.id) ?? []));
|
||||
},
|
||||
|
||||
edit: async (conversationId, messageId, userId, content) => {
|
||||
await assertMember(conversationId, userId);
|
||||
await assertMessageIn(conversationId, messageId);
|
||||
const edited = await messages.editContent(messageId, userId, content);
|
||||
if (edited === undefined) {
|
||||
throw new HttpError(403, 'not_editable', 'You can only edit your own messages');
|
||||
}
|
||||
const message = await loadMessage(messageId, userId);
|
||||
const event: MessageEditEvent = { type: 'message.edit', message };
|
||||
await deliver(conversationId, event);
|
||||
return message;
|
||||
},
|
||||
|
||||
remove: async (conversationId, messageId, userId) => {
|
||||
await assertMember(conversationId, userId);
|
||||
await assertMessageIn(conversationId, messageId);
|
||||
const deleted = await messages.softDelete(messageId, userId);
|
||||
if (deleted === undefined) {
|
||||
throw new HttpError(403, 'not_deletable', 'You can only delete your own messages');
|
||||
}
|
||||
const event: MessageDeleteEvent = { type: 'message.delete', conversationId, messageId };
|
||||
await deliver(conversationId, event);
|
||||
},
|
||||
|
||||
addReaction: async (conversationId, messageId, userId, emoji) => {
|
||||
await assertMember(conversationId, userId);
|
||||
await assertMessageIn(conversationId, messageId);
|
||||
await messages.addReaction(messageId, userId, emoji);
|
||||
const event: ReactionEvent = {
|
||||
type: 'reaction.add',
|
||||
conversationId,
|
||||
messageId,
|
||||
emoji,
|
||||
userId,
|
||||
};
|
||||
await deliver(conversationId, event);
|
||||
},
|
||||
|
||||
removeReaction: async (conversationId, messageId, userId, emoji) => {
|
||||
await assertMember(conversationId, userId);
|
||||
await assertMessageIn(conversationId, messageId);
|
||||
await messages.removeReaction(messageId, userId, emoji);
|
||||
const event: ReactionEvent = {
|
||||
type: 'reaction.remove',
|
||||
conversationId,
|
||||
messageId,
|
||||
emoji,
|
||||
userId,
|
||||
};
|
||||
await deliver(conversationId, event);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
3
packages/backend/src/modules/presence/index.ts
Normal file
3
packages/backend/src/modules/presence/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { createPresenceService } from './presence.service';
|
||||
export type { PresenceService, PresenceServiceDeps } from './presence.service';
|
||||
export { presenceRoutes } from './presence.routes';
|
||||
37
packages/backend/src/modules/presence/presence.routes.ts
Normal file
37
packages/backend/src/modules/presence/presence.routes.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { presenceListSchema, errorSchema } from '@altricade/core';
|
||||
|
||||
const querySchema = {
|
||||
type: 'object',
|
||||
required: ['userIds'],
|
||||
properties: { userIds: { type: 'string', minLength: 1 } },
|
||||
} as const;
|
||||
|
||||
export const presenceRoutes = (app: FastifyInstance): Promise<void> => {
|
||||
app.get<{ Querystring: { userIds: string } }>(
|
||||
'/presence',
|
||||
{
|
||||
schema: {
|
||||
tags: ['live'],
|
||||
summary: 'Online status + last-seen for a set of users (comma-separated ids)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
querystring: querySchema,
|
||||
response: { 200: presenceListSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) {
|
||||
return reply.code(401).send({ error: 'unauthorized' });
|
||||
}
|
||||
const ids = request.query.userIds
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0);
|
||||
return reply.send(await app.presenceService.getPresence(ids));
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
38
packages/backend/src/modules/presence/presence.service.ts
Normal file
38
packages/backend/src/modules/presence/presence.service.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { Presence } from '@altricade/core';
|
||||
import type { UsersRepository, UserRow } from '../users';
|
||||
|
||||
export interface PresenceServiceDeps {
|
||||
users: UsersRepository;
|
||||
isOnline: (userId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PresenceService {
|
||||
getPresence(userIds: string[]): Promise<Presence[]>;
|
||||
}
|
||||
|
||||
export const createPresenceService = (deps: PresenceServiceDeps): PresenceService => ({
|
||||
getPresence: async (userIds) => {
|
||||
const rows = await deps.users.findManyByIds(userIds);
|
||||
const byId = new Map<string, UserRow>(rows.map((row) => [row.id, row]));
|
||||
const result: Presence[] = [];
|
||||
for (const id of userIds) {
|
||||
const row = byId.get(id);
|
||||
if (row === undefined) {
|
||||
continue;
|
||||
}
|
||||
const online = await deps.isOnline(id);
|
||||
result.push({
|
||||
userId: id,
|
||||
online,
|
||||
lastSeenAt: row.last_seen_at === null ? null : row.last_seen_at.toISOString(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
presenceService: PresenceService;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,10 @@ export const realtimeRoutes = (app: FastifyInstance): Promise<void> => {
|
|||
async (request, reply) => {
|
||||
const { user, channel } = request.body;
|
||||
const decision = await authorizeSubscription(
|
||||
{ isRoomMember: (roomId, userId) => app.roomsService.isMember(roomId, userId) },
|
||||
{
|
||||
isConversationMember: (conversationId, userId) =>
|
||||
app.conversationsService.isMember(conversationId, userId),
|
||||
},
|
||||
user ?? '',
|
||||
channel,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { isUserChannel, roomChannelId, userChannel } from '@altricade/core';
|
||||
import { isUserChannel, conversationChannelId, userChannel } from '@altricade/core';
|
||||
|
||||
export type SubscribeDecision =
|
||||
| { allowed: true }
|
||||
| { allowed: false; errorCode: number; errorMessage: string };
|
||||
|
||||
export interface SubscribeDeps {
|
||||
isRoomMember: (roomId: string, userId: string) => Promise<boolean>;
|
||||
isConversationMember: (conversationId: string, userId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const deny = (errorMessage: string): SubscribeDecision => ({
|
||||
|
|
@ -16,7 +16,7 @@ const deny = (errorMessage: string): SubscribeDecision => ({
|
|||
|
||||
// Subscription authorization for the Centrifugo subscribe-proxy.
|
||||
// user:<id> → only that user's own personal channel.
|
||||
// room:<id> → only current members (live DB check → kicked users lose access).
|
||||
// conv:<id> → only current members (live DB check → kicked users lose access).
|
||||
export const authorizeSubscription = async (
|
||||
deps: SubscribeDeps,
|
||||
userId: string,
|
||||
|
|
@ -26,12 +26,14 @@ export const authorizeSubscription = async (
|
|||
return channel === userChannel(userId) ? { allowed: true } : deny('permission denied');
|
||||
}
|
||||
|
||||
const roomId = roomChannelId(channel);
|
||||
if (roomId !== null) {
|
||||
const conversationId = conversationChannelId(channel);
|
||||
if (conversationId !== null) {
|
||||
if (userId === '') {
|
||||
return deny('permission denied');
|
||||
}
|
||||
return (await deps.isRoomMember(roomId, userId)) ? { allowed: true } : deny('not a member');
|
||||
return (await deps.isConversationMember(conversationId, userId))
|
||||
? { allowed: true }
|
||||
: deny('not a member');
|
||||
}
|
||||
|
||||
return deny('unknown channel');
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
export { createRoomsRepository } from './rooms.repository';
|
||||
export type { RoomsRepository, RoomRow, RoomMemberWithUser } from './rooms.repository';
|
||||
export { createRoomsService } from './rooms.service';
|
||||
export type { RoomsService, RoomsServiceDeps } from './rooms.service';
|
||||
export { toRoom, toRoomMember } from './rooms.mapper';
|
||||
export { roomsRoutes } from './rooms.routes';
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import type { Room, RoomMember } from '@altricade/core';
|
||||
import type { RoomRow, RoomMemberWithUser } from './rooms.repository';
|
||||
|
||||
export const toRoom = (row: RoomRow): Room => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
});
|
||||
|
||||
export const toRoomMember = (row: RoomMemberWithUser): RoomMember => ({
|
||||
userId: row.user_id,
|
||||
role: row.role,
|
||||
joinedAt: row.joined_at.toISOString(),
|
||||
user: {
|
||||
id: row.user_id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
avatarUrl: row.avatar_ref,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import type { Kysely, Selectable } from 'kysely';
|
||||
import type { Database, RoomsTable } from '../../db/schema';
|
||||
|
||||
export type RoomRow = Selectable<RoomsTable>;
|
||||
|
||||
export interface RoomMemberWithUser {
|
||||
user_id: string;
|
||||
role: string;
|
||||
joined_at: Date;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_ref: string | null;
|
||||
}
|
||||
|
||||
export interface RoomsRepository {
|
||||
createWithOwner(name: string, ownerId: string): Promise<RoomRow>;
|
||||
findById(id: string): Promise<RoomRow | undefined>;
|
||||
listForUser(userId: string): Promise<RoomRow[]>;
|
||||
isMember(roomId: string, userId: string): Promise<boolean>;
|
||||
getRole(roomId: string, userId: string): Promise<string | undefined>;
|
||||
addMember(roomId: string, userId: string, role: string): Promise<void>;
|
||||
removeMember(roomId: string, userId: string): Promise<void>;
|
||||
listMembers(roomId: string): Promise<RoomMemberWithUser[]>;
|
||||
}
|
||||
|
||||
export const createRoomsRepository = (db: Kysely<Database>): RoomsRepository => ({
|
||||
createWithOwner: (name, ownerId) =>
|
||||
db.transaction().execute(async (trx) => {
|
||||
const room = await trx
|
||||
.insertInto('rooms')
|
||||
.values({ name, created_by: ownerId })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await trx
|
||||
.insertInto('room_members')
|
||||
.values({ room_id: room.id, user_id: ownerId, role: 'owner' })
|
||||
.execute();
|
||||
return room;
|
||||
}),
|
||||
|
||||
findById: (id) => db.selectFrom('rooms').selectAll().where('id', '=', id).executeTakeFirst(),
|
||||
|
||||
listForUser: (userId) =>
|
||||
db
|
||||
.selectFrom('rooms')
|
||||
.innerJoin('room_members', 'room_members.room_id', 'rooms.id')
|
||||
.where('room_members.user_id', '=', userId)
|
||||
.selectAll('rooms')
|
||||
.orderBy('rooms.created_at', 'desc')
|
||||
.execute(),
|
||||
|
||||
isMember: async (roomId, userId) => {
|
||||
const row = await db
|
||||
.selectFrom('room_members')
|
||||
.select('user_id')
|
||||
.where('room_id', '=', roomId)
|
||||
.where('user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return row !== undefined;
|
||||
},
|
||||
|
||||
getRole: async (roomId, userId) => {
|
||||
const row = await db
|
||||
.selectFrom('room_members')
|
||||
.select('role')
|
||||
.where('room_id', '=', roomId)
|
||||
.where('user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return row?.role;
|
||||
},
|
||||
|
||||
addMember: async (roomId, userId, role) => {
|
||||
await db
|
||||
.insertInto('room_members')
|
||||
.values({ room_id: roomId, user_id: userId, role })
|
||||
.onConflict((oc) => oc.columns(['room_id', 'user_id']).doNothing())
|
||||
.execute();
|
||||
},
|
||||
|
||||
removeMember: async (roomId, userId) => {
|
||||
await db
|
||||
.deleteFrom('room_members')
|
||||
.where('room_id', '=', roomId)
|
||||
.where('user_id', '=', userId)
|
||||
.execute();
|
||||
},
|
||||
|
||||
listMembers: (roomId) =>
|
||||
db
|
||||
.selectFrom('room_members')
|
||||
.innerJoin('users', 'users.id', 'room_members.user_id')
|
||||
.where('room_members.room_id', '=', roomId)
|
||||
.select([
|
||||
'room_members.user_id as user_id',
|
||||
'room_members.role as role',
|
||||
'room_members.joined_at as joined_at',
|
||||
'users.username as username',
|
||||
'users.display_name as display_name',
|
||||
'users.avatar_ref as avatar_ref',
|
||||
])
|
||||
.orderBy('room_members.joined_at', 'asc')
|
||||
.execute(),
|
||||
});
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
createRoomBodySchema,
|
||||
addMemberBodySchema,
|
||||
roomSchema,
|
||||
roomListSchema,
|
||||
roomMemberSchema,
|
||||
roomMemberListSchema,
|
||||
errorSchema,
|
||||
} from '@altricade/core';
|
||||
import type { CreateRoomBody, AddMemberBody } from '@altricade/core';
|
||||
|
||||
const bearerAuth = [{ bearerAuth: [] }];
|
||||
const roomParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
} as const;
|
||||
const memberParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['id', 'userId'],
|
||||
properties: { id: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' } },
|
||||
} as const;
|
||||
|
||||
export const roomsRoutes = (app: FastifyInstance): Promise<void> => {
|
||||
app.post<{ Body: CreateRoomBody }>(
|
||||
'/rooms',
|
||||
{
|
||||
schema: {
|
||||
tags: ['rooms'],
|
||||
summary: 'Create a room',
|
||||
security: bearerAuth,
|
||||
body: createRoomBodySchema,
|
||||
response: { 201: roomSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const room = await app.roomsService.create(request.body.name, user.id);
|
||||
return reply.code(201).send(room);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/rooms',
|
||||
{
|
||||
schema: {
|
||||
tags: ['rooms'],
|
||||
summary: 'List rooms I belong to',
|
||||
security: bearerAuth,
|
||||
response: { 200: roomListSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
return reply.send(await app.roomsService.listMine(user.id));
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/rooms/:id',
|
||||
{
|
||||
schema: {
|
||||
tags: ['rooms'],
|
||||
summary: 'Get a room (member only)',
|
||||
security: bearerAuth,
|
||||
params: roomParamsSchema,
|
||||
response: { 200: roomSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
return reply.send(await app.roomsService.get(request.params.id, user.id));
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/rooms/:id/members',
|
||||
{
|
||||
schema: {
|
||||
tags: ['rooms'],
|
||||
summary: 'List room members (member only)',
|
||||
security: bearerAuth,
|
||||
params: roomParamsSchema,
|
||||
response: { 200: roomMemberListSchema, 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
return reply.send(await app.roomsService.listMembers(request.params.id, user.id));
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: AddMemberBody }>(
|
||||
'/rooms/:id/members',
|
||||
{
|
||||
schema: {
|
||||
tags: ['rooms'],
|
||||
summary: 'Add a member by username (owner only)',
|
||||
security: bearerAuth,
|
||||
params: roomParamsSchema,
|
||||
body: addMemberBodySchema,
|
||||
response: { 201: roomMemberSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const member = await app.roomsService.addMember(request.params.id, user.id, request.body.username);
|
||||
return reply.code(201).send(member);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string; userId: string } }>(
|
||||
'/rooms/:id/members/:userId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['rooms'],
|
||||
summary: 'Remove a member (owner) or leave (self)',
|
||||
security: bearerAuth,
|
||||
params: memberParamsSchema,
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
await app.roomsService.removeMember(request.params.id, user.id, request.params.userId);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import type { Room, RoomMember, RoomMembershipEvent } from '@altricade/core';
|
||||
import { userChannel } from '@altricade/core';
|
||||
import { HttpError } from '../../shared/http-error';
|
||||
import type { Publisher } from '../../shared/publisher';
|
||||
import type { UsersRepository } from '../users';
|
||||
import type { RoomsRepository } from './rooms.repository';
|
||||
import { toRoom, toRoomMember } from './rooms.mapper';
|
||||
|
||||
export interface RoomsServiceDeps {
|
||||
rooms: RoomsRepository;
|
||||
users: UsersRepository;
|
||||
publish: Publisher;
|
||||
}
|
||||
|
||||
export interface RoomsService {
|
||||
create(name: string, ownerId: string): Promise<Room>;
|
||||
get(roomId: string, userId: string): Promise<Room>;
|
||||
listMine(userId: string): Promise<Room[]>;
|
||||
listMembers(roomId: string, userId: string): Promise<RoomMember[]>;
|
||||
addMember(roomId: string, actorId: string, username: string): Promise<RoomMember>;
|
||||
removeMember(roomId: string, actorId: string, targetUserId: string): Promise<void>;
|
||||
isMember(roomId: string, userId: string): Promise<boolean>;
|
||||
assertMember(roomId: string, userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const createRoomsService = (deps: RoomsServiceDeps): RoomsService => {
|
||||
const { rooms, users, publish } = deps;
|
||||
|
||||
const assertMember = async (roomId: string, userId: string): Promise<void> => {
|
||||
if (!(await rooms.isMember(roomId, userId))) {
|
||||
throw new HttpError(403, 'not_a_member', 'You are not a member of this room');
|
||||
}
|
||||
};
|
||||
|
||||
const assertOwner = async (roomId: string, userId: string): Promise<void> => {
|
||||
const role = await rooms.getRole(roomId, userId);
|
||||
if (role !== 'owner') {
|
||||
throw new HttpError(403, 'not_owner', 'Only the room owner can do this');
|
||||
}
|
||||
};
|
||||
|
||||
const membershipEvent = (
|
||||
action: RoomMembershipEvent['action'],
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): RoomMembershipEvent => ({ type: 'room.membership', action, roomId, userId });
|
||||
|
||||
return {
|
||||
create: async (name, ownerId) => toRoom(await rooms.createWithOwner(name, ownerId)),
|
||||
|
||||
get: async (roomId, userId) => {
|
||||
await assertMember(roomId, userId);
|
||||
const room = await rooms.findById(roomId);
|
||||
if (room === undefined) {
|
||||
throw new HttpError(404, 'not_found', 'Room not found');
|
||||
}
|
||||
return toRoom(room);
|
||||
},
|
||||
|
||||
listMine: async (userId) => (await rooms.listForUser(userId)).map(toRoom),
|
||||
|
||||
listMembers: async (roomId, userId) => {
|
||||
await assertMember(roomId, userId);
|
||||
return (await rooms.listMembers(roomId)).map(toRoomMember);
|
||||
},
|
||||
|
||||
addMember: async (roomId, actorId, username) => {
|
||||
await assertOwner(roomId, actorId);
|
||||
const target = await users.findByUsername(username);
|
||||
if (target === undefined) {
|
||||
throw new HttpError(404, 'user_not_found', 'No such user');
|
||||
}
|
||||
await rooms.addMember(roomId, target.id, 'member');
|
||||
await publish(userChannel(target.id), membershipEvent('added', roomId, target.id));
|
||||
const members = await rooms.listMembers(roomId);
|
||||
const added = members.find((m) => m.user_id === target.id);
|
||||
if (added === undefined) {
|
||||
throw new HttpError(500, 'internal_error', 'Member not found after add');
|
||||
}
|
||||
return toRoomMember(added);
|
||||
},
|
||||
|
||||
removeMember: async (roomId, actorId, targetUserId) => {
|
||||
if (actorId !== targetUserId) {
|
||||
await assertOwner(roomId, actorId);
|
||||
}
|
||||
await rooms.removeMember(roomId, targetUserId);
|
||||
await publish(userChannel(targetUserId), membershipEvent('removed', roomId, targetUserId));
|
||||
},
|
||||
|
||||
isMember: (roomId, userId) => rooms.isMember(roomId, userId),
|
||||
|
||||
assertMember,
|
||||
};
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
roomsService: RoomsService;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ export interface UsersRepository {
|
|||
create(input: NewUser): Promise<UserRow>;
|
||||
findByUsername(username: string): Promise<UserRow | undefined>;
|
||||
findById(id: string): Promise<UserRow | undefined>;
|
||||
findManyByIds(ids: string[]): Promise<UserRow[]>;
|
||||
searchByPrefix(query: string, excludeUserId: string, limit: number): Promise<UserRow[]>;
|
||||
updateProfile(id: string, patch: ProfilePatch): Promise<UserRow | undefined>;
|
||||
touchLastSeen(id: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -44,6 +46,23 @@ export const createUsersRepository = (db: Kysely<Database>): UsersRepository =>
|
|||
|
||||
findById: (id) => db.selectFrom('users').selectAll().where('id', '=', id).executeTakeFirst(),
|
||||
|
||||
findManyByIds: (ids) =>
|
||||
ids.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.selectFrom('users').selectAll().where('id', 'in', ids).execute(),
|
||||
|
||||
searchByPrefix: (query, excludeUserId, limit) =>
|
||||
db
|
||||
.selectFrom('users')
|
||||
.selectAll()
|
||||
.where('id', '!=', excludeUserId)
|
||||
.where((eb) =>
|
||||
eb.or([eb('username', 'ilike', `${query}%`), eb('display_name', 'ilike', `${query}%`)]),
|
||||
)
|
||||
.orderBy('username', 'asc')
|
||||
.limit(limit)
|
||||
.execute(),
|
||||
|
||||
updateProfile: (id, patch) => {
|
||||
const values: Updateable<UsersTable> = { updated_at: new Date() };
|
||||
if (patch.displayName !== undefined) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { updateMeBodySchema, userSchema, publicUserSchema, errorSchema } from '@altricade/core';
|
||||
import {
|
||||
updateMeBodySchema,
|
||||
userSchema,
|
||||
publicUserSchema,
|
||||
publicUserListSchema,
|
||||
errorSchema,
|
||||
} from '@altricade/core';
|
||||
import type { UpdateMeBody } from '@altricade/core';
|
||||
|
||||
const searchQuerySchema = {
|
||||
type: 'object',
|
||||
required: ['q'],
|
||||
properties: { q: { type: 'string', minLength: 1, maxLength: 64 } },
|
||||
} as const;
|
||||
|
||||
const usernameParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['username'],
|
||||
|
|
@ -54,6 +66,47 @@ export const usersRoutes = (app: FastifyInstance): Promise<void> => {
|
|||
},
|
||||
);
|
||||
|
||||
app.get<{ Querystring: { q: string } }>(
|
||||
'/users/search',
|
||||
{
|
||||
schema: {
|
||||
tags: ['users'],
|
||||
summary: 'Search users by username or display name',
|
||||
security: bearerAuth,
|
||||
querystring: searchQuerySchema,
|
||||
response: { 200: publicUserListSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) {
|
||||
return reply.code(401).send({ error: 'unauthorized' });
|
||||
}
|
||||
return reply.send(await app.usersService.search(request.query.q, user.id));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/me/heartbeat',
|
||||
{
|
||||
schema: {
|
||||
tags: ['users'],
|
||||
summary: 'Keep-alive: bump last_seen',
|
||||
security: bearerAuth,
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) {
|
||||
return reply.code(401).send({ error: 'unauthorized' });
|
||||
}
|
||||
await app.usersService.heartbeat(user.id);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { username: string } }>(
|
||||
'/users/:username',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,8 +7,15 @@ export interface UsersService {
|
|||
getMe(userId: string): Promise<User>;
|
||||
getPublicProfile(username: string): Promise<PublicUser>;
|
||||
updateProfile(userId: string, patch: UpdateMeBody): Promise<User>;
|
||||
search(query: string, excludeUserId: string): Promise<PublicUser[]>;
|
||||
heartbeat(userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
const SEARCH_LIMIT = 20;
|
||||
|
||||
// Escape LIKE wildcards so user input is matched literally.
|
||||
const escapeLike = (value: string): string => value.replace(/[\\%_]/g, '\\$&');
|
||||
|
||||
export const createUsersService = (users: UsersRepository): UsersService => ({
|
||||
getMe: async (userId) => {
|
||||
const row = await users.findById(userId);
|
||||
|
|
@ -33,6 +40,17 @@ export const createUsersService = (users: UsersRepository): UsersService => ({
|
|||
}
|
||||
return toUser(row);
|
||||
},
|
||||
|
||||
search: async (query, excludeUserId) => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const rows = await users.searchByPrefix(escapeLike(trimmed), excludeUserId, SEARCH_LIMIT);
|
||||
return rows.map(toPublicUser);
|
||||
},
|
||||
|
||||
heartbeat: (userId) => users.touchLastSeen(userId),
|
||||
});
|
||||
|
||||
declare module 'fastify' {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,22 @@ import fp from 'fastify-plugin';
|
|||
export interface CentrifugoClient {
|
||||
/** Publish an event payload into a channel via Centrifugo's server HTTP API. */
|
||||
publish(channel: string, data: unknown): Promise<void>;
|
||||
/** Number of clients currently present in a channel (0 if none / on error). */
|
||||
presenceStats(channel: string): Promise<number>;
|
||||
}
|
||||
|
||||
const extractNumClients = (data: unknown): number => {
|
||||
if (typeof data !== 'object' || data === null || !('result' in data)) {
|
||||
return 0;
|
||||
}
|
||||
const result = data.result;
|
||||
if (typeof result !== 'object' || result === null || !('num_clients' in result)) {
|
||||
return 0;
|
||||
}
|
||||
const numClients = result.num_clients;
|
||||
return typeof numClients === 'number' ? numClients : 0;
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
centrifugo: CentrifugoClient;
|
||||
|
|
@ -31,6 +45,22 @@ export const centrifugoPlugin = fp(
|
|||
throw new Error(`Centrifugo publish failed with status ${String(response.status)}`);
|
||||
}
|
||||
},
|
||||
|
||||
async presenceStats(channel) {
|
||||
const response = await fetch(`${apiUrl}/presence_stats`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({ channel }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return 0;
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
return extractNumClients(data);
|
||||
},
|
||||
};
|
||||
|
||||
app.decorate('centrifugo', client);
|
||||
|
|
|
|||
20
packages/core/src/api/contacts.ts
Normal file
20
packages/core/src/api/contacts.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { contactListSchema, contactSchema } from '../schemas/index';
|
||||
import type { AddContactBody } from '../schemas/index';
|
||||
import type { Contact } from '../types/index';
|
||||
import { compileValidator, parse, requestJson } from './http';
|
||||
import type { ApiClientConfig } from './http';
|
||||
|
||||
const contactV = compileValidator<Contact>(contactSchema);
|
||||
const contactListV = compileValidator<Contact[]>(contactListSchema);
|
||||
|
||||
export const listContacts = async (config: ApiClientConfig): Promise<Contact[]> =>
|
||||
parse(contactListV, await requestJson(config, 'GET', '/contacts'));
|
||||
|
||||
export const addContact = async (
|
||||
config: ApiClientConfig,
|
||||
body: AddContactBody,
|
||||
): Promise<Contact> => parse(contactV, await requestJson(config, 'POST', '/contacts', body));
|
||||
|
||||
export const removeContact = async (config: ApiClientConfig, userId: string): Promise<void> => {
|
||||
await requestJson(config, 'DELETE', `/contacts/${userId}`);
|
||||
};
|
||||
71
packages/core/src/api/conversations.ts
Normal file
71
packages/core/src/api/conversations.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import {
|
||||
conversationSchema,
|
||||
conversationListSchema,
|
||||
conversationMemberListSchema,
|
||||
} from '../schemas/index';
|
||||
import type { CreateDirectBody, CreateGroupBody, AddMemberBody } from '../schemas/index';
|
||||
import type { Conversation, ConversationMember } from '../types/index';
|
||||
import { compileValidator, parse, requestJson } from './http';
|
||||
import type { ApiClientConfig } from './http';
|
||||
|
||||
const conversationV = compileValidator<Conversation>(conversationSchema);
|
||||
const conversationListV = compileValidator<Conversation[]>(conversationListSchema);
|
||||
const memberListV = compileValidator<ConversationMember[]>(conversationMemberListSchema);
|
||||
|
||||
export const listConversations = async (config: ApiClientConfig): Promise<Conversation[]> =>
|
||||
parse(conversationListV, await requestJson(config, 'GET', '/conversations'));
|
||||
|
||||
export const getConversation = async (
|
||||
config: ApiClientConfig,
|
||||
id: string,
|
||||
): Promise<Conversation> =>
|
||||
parse(conversationV, await requestJson(config, 'GET', `/conversations/${id}`));
|
||||
|
||||
export const createDirect = async (
|
||||
config: ApiClientConfig,
|
||||
body: CreateDirectBody,
|
||||
): Promise<Conversation> =>
|
||||
parse(conversationV, await requestJson(config, 'POST', '/conversations/direct', body));
|
||||
|
||||
export const createGroup = async (
|
||||
config: ApiClientConfig,
|
||||
body: CreateGroupBody,
|
||||
): Promise<Conversation> =>
|
||||
parse(conversationV, await requestJson(config, 'POST', '/conversations', body));
|
||||
|
||||
export const listMembers = async (
|
||||
config: ApiClientConfig,
|
||||
id: string,
|
||||
): Promise<ConversationMember[]> =>
|
||||
parse(memberListV, await requestJson(config, 'GET', `/conversations/${id}/members`));
|
||||
|
||||
export const addMember = async (
|
||||
config: ApiClientConfig,
|
||||
id: string,
|
||||
body: AddMemberBody,
|
||||
): Promise<ConversationMember[]> =>
|
||||
parse(memberListV, await requestJson(config, 'POST', `/conversations/${id}/members`, body));
|
||||
|
||||
export const removeMember = async (
|
||||
config: ApiClientConfig,
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<void> => {
|
||||
await requestJson(config, 'DELETE', `/conversations/${id}/members/${userId}`);
|
||||
};
|
||||
|
||||
export const markRead = async (
|
||||
config: ApiClientConfig,
|
||||
id: string,
|
||||
seq: number,
|
||||
): Promise<void> => {
|
||||
await requestJson(config, 'POST', `/conversations/${id}/read`, { seq });
|
||||
};
|
||||
|
||||
export const sendTyping = async (
|
||||
config: ApiClientConfig,
|
||||
id: string,
|
||||
state: 'start' | 'stop',
|
||||
): Promise<void> => {
|
||||
await requestJson(config, 'POST', `/conversations/${id}/typing`, { state });
|
||||
};
|
||||
|
|
@ -13,6 +13,25 @@ export {
|
|||
getCentrifugoToken,
|
||||
} from './auth';
|
||||
export { sendEcho } from './realtime';
|
||||
export { createRoom, listRooms, getRoom, listMembers, addMember, removeMember } from './rooms';
|
||||
export { sendMessage, getHistory } from './messages';
|
||||
export { searchUsers } from './users';
|
||||
export {
|
||||
listConversations,
|
||||
getConversation,
|
||||
createDirect,
|
||||
createGroup,
|
||||
listMembers,
|
||||
addMember,
|
||||
removeMember,
|
||||
} from './conversations';
|
||||
export { listContacts, addContact, removeContact } from './contacts';
|
||||
export {
|
||||
sendMessage,
|
||||
getHistory,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
} from './messages';
|
||||
export type { HistoryOptions } from './messages';
|
||||
export { markRead, sendTyping } from './conversations';
|
||||
export { getPresence, heartbeat } from './presence';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { messageSchema, messageListSchema } from '../schemas/index';
|
||||
import type { SendMessageBody } from '../schemas/index';
|
||||
import type { SendMessageBody, EditMessageBody, ReactionBody } from '../schemas/index';
|
||||
import type { Message } from '../types/index';
|
||||
import { compileValidator, parse, requestJson } from './http';
|
||||
import type { ApiClientConfig } from './http';
|
||||
|
|
@ -9,10 +9,10 @@ const messageListV = compileValidator<Message[]>(messageListSchema);
|
|||
|
||||
export const sendMessage = async (
|
||||
config: ApiClientConfig,
|
||||
roomId: string,
|
||||
conversationId: string,
|
||||
body: SendMessageBody,
|
||||
): Promise<Message> =>
|
||||
parse(messageV, await requestJson(config, 'POST', `/rooms/${roomId}/messages`, body));
|
||||
parse(messageV, await requestJson(config, 'POST', `/conversations/${conversationId}/messages`, body));
|
||||
|
||||
export interface HistoryOptions {
|
||||
before?: number;
|
||||
|
|
@ -21,7 +21,7 @@ export interface HistoryOptions {
|
|||
|
||||
export const getHistory = async (
|
||||
config: ApiClientConfig,
|
||||
roomId: string,
|
||||
conversationId: string,
|
||||
options: HistoryOptions = {},
|
||||
): Promise<Message[]> => {
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -32,6 +32,53 @@ export const getHistory = async (
|
|||
params.set('limit', String(options.limit));
|
||||
}
|
||||
const qs = params.toString();
|
||||
const path = qs.length > 0 ? `/rooms/${roomId}/messages?${qs}` : `/rooms/${roomId}/messages`;
|
||||
const base = `/conversations/${conversationId}/messages`;
|
||||
const path = qs.length > 0 ? `${base}?${qs}` : base;
|
||||
return parse(messageListV, await requestJson(config, 'GET', path));
|
||||
};
|
||||
|
||||
export const editMessage = async (
|
||||
config: ApiClientConfig,
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
body: EditMessageBody,
|
||||
): Promise<Message> =>
|
||||
parse(
|
||||
messageV,
|
||||
await requestJson(config, 'PATCH', `/conversations/${conversationId}/messages/${messageId}`, body),
|
||||
);
|
||||
|
||||
export const deleteMessage = async (
|
||||
config: ApiClientConfig,
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
): Promise<void> => {
|
||||
await requestJson(config, 'DELETE', `/conversations/${conversationId}/messages/${messageId}`);
|
||||
};
|
||||
|
||||
export const addReaction = async (
|
||||
config: ApiClientConfig,
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
body: ReactionBody,
|
||||
): Promise<void> => {
|
||||
await requestJson(
|
||||
config,
|
||||
'POST',
|
||||
`/conversations/${conversationId}/messages/${messageId}/reactions`,
|
||||
body,
|
||||
);
|
||||
};
|
||||
|
||||
export const removeReaction = async (
|
||||
config: ApiClientConfig,
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
): Promise<void> => {
|
||||
await requestJson(
|
||||
config,
|
||||
'DELETE',
|
||||
`/conversations/${conversationId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
21
packages/core/src/api/presence.ts
Normal file
21
packages/core/src/api/presence.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { presenceListSchema } from '../schemas/index';
|
||||
import type { Presence } from '../types/index';
|
||||
import { compileValidator, parse, requestJson } from './http';
|
||||
import type { ApiClientConfig } from './http';
|
||||
|
||||
const presenceListV = compileValidator<Presence[]>(presenceListSchema);
|
||||
|
||||
export const getPresence = async (
|
||||
config: ApiClientConfig,
|
||||
userIds: string[],
|
||||
): Promise<Presence[]> => {
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const qs = encodeURIComponent(userIds.join(','));
|
||||
return parse(presenceListV, await requestJson(config, 'GET', `/presence?userIds=${qs}`));
|
||||
};
|
||||
|
||||
export const heartbeat = async (config: ApiClientConfig): Promise<void> => {
|
||||
await requestJson(config, 'POST', '/me/heartbeat');
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import {
|
||||
roomSchema,
|
||||
roomListSchema,
|
||||
roomMemberSchema,
|
||||
roomMemberListSchema,
|
||||
} from '../schemas/index';
|
||||
import type { CreateRoomBody, AddMemberBody } from '../schemas/index';
|
||||
import type { Room, RoomMember } from '../types/index';
|
||||
import { compileValidator, parse, requestJson } from './http';
|
||||
import type { ApiClientConfig } from './http';
|
||||
|
||||
const roomV = compileValidator<Room>(roomSchema);
|
||||
const roomListV = compileValidator<Room[]>(roomListSchema);
|
||||
const memberV = compileValidator<RoomMember>(roomMemberSchema);
|
||||
const memberListV = compileValidator<RoomMember[]>(roomMemberListSchema);
|
||||
|
||||
export const createRoom = async (config: ApiClientConfig, body: CreateRoomBody): Promise<Room> =>
|
||||
parse(roomV, await requestJson(config, 'POST', '/rooms', body));
|
||||
|
||||
export const listRooms = async (config: ApiClientConfig): Promise<Room[]> =>
|
||||
parse(roomListV, await requestJson(config, 'GET', '/rooms'));
|
||||
|
||||
export const getRoom = async (config: ApiClientConfig, roomId: string): Promise<Room> =>
|
||||
parse(roomV, await requestJson(config, 'GET', `/rooms/${roomId}`));
|
||||
|
||||
export const listMembers = async (
|
||||
config: ApiClientConfig,
|
||||
roomId: string,
|
||||
): Promise<RoomMember[]> =>
|
||||
parse(memberListV, await requestJson(config, 'GET', `/rooms/${roomId}/members`));
|
||||
|
||||
export const addMember = async (
|
||||
config: ApiClientConfig,
|
||||
roomId: string,
|
||||
body: AddMemberBody,
|
||||
): Promise<RoomMember> =>
|
||||
parse(memberV, await requestJson(config, 'POST', `/rooms/${roomId}/members`, body));
|
||||
|
||||
export const removeMember = async (
|
||||
config: ApiClientConfig,
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<void> => {
|
||||
await requestJson(config, 'DELETE', `/rooms/${roomId}/members/${userId}`);
|
||||
};
|
||||
15
packages/core/src/api/users.ts
Normal file
15
packages/core/src/api/users.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { publicUserListSchema } from '../schemas/index';
|
||||
import type { PublicUser } from '../types/index';
|
||||
import { compileValidator, parse, requestJson } from './http';
|
||||
import type { ApiClientConfig } from './http';
|
||||
|
||||
const publicUserListV = compileValidator<PublicUser[]>(publicUserListSchema);
|
||||
|
||||
export const searchUsers = async (
|
||||
config: ApiClientConfig,
|
||||
query: string,
|
||||
): Promise<PublicUser[]> =>
|
||||
parse(
|
||||
publicUserListV,
|
||||
await requestJson(config, 'GET', `/users/search?q=${encodeURIComponent(query)}`),
|
||||
);
|
||||
|
|
@ -1,28 +1,30 @@
|
|||
// Channel-name builders — the single source of truth for Centrifugo channel
|
||||
// names, shared by backend (publish/subscribe-proxy) and every client.
|
||||
//
|
||||
// room:<roomId> — one channel per room; all members subscribe.
|
||||
// user:<userId> — one personal channel per user; DM + notification delivery.
|
||||
// conv:<conversationId> — one channel per GROUP conversation; members subscribe.
|
||||
// user:<userId> — one personal channel per user. Direct-message delivery,
|
||||
// notifications, "added to a chat", etc.
|
||||
|
||||
export type RoomId = string;
|
||||
export type ConversationId = string;
|
||||
export type UserId = string;
|
||||
|
||||
const ROOM_PREFIX = 'room:';
|
||||
const CONV_PREFIX = 'conv:';
|
||||
const USER_PREFIX = 'user:';
|
||||
|
||||
export const roomChannel = (roomId: RoomId): string => `${ROOM_PREFIX}${roomId}`;
|
||||
export const conversationChannel = (conversationId: ConversationId): string =>
|
||||
`${CONV_PREFIX}${conversationId}`;
|
||||
|
||||
export const userChannel = (userId: UserId): string => `${USER_PREFIX}${userId}`;
|
||||
|
||||
/** True for a `room:<id>` channel name. */
|
||||
export const isRoomChannel = (channel: string): boolean => channel.startsWith(ROOM_PREFIX);
|
||||
/** True for a `conv:<id>` channel name. */
|
||||
export const isConversationChannel = (channel: string): boolean => channel.startsWith(CONV_PREFIX);
|
||||
|
||||
/** True for a `user:<id>` channel name. */
|
||||
export const isUserChannel = (channel: string): boolean => channel.startsWith(USER_PREFIX);
|
||||
|
||||
/** Extract the room id from a `room:<id>` channel, or null. */
|
||||
export const roomChannelId = (channel: string): string | null =>
|
||||
channel.startsWith(ROOM_PREFIX) ? channel.slice(ROOM_PREFIX.length) : null;
|
||||
/** Extract the conversation id from a `conv:<id>` channel, or null. */
|
||||
export const conversationChannelId = (channel: string): string | null =>
|
||||
channel.startsWith(CONV_PREFIX) ? channel.slice(CONV_PREFIX.length) : null;
|
||||
|
||||
/** Extract the user id from a `user:<id>` channel, or null. */
|
||||
export const userChannelId = (channel: string): string | null =>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
// Bucket C — connection-derived → Centrifugo built-in presence
|
||||
|
||||
import type { Message } from '../types/message';
|
||||
import type { Conversation } from '../types/conversation';
|
||||
|
||||
export const EventType = {
|
||||
// Bucket A
|
||||
|
|
@ -17,7 +18,8 @@ export const EventType = {
|
|||
ReactionRemove: 'reaction.remove',
|
||||
ReadReceipt: 'read.receipt',
|
||||
LastSeen: 'last_seen',
|
||||
RoomMembership: 'room.membership',
|
||||
ConversationNew: 'conversation.new',
|
||||
ConversationMembership: 'conversation.membership',
|
||||
ProfileUpdate: 'profile.update',
|
||||
|
||||
// Bucket B
|
||||
|
|
@ -39,11 +41,55 @@ export interface MessageNewEvent {
|
|||
message: Message;
|
||||
}
|
||||
|
||||
export type RoomMembershipAction = 'added' | 'removed';
|
||||
// Published to a user's personal channel when a new conversation involving them
|
||||
// is created (a DM someone started, or a group they were added to) so it appears
|
||||
// in their list immediately.
|
||||
export interface ConversationNewEvent {
|
||||
type: 'conversation.new';
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
export interface RoomMembershipEvent {
|
||||
type: 'room.membership';
|
||||
action: RoomMembershipAction;
|
||||
roomId: string;
|
||||
export type ConversationMembershipAction = 'added' | 'removed';
|
||||
|
||||
export interface ConversationMembershipEvent {
|
||||
type: 'conversation.membership';
|
||||
action: ConversationMembershipAction;
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface MessageEditEvent {
|
||||
type: 'message.edit';
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export interface MessageDeleteEvent {
|
||||
type: 'message.delete';
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export type ReactionAction = 'reaction.add' | 'reaction.remove';
|
||||
|
||||
export interface ReactionEvent {
|
||||
type: ReactionAction;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ReadReceiptEvent {
|
||||
type: 'read.receipt';
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export type TypingState = 'typing.start' | 'typing.stop';
|
||||
|
||||
export interface TypingEvent {
|
||||
type: TypingState;
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
|
|
|||
62
packages/core/src/schemas/conversation.ts
Normal file
62
packages/core/src/schemas/conversation.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
|
||||
const USERNAME_PATTERN = '^[a-zA-Z0-9_]{3,32}$';
|
||||
|
||||
export const createDirectBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['username'],
|
||||
properties: {
|
||||
username: { type: 'string', pattern: USERNAME_PATTERN },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const createGroupBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['title'],
|
||||
properties: {
|
||||
title: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
members: {
|
||||
type: 'array',
|
||||
maxItems: 200,
|
||||
items: { type: 'string', pattern: USERNAME_PATTERN },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const addMemberBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['username'],
|
||||
properties: {
|
||||
username: { type: 'string', pattern: USERNAME_PATTERN },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const sendMessageBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['content', 'clientMsgId'],
|
||||
properties: {
|
||||
content: { type: 'string', minLength: 1, maxLength: 16000 },
|
||||
clientMsgId: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
contentType: { type: 'string', enum: ['text', 'text/ciphertext'] },
|
||||
encryption: { type: ['string', 'null'] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const addContactBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['username'],
|
||||
properties: {
|
||||
username: { type: 'string', pattern: USERNAME_PATTERN },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type CreateDirectBody = FromSchema<typeof createDirectBodySchema>;
|
||||
export type CreateGroupBody = FromSchema<typeof createGroupBodySchema>;
|
||||
export type AddMemberBody = FromSchema<typeof addMemberBodySchema>;
|
||||
export type SendMessageBody = FromSchema<typeof sendMessageBodySchema>;
|
||||
export type AddContactBody = FromSchema<typeof addContactBodySchema>;
|
||||
|
|
@ -90,21 +90,62 @@ export const errorSchema = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
export const roomSchema = {
|
||||
export const publicUserListSchema = { type: 'array', items: publicUserSchema } as const;
|
||||
|
||||
export const conversationSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id', 'name', 'createdBy', 'createdAt'],
|
||||
required: [
|
||||
'id',
|
||||
'type',
|
||||
'title',
|
||||
'peer',
|
||||
'createdBy',
|
||||
'createdAt',
|
||||
'lastMessageAt',
|
||||
'unreadCount',
|
||||
],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string', enum: ['direct', 'group'] },
|
||||
title: { type: ['string', 'null'] },
|
||||
peer: {
|
||||
oneOf: [publicUserSchema, { type: 'null' }],
|
||||
},
|
||||
createdBy: { type: 'string', format: 'uuid' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
lastMessageAt: { type: 'string', format: 'date-time' },
|
||||
unreadCount: { type: 'integer' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const roomListSchema = { type: 'array', items: roomSchema } as const;
|
||||
export const reactionSummarySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['emoji', 'count', 'mine'],
|
||||
properties: {
|
||||
emoji: { type: 'string' },
|
||||
count: { type: 'integer' },
|
||||
mine: { type: 'boolean' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const roomMemberSchema = {
|
||||
export const presenceSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['userId', 'online', 'lastSeenAt'],
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'uuid' },
|
||||
online: { type: 'boolean' },
|
||||
lastSeenAt: { type: ['string', 'null'], format: 'date-time' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const presenceListSchema = { type: 'array', items: presenceSchema } as const;
|
||||
|
||||
export const conversationListSchema = { type: 'array', items: conversationSchema } as const;
|
||||
|
||||
export const conversationMemberSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['userId', 'role', 'joinedAt', 'user'],
|
||||
|
|
@ -116,14 +157,30 @@ export const roomMemberSchema = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
export const roomMemberListSchema = { type: 'array', items: roomMemberSchema } as const;
|
||||
export const conversationMemberListSchema = {
|
||||
type: 'array',
|
||||
items: conversationMemberSchema,
|
||||
} as const;
|
||||
|
||||
export const contactSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['userId', 'user', 'createdAt'],
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'uuid' },
|
||||
user: publicUserSchema,
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const contactListSchema = { type: 'array', items: contactSchema } as const;
|
||||
|
||||
export const messageSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
'id',
|
||||
'roomId',
|
||||
'conversationId',
|
||||
'senderId',
|
||||
'sender',
|
||||
'content',
|
||||
|
|
@ -132,10 +189,13 @@ export const messageSchema = {
|
|||
'clientMsgId',
|
||||
'seq',
|
||||
'createdAt',
|
||||
'editedAt',
|
||||
'deletedAt',
|
||||
'reactions',
|
||||
],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
roomId: { type: 'string', format: 'uuid' },
|
||||
conversationId: { type: 'string', format: 'uuid' },
|
||||
senderId: { type: 'string', format: 'uuid' },
|
||||
sender: publicUserSchema,
|
||||
content: { type: 'string' },
|
||||
|
|
@ -144,6 +204,9 @@ export const messageSchema = {
|
|||
clientMsgId: { type: 'string' },
|
||||
seq: { type: 'integer' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
editedAt: { type: ['string', 'null'], format: 'date-time' },
|
||||
deletedAt: { type: ['string', 'null'], format: 'date-time' },
|
||||
reactions: { type: 'array', items: reactionSummarySchema },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,44 @@
|
|||
export { registerBodySchema, loginBodySchema, updateMeBodySchema } from './auth';
|
||||
export type { RegisterBody, LoginBody, UpdateMeBody } from './auth';
|
||||
export { createRoomBodySchema, addMemberBodySchema, sendMessageBodySchema } from './room';
|
||||
export type { CreateRoomBody, AddMemberBody, SendMessageBody } from './room';
|
||||
export {
|
||||
createDirectBodySchema,
|
||||
createGroupBodySchema,
|
||||
addMemberBodySchema,
|
||||
sendMessageBodySchema,
|
||||
addContactBodySchema,
|
||||
} from './conversation';
|
||||
export type {
|
||||
CreateDirectBody,
|
||||
CreateGroupBody,
|
||||
AddMemberBody,
|
||||
SendMessageBody,
|
||||
AddContactBody,
|
||||
} from './conversation';
|
||||
export {
|
||||
editMessageBodySchema,
|
||||
reactionBodySchema,
|
||||
readBodySchema,
|
||||
typingBodySchema,
|
||||
} from './live';
|
||||
export type { EditMessageBody, ReactionBody, ReadBody, TypingBody } from './live';
|
||||
export {
|
||||
publicUserSchema,
|
||||
publicUserListSchema,
|
||||
userSchema,
|
||||
authResultSchema,
|
||||
sessionSchema,
|
||||
sessionListSchema,
|
||||
centrifugoTokenSchema,
|
||||
errorSchema,
|
||||
roomSchema,
|
||||
roomListSchema,
|
||||
roomMemberSchema,
|
||||
roomMemberListSchema,
|
||||
conversationSchema,
|
||||
conversationListSchema,
|
||||
conversationMemberSchema,
|
||||
conversationMemberListSchema,
|
||||
contactSchema,
|
||||
contactListSchema,
|
||||
messageSchema,
|
||||
messageListSchema,
|
||||
reactionSummarySchema,
|
||||
presenceSchema,
|
||||
presenceListSchema,
|
||||
} from './entities';
|
||||
|
|
|
|||
42
packages/core/src/schemas/live.ts
Normal file
42
packages/core/src/schemas/live.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
|
||||
export const editMessageBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['content'],
|
||||
properties: {
|
||||
content: { type: 'string', minLength: 1, maxLength: 16000 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const reactionBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['emoji'],
|
||||
properties: {
|
||||
emoji: { type: 'string', minLength: 1, maxLength: 32 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const readBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['seq'],
|
||||
properties: {
|
||||
seq: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const typingBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['state'],
|
||||
properties: {
|
||||
state: { type: 'string', enum: ['start', 'stop'] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type EditMessageBody = FromSchema<typeof editMessageBodySchema>;
|
||||
export type ReactionBody = FromSchema<typeof reactionBodySchema>;
|
||||
export type ReadBody = FromSchema<typeof readBodySchema>;
|
||||
export type TypingBody = FromSchema<typeof typingBodySchema>;
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
|
||||
export const createRoomBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['name'],
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const addMemberBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['username'],
|
||||
properties: {
|
||||
username: { type: 'string', pattern: '^[a-zA-Z0-9_]{3,32}$' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const sendMessageBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['content', 'clientMsgId'],
|
||||
properties: {
|
||||
// Plaintext today; base64 ciphertext once E2EE lands (hence the generous max).
|
||||
content: { type: 'string', minLength: 1, maxLength: 16000 },
|
||||
clientMsgId: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
contentType: { type: 'string', enum: ['text', 'text/ciphertext'] },
|
||||
encryption: { type: ['string', 'null'] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type CreateRoomBody = FromSchema<typeof createRoomBodySchema>;
|
||||
export type AddMemberBody = FromSchema<typeof addMemberBodySchema>;
|
||||
export type SendMessageBody = FromSchema<typeof sendMessageBodySchema>;
|
||||
7
packages/core/src/types/contact.ts
Normal file
7
packages/core/src/types/contact.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { PublicUser } from './user';
|
||||
|
||||
export interface Contact {
|
||||
userId: string;
|
||||
user: PublicUser;
|
||||
createdAt: string;
|
||||
}
|
||||
23
packages/core/src/types/conversation.ts
Normal file
23
packages/core/src/types/conversation.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { PublicUser } from './user';
|
||||
|
||||
export type ConversationType = 'direct' | 'group';
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
type: ConversationType;
|
||||
/** Title for a group; null for a direct conversation. */
|
||||
title: string | null;
|
||||
/** The other participant for a direct conversation; null for a group. */
|
||||
peer: PublicUser | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
lastMessageAt: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface ConversationMember {
|
||||
userId: string;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
|
@ -3,5 +3,7 @@ export const CORE_VERSION = '0.1.0';
|
|||
|
||||
export type { User, PublicUser } from './user';
|
||||
export type { AuthResult, Session, CentrifugoToken } from './auth';
|
||||
export type { Room, RoomMember } from './room';
|
||||
export type { Message } from './message';
|
||||
export type { Conversation, ConversationType, ConversationMember } from './conversation';
|
||||
export type { Contact } from './contact';
|
||||
export type { Message, ReactionSummary } from './message';
|
||||
export type { Presence } from './presence';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import type { PublicUser } from './user';
|
||||
|
||||
export interface ReactionSummary {
|
||||
emoji: string;
|
||||
count: number;
|
||||
/** Whether the requesting user reacted with this emoji. */
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
// A chat message. `content` is a crypto-agnostic envelope: plaintext today,
|
||||
// base64 ciphertext once E2EE lands — the server never needs to interpret it
|
||||
// beyond storing/relaying. `seq` is the server-assigned monotonic order.
|
||||
// base64 ciphertext once E2EE lands. `seq` is the server-assigned monotonic order.
|
||||
export interface Message {
|
||||
id: string;
|
||||
roomId: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
sender: PublicUser;
|
||||
content: string;
|
||||
|
|
@ -14,4 +20,7 @@ export interface Message {
|
|||
clientMsgId: string;
|
||||
seq: number;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
reactions: ReactionSummary[];
|
||||
}
|
||||
|
|
|
|||
5
packages/core/src/types/presence.ts
Normal file
5
packages/core/src/types/presence.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface Presence {
|
||||
userId: string;
|
||||
online: boolean;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import type { PublicUser } from './user';
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RoomMember {
|
||||
userId: string;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { PublicUser, User } from '@altricade/core';
|
||||
import type { Conversation, PublicUser, User } from '@altricade/core';
|
||||
import { getPresence, heartbeat } from '@altricade/core/api';
|
||||
import { SessionProvider, useSession } from '../entities/session';
|
||||
import { AuthForm } from '../features/auth';
|
||||
import { RealtimeProvider, useRealtime } from '../features/realtime';
|
||||
import { useRooms, RoomSidebar } from '../features/rooms';
|
||||
import { useConversations, ConversationSidebar } from '../features/conversations';
|
||||
import { ContactsPanel } from '../features/contacts';
|
||||
import { ChatView } from '../features/messaging';
|
||||
import { apiConfig } from '../shared/api';
|
||||
import { useTheme } from '../shared/theme';
|
||||
import type { ThemePreference } from '../shared/theme';
|
||||
|
||||
|
|
@ -39,8 +42,54 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
|||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
const { state } = useRealtime();
|
||||
const { rooms, createRoom } = useRooms(user.id);
|
||||
const [currentRoomId, setCurrentRoomId] = useState<string | null>(null);
|
||||
const { conversations, startDirect, createGroupChat } = useConversations(user.id);
|
||||
const [current, setCurrent] = useState<Conversation | null>(null);
|
||||
const [onlineMap, setOnlineMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const peerKey = conversations
|
||||
.flatMap((conversation) => (conversation.peer === null ? [] : [conversation.peer.id]))
|
||||
.join(',');
|
||||
|
||||
// Poll presence for the people we have DMs with.
|
||||
useEffect(() => {
|
||||
const ids = peerKey.split(',').filter((id) => id.length > 0);
|
||||
if (ids.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const presences = await getPresence(apiConfig, ids);
|
||||
if (!cancelled) {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const presence of presences) {
|
||||
map[presence.userId] = presence.online;
|
||||
}
|
||||
setOnlineMap(map);
|
||||
}
|
||||
} catch {
|
||||
// presence is best-effort
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const interval = setInterval(() => {
|
||||
void poll();
|
||||
}, 20000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [peerKey]);
|
||||
|
||||
// Keep-alive heartbeat so our own last-seen stays fresh while connected.
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void heartbeat(apiConfig);
|
||||
}, 45000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
|
|
@ -56,20 +105,30 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
|||
</span>
|
||||
</header>
|
||||
<div className="workspace">
|
||||
<RoomSidebar
|
||||
rooms={rooms}
|
||||
currentRoomId={currentRoomId}
|
||||
onSelect={setCurrentRoomId}
|
||||
onCreate={async (name) => {
|
||||
const room = await createRoom(name);
|
||||
setCurrentRoomId(room.id);
|
||||
}}
|
||||
/>
|
||||
{currentRoomId !== null ? (
|
||||
<ChatView roomId={currentRoomId} me={me} />
|
||||
<div className="sidebar-column">
|
||||
<ConversationSidebar
|
||||
conversations={conversations}
|
||||
currentId={current?.id ?? null}
|
||||
onlineMap={onlineMap}
|
||||
onSelect={setCurrent}
|
||||
onStartDirect={async (username) => {
|
||||
setCurrent(await startDirect(username));
|
||||
}}
|
||||
onCreateGroup={async (title, members) => {
|
||||
setCurrent(await createGroupChat(title, members));
|
||||
}}
|
||||
/>
|
||||
<ContactsPanel
|
||||
onStartDirect={async (username) => {
|
||||
setCurrent(await startDirect(username));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{current !== null ? (
|
||||
<ChatView conversation={current} me={me} />
|
||||
) : (
|
||||
<section className="chat chat-empty">
|
||||
<p>Select a room, or create one to start chatting.</p>
|
||||
<p>Search for a user or pick a contact to start chatting.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -278,3 +278,176 @@ body {
|
|||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar-column {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-column .sidebar {
|
||||
width: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-textMuted);
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-results button,
|
||||
.contact-list button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-textMuted);
|
||||
}
|
||||
|
||||
.contacts {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.contact-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.contact-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-textMuted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #30a46c;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
display: inline-block;
|
||||
min-width: 1.2rem;
|
||||
padding: 0 0.35rem;
|
||||
margin-left: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.read-mark {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.msg-actions {
|
||||
display: none;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.message-list li:hover .msg-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.react-btn,
|
||||
.link-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 0.15rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
color: var(--color-textMuted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reactions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.reaction {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reaction.mine-reaction {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.typing {
|
||||
font-style: italic;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
|
|
|||
3
packages/web/src/features/contacts/index.ts
Normal file
3
packages/web/src/features/contacts/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useContacts } from './model';
|
||||
export type { UseContacts } from './model';
|
||||
export { ContactsPanel } from './ui/ContactsPanel';
|
||||
34
packages/web/src/features/contacts/model.ts
Normal file
34
packages/web/src/features/contacts/model.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Contact } from '@altricade/core';
|
||||
import { listContacts, addContact, removeContact } from '@altricade/core/api';
|
||||
import { apiConfig } from '../../shared/api';
|
||||
|
||||
export interface UseContacts {
|
||||
contacts: Contact[];
|
||||
add: (username: string) => Promise<void>;
|
||||
remove: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useContacts = (): UseContacts => {
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
|
||||
const reload = useCallback(async (): Promise<void> => {
|
||||
setContacts(await listContacts(apiConfig));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const add = useCallback(async (username: string): Promise<void> => {
|
||||
const contact = await addContact(apiConfig, { username });
|
||||
setContacts((prev) => [contact, ...prev.filter((item) => item.userId !== contact.userId)]);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback(async (userId: string): Promise<void> => {
|
||||
await removeContact(apiConfig, userId);
|
||||
setContacts((prev) => prev.filter((item) => item.userId !== userId));
|
||||
}, []);
|
||||
|
||||
return { contacts, add, remove };
|
||||
};
|
||||
75
packages/web/src/features/contacts/ui/ContactsPanel.tsx
Normal file
75
packages/web/src/features/contacts/ui/ContactsPanel.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useState } from 'react';
|
||||
import type { ReactElement, SyntheticEvent } from 'react';
|
||||
import { ApiError } from '@altricade/core/api';
|
||||
import { useContacts } from '../model';
|
||||
|
||||
interface Props {
|
||||
onStartDirect: (username: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => {
|
||||
const { contacts, add, remove } = useContacts();
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (event: SyntheticEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === '') {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
await add(trimmed);
|
||||
setName('');
|
||||
} catch (caught) {
|
||||
setError(caught instanceof ApiError ? caught.message : 'Could not add contact');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="contacts">
|
||||
<h3 className="sidebar-heading">Contacts</h3>
|
||||
<form
|
||||
className="sidebar-form"
|
||||
onSubmit={(event) => {
|
||||
void submit(event);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="add contact username"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<ul className="contact-list">
|
||||
{contacts.map((contact) => (
|
||||
<li key={contact.userId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onStartDirect(contact.user.username);
|
||||
}}
|
||||
>
|
||||
@{contact.user.username}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="contact-remove"
|
||||
aria-label="remove contact"
|
||||
onClick={() => {
|
||||
void remove(contact.userId);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{error !== null ? <p className="auth-error">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
3
packages/web/src/features/conversations/index.ts
Normal file
3
packages/web/src/features/conversations/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useConversations } from './model';
|
||||
export type { UseConversations } from './model';
|
||||
export { ConversationSidebar } from './ui/ConversationSidebar';
|
||||
81
packages/web/src/features/conversations/model.ts
Normal file
81
packages/web/src/features/conversations/model.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Conversation } from '@altricade/core';
|
||||
import { userChannel } from '@altricade/core';
|
||||
import { listConversations, createDirect, createGroup } from '@altricade/core/api';
|
||||
import { apiConfig } from '../../shared/api';
|
||||
import { useRealtime } from '../realtime';
|
||||
|
||||
export interface UseConversations {
|
||||
conversations: Conversation[];
|
||||
loading: boolean;
|
||||
startDirect: (username: string) => Promise<Conversation>;
|
||||
createGroupChat: (title: string, members: string[]) => Promise<Conversation>;
|
||||
}
|
||||
|
||||
const isConversationNew = (
|
||||
data: unknown,
|
||||
): data is { type: 'conversation.new'; conversation: Conversation } => {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
}
|
||||
return 'type' in data && data.type === 'conversation.new' && 'conversation' in data;
|
||||
};
|
||||
|
||||
const upsert = (list: Conversation[], conversation: Conversation): Conversation[] => {
|
||||
const rest = list.filter((item) => item.id !== conversation.id);
|
||||
return [conversation, ...rest];
|
||||
};
|
||||
|
||||
export const useConversations = (userId: string): UseConversations => {
|
||||
const { subscribe } = useRealtime();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const list = await listConversations(apiConfig);
|
||||
if (!cancelled) {
|
||||
setConversations(list);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// New conversations (a DM someone started with me, or a group I was added to)
|
||||
// arrive on my personal channel.
|
||||
useEffect(() => {
|
||||
return subscribe(userChannel(userId), (event) => {
|
||||
if (isConversationNew(event.data)) {
|
||||
const { conversation } = event.data;
|
||||
setConversations((prev) => upsert(prev, conversation));
|
||||
}
|
||||
});
|
||||
}, [subscribe, userId]);
|
||||
|
||||
const startDirect = useCallback(async (username: string): Promise<Conversation> => {
|
||||
const conversation = await createDirect(apiConfig, { username });
|
||||
setConversations((prev) => upsert(prev, conversation));
|
||||
return conversation;
|
||||
}, []);
|
||||
|
||||
const createGroupChat = useCallback(
|
||||
async (title: string, members: string[]): Promise<Conversation> => {
|
||||
const conversation = await createGroup(apiConfig, { title, members });
|
||||
setConversations((prev) => upsert(prev, conversation));
|
||||
return conversation;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { conversations, loading, startDirect, createGroupChat };
|
||||
};
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { useState } from 'react';
|
||||
import type { ReactElement, SyntheticEvent } from 'react';
|
||||
import type { Conversation, PublicUser } from '@altricade/core';
|
||||
import { searchUsers, ApiError } from '@altricade/core/api';
|
||||
import { apiConfig } from '../../../shared/api';
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
currentId: string | null;
|
||||
onlineMap: Record<string, boolean>;
|
||||
onSelect: (conversation: Conversation) => void;
|
||||
onStartDirect: (username: string) => Promise<void>;
|
||||
onCreateGroup: (title: string, members: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const label = (conversation: Conversation): string => {
|
||||
if (conversation.type === 'group') {
|
||||
return conversation.title ?? 'Group';
|
||||
}
|
||||
return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`;
|
||||
};
|
||||
|
||||
export const ConversationSidebar = ({
|
||||
conversations,
|
||||
currentId,
|
||||
onlineMap,
|
||||
onSelect,
|
||||
onStartDirect,
|
||||
onCreateGroup,
|
||||
}: Props): ReactElement => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<PublicUser[]>([]);
|
||||
const [groupTitle, setGroupTitle] = useState('');
|
||||
const [groupMembers, setGroupMembers] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runSearch = async (value: string): Promise<void> => {
|
||||
setQuery(value);
|
||||
if (value.trim().length === 0) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setResults(await searchUsers(apiConfig, value.trim()));
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (username: string): Promise<void> => {
|
||||
setError(null);
|
||||
try {
|
||||
await onStartDirect(username);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof ApiError ? caught.message : 'Could not start chat');
|
||||
}
|
||||
};
|
||||
|
||||
const submitGroup = async (event: SyntheticEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const title = groupTitle.trim();
|
||||
if (title === '') {
|
||||
return;
|
||||
}
|
||||
const members = groupMembers
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name.length > 0);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreateGroup(title, members);
|
||||
setGroupTitle('');
|
||||
setGroupMembers('');
|
||||
} catch (caught) {
|
||||
setError(caught instanceof ApiError ? caught.message : 'Could not create group');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="Search users to chat…"
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
void runSearch(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{results.length > 0 ? (
|
||||
<ul className="search-results">
|
||||
{results.map((user) => (
|
||||
<li key={user.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void start(user.username);
|
||||
}}
|
||||
>
|
||||
{user.displayName} <span className="muted">@{user.username}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<h3 className="sidebar-heading">Conversations</h3>
|
||||
<ul className="room-list">
|
||||
{conversations.map((conversation) => {
|
||||
const online =
|
||||
conversation.peer !== null && onlineMap[conversation.peer.id] === true;
|
||||
return (
|
||||
<li key={conversation.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={conversation.id === currentId}
|
||||
onClick={() => {
|
||||
onSelect(conversation);
|
||||
}}
|
||||
>
|
||||
{online ? <span className="online-dot" aria-label="online" /> : null}
|
||||
{label(conversation)}
|
||||
{conversation.unreadCount > 0 ? (
|
||||
<span className="unread-badge">{conversation.unreadCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<form
|
||||
className="sidebar-form group-form"
|
||||
onSubmit={(event) => {
|
||||
void submitGroup(event);
|
||||
}}
|
||||
>
|
||||
<h3 className="sidebar-heading">New group</h3>
|
||||
<input
|
||||
placeholder="group title"
|
||||
value={groupTitle}
|
||||
onChange={(event) => {
|
||||
setGroupTitle(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="members (comma-separated usernames)"
|
||||
value={groupMembers}
|
||||
onChange={(event) => {
|
||||
setGroupMembers(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<button type="submit">Create group</button>
|
||||
</form>
|
||||
|
||||
{error !== null ? <p className="auth-error">{error}</p> : null}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export { useRoomMessages } from './model';
|
||||
export type { UseRoomMessages } from './model';
|
||||
export { useConversationMessages } from './model';
|
||||
export type { UseConversationMessages } from './model';
|
||||
export { ChatView } from './ui/ChatView';
|
||||
|
|
|
|||
|
|
@ -1,48 +1,173 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Message, MessageNewEvent, PublicUser } from '@altricade/core';
|
||||
import { roomChannel, mergeMessages, OPTIMISTIC_SEQ } from '@altricade/core';
|
||||
import { getHistory, sendMessage } from '@altricade/core/api';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
MessageNewEvent,
|
||||
MessageEditEvent,
|
||||
MessageDeleteEvent,
|
||||
ReactionEvent,
|
||||
ReadReceiptEvent,
|
||||
TypingEvent,
|
||||
PublicUser,
|
||||
ReactionSummary,
|
||||
} from '@altricade/core';
|
||||
import { conversationChannel, userChannel, mergeMessages, OPTIMISTIC_SEQ } from '@altricade/core';
|
||||
import {
|
||||
getHistory,
|
||||
sendMessage,
|
||||
editMessage as apiEdit,
|
||||
deleteMessage as apiDelete,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
markRead,
|
||||
sendTyping,
|
||||
} from '@altricade/core/api';
|
||||
import { apiConfig } from '../../shared/api';
|
||||
import { useRealtime } from '../realtime';
|
||||
|
||||
export interface UseRoomMessages {
|
||||
export interface UseConversationMessages {
|
||||
messages: Message[];
|
||||
send: (content: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
typingUserIds: string[];
|
||||
/** For a direct conversation: the peer's last-read seq (drives ✓✓). */
|
||||
peerReadSeq: number;
|
||||
send: (content: string) => Promise<void>;
|
||||
edit: (messageId: string, content: string) => Promise<void>;
|
||||
remove: (messageId: string) => Promise<void>;
|
||||
toggleReaction: (message: Message, emoji: string) => Promise<void>;
|
||||
notifyTyping: () => void;
|
||||
}
|
||||
|
||||
const isMessageNew = (data: unknown): data is MessageNewEvent => {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
const hasType = (data: unknown): data is { type: string } =>
|
||||
typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string';
|
||||
|
||||
const isMessageEvent = (d: unknown): d is MessageNewEvent | MessageEditEvent =>
|
||||
hasType(d) && (d.type === 'message.new' || d.type === 'message.edit') && 'message' in d;
|
||||
const isDeleteEvent = (d: unknown): d is MessageDeleteEvent =>
|
||||
hasType(d) && d.type === 'message.delete' && 'messageId' in d && 'conversationId' in d;
|
||||
const isReactionEvent = (d: unknown): d is ReactionEvent =>
|
||||
hasType(d) && (d.type === 'reaction.add' || d.type === 'reaction.remove');
|
||||
const isReadEvent = (d: unknown): d is ReadReceiptEvent => hasType(d) && d.type === 'read.receipt';
|
||||
const isTypingEvent = (d: unknown): d is TypingEvent =>
|
||||
hasType(d) && (d.type === 'typing.start' || d.type === 'typing.stop');
|
||||
|
||||
const applyReaction = (
|
||||
reactions: ReactionSummary[],
|
||||
emoji: string,
|
||||
delta: 1 | -1,
|
||||
fromMe: boolean,
|
||||
): ReactionSummary[] => {
|
||||
const existing = reactions.find((r) => r.emoji === emoji);
|
||||
if (existing === undefined) {
|
||||
return delta === 1 ? [...reactions, { emoji, count: 1, mine: fromMe }] : reactions;
|
||||
}
|
||||
if (!('type' in data) || data.type !== 'message.new') {
|
||||
return false;
|
||||
}
|
||||
return 'message' in data;
|
||||
return reactions
|
||||
.map((r) => {
|
||||
if (r.emoji !== emoji) {
|
||||
return r;
|
||||
}
|
||||
const mine = fromMe ? delta === 1 : r.mine;
|
||||
return { emoji, count: r.count + delta, mine };
|
||||
})
|
||||
.filter((r) => r.count > 0);
|
||||
};
|
||||
|
||||
export const useRoomMessages = (roomId: string, me: PublicUser): UseRoomMessages => {
|
||||
export const useConversationMessages = (
|
||||
conversation: Conversation,
|
||||
me: PublicUser,
|
||||
): UseConversationMessages => {
|
||||
const { subscribe } = useRealtime();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
|
||||
const [peerReadSeq, setPeerReadSeq] = useState(0);
|
||||
const typingTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const lastTypingSent = useRef(0);
|
||||
|
||||
const conversationId = conversation.id;
|
||||
const channel =
|
||||
conversation.type === 'group' ? conversationChannel(conversationId) : userChannel(me.id);
|
||||
|
||||
// Reconnect-safe ordering: subscribe FIRST (live events buffer into state),
|
||||
// THEN load history, then merge/dedupe — so nothing slips through the gap.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setMessages([]);
|
||||
setLoading(true);
|
||||
setTypingUserIds([]);
|
||||
setPeerReadSeq(0);
|
||||
const timers = typingTimers.current;
|
||||
|
||||
const unsubscribe = subscribe(roomChannel(roomId), (event) => {
|
||||
if (isMessageNew(event.data)) {
|
||||
const incoming = event.data.message;
|
||||
setMessages((prev) => mergeMessages(prev, [incoming]));
|
||||
const clearTyping = (userId: string): void => {
|
||||
setTypingUserIds((prev) => prev.filter((id) => id !== userId));
|
||||
};
|
||||
|
||||
const unsubscribe = subscribe(channel, (event) => {
|
||||
const data = event.data;
|
||||
|
||||
if (isMessageEvent(data)) {
|
||||
if (data.message.conversationId === conversationId) {
|
||||
const { message } = data;
|
||||
setMessages((prev) => mergeMessages(prev, [message]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isDeleteEvent(data)) {
|
||||
if (data.conversationId === conversationId) {
|
||||
const { messageId } = data;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === messageId ? { ...m, content: '', deletedAt: new Date().toISOString() } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isReactionEvent(data)) {
|
||||
if (data.conversationId === conversationId) {
|
||||
const { messageId, emoji, userId } = data;
|
||||
const delta = data.type === 'reaction.add' ? 1 : -1;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === messageId
|
||||
? { ...m, reactions: applyReaction(m.reactions, emoji, delta, userId === me.id) }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isReadEvent(data)) {
|
||||
if (data.conversationId === conversationId && data.userId !== me.id) {
|
||||
const { seq } = data;
|
||||
setPeerReadSeq((prev) => Math.max(prev, seq));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isTypingEvent(data)) {
|
||||
if (data.conversationId !== conversationId || data.userId === me.id) {
|
||||
return;
|
||||
}
|
||||
const { userId } = data;
|
||||
if (data.type === 'typing.stop') {
|
||||
clearTyping(userId);
|
||||
return;
|
||||
}
|
||||
setTypingUserIds((prev) => (prev.includes(userId) ? prev : [...prev, userId]));
|
||||
const existing = timers.get(userId);
|
||||
if (existing !== undefined) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
timers.set(
|
||||
userId,
|
||||
setTimeout(() => {
|
||||
clearTyping(userId);
|
||||
}, 4000),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const history = await getHistory(apiConfig, roomId, { limit: 50 });
|
||||
const history = await getHistory(apiConfig, conversationId, { limit: 50 });
|
||||
if (!cancelled) {
|
||||
setMessages((prev) => mergeMessages(prev, history));
|
||||
}
|
||||
|
|
@ -57,15 +182,29 @@ export const useRoomMessages = (roomId: string, me: PublicUser): UseRoomMessages
|
|||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timers.clear();
|
||||
};
|
||||
}, [roomId, subscribe]);
|
||||
}, [conversationId, channel, subscribe, me.id]);
|
||||
|
||||
// Mark the conversation read up to the newest confirmed message.
|
||||
useEffect(() => {
|
||||
const maxSeq = messages
|
||||
.filter((m) => m.seq !== OPTIMISTIC_SEQ)
|
||||
.reduce((max, m) => Math.max(max, m.seq), 0);
|
||||
if (maxSeq > 0) {
|
||||
void markRead(apiConfig, conversationId, maxSeq);
|
||||
}
|
||||
}, [messages, conversationId]);
|
||||
|
||||
const send = useCallback(
|
||||
async (content: string): Promise<void> => {
|
||||
const clientMsgId = crypto.randomUUID();
|
||||
const optimistic: Message = {
|
||||
id: `optimistic:${clientMsgId}`,
|
||||
roomId,
|
||||
conversationId,
|
||||
senderId: me.id,
|
||||
sender: me,
|
||||
content,
|
||||
|
|
@ -74,13 +213,61 @@ export const useRoomMessages = (roomId: string, me: PublicUser): UseRoomMessages
|
|||
clientMsgId,
|
||||
seq: OPTIMISTIC_SEQ,
|
||||
createdAt: new Date().toISOString(),
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
reactions: [],
|
||||
};
|
||||
setMessages((prev) => mergeMessages(prev, [optimistic]));
|
||||
const confirmed = await sendMessage(apiConfig, roomId, { content, clientMsgId });
|
||||
const confirmed = await sendMessage(apiConfig, conversationId, { content, clientMsgId });
|
||||
setMessages((prev) => mergeMessages(prev, [confirmed]));
|
||||
},
|
||||
[roomId, me],
|
||||
[conversationId, me],
|
||||
);
|
||||
|
||||
return { messages, send, loading };
|
||||
const edit = useCallback(
|
||||
async (messageId: string, content: string): Promise<void> => {
|
||||
const updated = await apiEdit(apiConfig, conversationId, messageId, { content });
|
||||
setMessages((prev) => mergeMessages(prev, [updated]));
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (messageId: string): Promise<void> => {
|
||||
await apiDelete(apiConfig, conversationId, messageId);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (message: Message, emoji: string): Promise<void> => {
|
||||
const mine = message.reactions.some((r) => r.emoji === emoji && r.mine);
|
||||
if (mine) {
|
||||
await removeReaction(apiConfig, conversationId, message.id, emoji);
|
||||
} else {
|
||||
await addReaction(apiConfig, conversationId, message.id, { emoji });
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const notifyTyping = useCallback((): void => {
|
||||
const now = Date.now();
|
||||
if (now - lastTypingSent.current > 2500) {
|
||||
lastTypingSent.current = now;
|
||||
void sendTyping(apiConfig, conversationId, 'start');
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
typingUserIds,
|
||||
peerReadSeq,
|
||||
send,
|
||||
edit,
|
||||
remove,
|
||||
toggleReaction,
|
||||
notifyTyping,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
import { useState } from 'react';
|
||||
import type { ReactElement, SyntheticEvent } from 'react';
|
||||
import type { PublicUser } from '@altricade/core';
|
||||
import { useRoomMessages } from '../model';
|
||||
import type { Conversation, Message, PublicUser } from '@altricade/core';
|
||||
import { useConversationMessages } from '../model';
|
||||
|
||||
interface Props {
|
||||
roomId: string;
|
||||
conversation: Conversation;
|
||||
me: PublicUser;
|
||||
}
|
||||
|
||||
export const ChatView = ({ roomId, me }: Props): ReactElement => {
|
||||
const { messages, send, loading } = useRoomMessages(roomId, me);
|
||||
const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉'];
|
||||
|
||||
const headerTitle = (conversation: Conversation): string => {
|
||||
if (conversation.type === 'group') {
|
||||
return conversation.title ?? 'Group';
|
||||
}
|
||||
return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`;
|
||||
};
|
||||
|
||||
export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||
const { messages, loading, typingUserIds, peerReadSeq, send, edit, remove, toggleReaction, notifyTyping } =
|
||||
useConversationMessages(conversation, me);
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const submit = async (event: SyntheticEvent): Promise<void> => {
|
||||
|
|
@ -22,17 +32,89 @@ export const ChatView = ({ roomId, me }: Props): ReactElement => {
|
|||
await send(trimmed);
|
||||
};
|
||||
|
||||
const onEdit = (message: Message): void => {
|
||||
const next = window.prompt('Edit message', message.content);
|
||||
if (next !== null && next.trim() !== '') {
|
||||
void edit(message.id, next.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const readMark = (message: Message): string => {
|
||||
if (conversation.type !== 'direct' || message.senderId !== me.id || message.deletedAt !== null) {
|
||||
return '';
|
||||
}
|
||||
return peerReadSeq >= message.seq ? '✓✓' : '✓';
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="chat">
|
||||
<h2 className="chat-title">{headerTitle(conversation)}</h2>
|
||||
{loading ? <p className="chat-loading">Loading…</p> : null}
|
||||
<ul className="message-list">
|
||||
{messages.map((message) => (
|
||||
<li key={message.id} className={message.senderId === me.id ? 'mine' : ''}>
|
||||
<span className="msg-author">@{message.sender.username}</span>
|
||||
<span className="msg-body">{message.content}</span>
|
||||
<div className="msg-row">
|
||||
<span className="msg-author">@{message.sender.username}</span>
|
||||
<span className="msg-body">
|
||||
{message.deletedAt !== null ? <em className="muted">message deleted</em> : message.content}
|
||||
</span>
|
||||
{message.editedAt !== null && message.deletedAt === null ? (
|
||||
<span className="muted"> (edited)</span>
|
||||
) : null}
|
||||
<span className="read-mark">{readMark(message)}</span>
|
||||
</div>
|
||||
{message.deletedAt === null ? (
|
||||
<div className="msg-actions">
|
||||
{QUICK_REACTIONS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
className="react-btn"
|
||||
onClick={() => {
|
||||
void toggleReaction(message, emoji);
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
{message.senderId === me.id ? (
|
||||
<>
|
||||
<button type="button" className="link-btn" onClick={() => { onEdit(message); }}>
|
||||
edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => {
|
||||
void remove(message.id);
|
||||
}}
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{message.reactions.length > 0 ? (
|
||||
<div className="reactions">
|
||||
{message.reactions.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
type="button"
|
||||
className={r.mine ? 'reaction mine-reaction' : 'reaction'}
|
||||
onClick={() => {
|
||||
void toggleReaction(message, r.emoji);
|
||||
}}
|
||||
>
|
||||
{r.emoji} {r.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{typingUserIds.length > 0 ? <p className="typing muted">typing…</p> : null}
|
||||
<form
|
||||
className="composer"
|
||||
onSubmit={(event) => {
|
||||
|
|
@ -44,6 +126,7 @@ export const ChatView = ({ roomId, me }: Props): ReactElement => {
|
|||
placeholder="Write a message…"
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
notifyTyping();
|
||||
}}
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export { useRooms } from './model';
|
||||
export type { UseRooms } from './model';
|
||||
export { RoomSidebar } from './ui/RoomSidebar';
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Room } from '@altricade/core';
|
||||
import { userChannel } from '@altricade/core';
|
||||
import { listRooms, createRoom as apiCreateRoom } from '@altricade/core/api';
|
||||
import { apiConfig } from '../../shared/api';
|
||||
import { useRealtime } from '../realtime';
|
||||
|
||||
export interface UseRooms {
|
||||
rooms: Room[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
createRoom: (name: string) => Promise<Room>;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useRooms = (userId: string): UseRooms => {
|
||||
const { subscribe } = useRealtime();
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setRooms(await listRooms(apiConfig));
|
||||
} catch {
|
||||
setError('Failed to load rooms');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
// A room.membership event on the personal channel means we were added/removed.
|
||||
useEffect(() => {
|
||||
return subscribe(userChannel(userId), () => {
|
||||
void reload();
|
||||
});
|
||||
}, [subscribe, userId, reload]);
|
||||
|
||||
const createRoom = useCallback(async (name: string): Promise<Room> => {
|
||||
const room = await apiCreateRoom(apiConfig, { name });
|
||||
setRooms((prev) => [room, ...prev]);
|
||||
return room;
|
||||
}, []);
|
||||
|
||||
return { rooms, loading, error, createRoom, reload };
|
||||
};
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import type { ReactElement, SyntheticEvent } from 'react';
|
||||
import type { Room } from '@altricade/core';
|
||||
import { ApiError, addMember } from '@altricade/core/api';
|
||||
import { apiConfig } from '../../../shared/api';
|
||||
|
||||
interface Props {
|
||||
rooms: Room[];
|
||||
currentRoomId: string | null;
|
||||
onSelect: (roomId: string) => void;
|
||||
onCreate: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const RoomSidebar = ({ rooms, currentRoomId, onSelect, onCreate }: Props): ReactElement => {
|
||||
const [name, setName] = useState('');
|
||||
const [memberName, setMemberName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submitCreate = async (event: SyntheticEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === '') {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate(trimmed);
|
||||
setName('');
|
||||
} catch {
|
||||
setError('Could not create room');
|
||||
}
|
||||
};
|
||||
|
||||
const submitAddMember = async (event: SyntheticEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const trimmed = memberName.trim();
|
||||
if (currentRoomId === null || trimmed === '') {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
await addMember(apiConfig, currentRoomId, { username: trimmed });
|
||||
setMemberName('');
|
||||
} catch (caught) {
|
||||
setError(caught instanceof ApiError ? caught.message : 'Could not add member');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<form
|
||||
className="sidebar-form"
|
||||
onSubmit={(event) => {
|
||||
void submitCreate(event);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="new room name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
<ul className="room-list">
|
||||
{rooms.map((room) => (
|
||||
<li key={room.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={room.id === currentRoomId}
|
||||
onClick={() => {
|
||||
onSelect(room.id);
|
||||
}}
|
||||
>
|
||||
{room.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{currentRoomId !== null ? (
|
||||
<form
|
||||
className="sidebar-form"
|
||||
onSubmit={(event) => {
|
||||
void submitAddMember(event);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="add member username"
|
||||
value={memberName}
|
||||
onChange={(event) => {
|
||||
setMemberName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{error !== null ? <p className="auth-error">{error}</p> : null}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue