Phase 6: media messages + avatars via MinIO presigned uploads
- Media (voice/video/image/file): private bucket, presigned PUT upload, reference-only publish, membership-gated presigned GET for viewing - Avatars: public bucket, presigned PUT + direct public URL, profile.update event - messages.media_key + media_meta (migration 1720000000005_media) - Explicit MinIO region on presigning client (avoids getBucketRegion network call) - Event type values sourced from EventType constants (no raw string literals) - Web: attach button, MediaView renderer, avatar upload in topbar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
This commit is contained in:
parent
71e018d802
commit
db5c1610b3
43 changed files with 1031 additions and 229 deletions
|
|
@ -54,6 +54,12 @@ MINIO_PORT=9000
|
|||
MINIO_USE_SSL=false
|
||||
MINIO_BUCKET_MEDIA=media
|
||||
MINIO_BUCKET_AVATARS=avatars
|
||||
# Browser-facing MinIO URL — presigned upload/download URLs are signed for this
|
||||
# host, so it must match what the browser uses (dev: the exposed host port).
|
||||
MINIO_PUBLIC_URL=http://localhost:9000
|
||||
# S3 region used for SigV4 presigning (MinIO default is us-east-1). Set explicitly
|
||||
# so presigning never makes a network region-lookup call.
|
||||
MINIO_REGION=us-east-1
|
||||
|
||||
# --- nginx (public entrypoint) ---
|
||||
NGINX_HTTP_PORT=8080
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ services:
|
|||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
# Allow browser (cross-origin) presigned PUT/GET uploads.
|
||||
MINIO_API_CORS_ALLOW_ORIGIN: '*'
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
healthcheck:
|
||||
|
|
@ -61,7 +63,8 @@ services:
|
|||
mc alias set local http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
|
||||
mc mb --ignore-existing "local/$${MINIO_BUCKET_MEDIA}" &&
|
||||
mc mb --ignore-existing "local/$${MINIO_BUCKET_AVATARS}" &&
|
||||
echo "minio buckets ready"
|
||||
mc anonymous set download "local/$${MINIO_BUCKET_AVATARS}" &&
|
||||
echo "minio buckets ready (avatars public)"
|
||||
restart: 'no'
|
||||
networks: [altricade]
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"presence": true,
|
||||
"join_leave": true,
|
||||
"force_push_join_leave": true,
|
||||
"allow_presence_for_subscriber": true,
|
||||
"history_size": 100,
|
||||
"history_ttl": "300s",
|
||||
"force_recovery": true,
|
||||
|
|
@ -34,6 +35,12 @@
|
|||
"force_recovery": true,
|
||||
"subscribe_proxy_enabled": true,
|
||||
"subscribe_proxy_name": "backend"
|
||||
},
|
||||
{
|
||||
"name": "eph",
|
||||
"allow_publish_for_subscriber": true,
|
||||
"subscribe_proxy_enabled": true,
|
||||
"subscribe_proxy_name": "backend"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
16
packages/backend/migrations/1720000000005_media.cjs
Normal file
16
packages/backend/migrations/1720000000005_media.cjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Phase 6 — media messages. The media reference is kept in dedicated columns
|
||||
// (server-visible object key + metadata) separate from `content` (the caption),
|
||||
// so E2EE can later encrypt the caption without hiding the storage pointer.
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.up = (pgm) => {
|
||||
pgm.addColumns('messages', {
|
||||
media_key: { type: 'text' },
|
||||
media_meta: { type: 'jsonb' },
|
||||
});
|
||||
};
|
||||
|
||||
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropColumns('messages', ['media_key', 'media_meta']);
|
||||
};
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages';
|
||||
import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts';
|
||||
import { createPresenceService, presenceRoutes } from './modules/presence';
|
||||
import { createMediaService, mediaRoutes } from './modules/media';
|
||||
import { realtimeRoutes } from './modules/realtime';
|
||||
import type { Publisher } from './shared/publisher';
|
||||
|
||||
|
|
@ -89,8 +90,15 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
|||
const messagesRepository = createMessagesRepository(app.db);
|
||||
const contactsRepository = createContactsRepository(app.db);
|
||||
const deliver = createDeliver(conversationsRepository, publish);
|
||||
const mediaService = createMediaService({
|
||||
minio: app.minioPublic,
|
||||
mediaBucket: config.minio.buckets.media,
|
||||
avatarsBucket: config.minio.buckets.avatars,
|
||||
publicUrl: config.minio.publicUrl,
|
||||
});
|
||||
app.decorate('mediaService', mediaService);
|
||||
|
||||
app.decorate('usersService', createUsersService(usersRepository));
|
||||
app.decorate('usersService', createUsersService({ users: usersRepository, publish }));
|
||||
app.decorate(
|
||||
'authService',
|
||||
createAuthService({
|
||||
|
|
@ -121,6 +129,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
|||
messages: messagesRepository,
|
||||
conversations: conversationsRepository,
|
||||
deliver,
|
||||
mediaDownloadUrl: (objectKey) => mediaService.downloadUrl(objectKey),
|
||||
}),
|
||||
);
|
||||
app.decorate(
|
||||
|
|
@ -146,6 +155,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
|||
await app.register(messagesRoutes);
|
||||
await app.register(contactsRoutes);
|
||||
await app.register(presenceRoutes);
|
||||
await app.register(mediaRoutes);
|
||||
await app.register(realtimeRoutes);
|
||||
|
||||
app.log.info(`core wired — example channel: ${conversationChannel('demo')}`);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ export interface MinioConfig {
|
|||
useSSL: boolean;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
/** S3 region; set explicitly so presigning never triggers a network region lookup. */
|
||||
region: string;
|
||||
/** Browser-facing base URL; presigned URLs are signed for this host. */
|
||||
publicUrl: string;
|
||||
buckets: {
|
||||
media: string;
|
||||
avatars: string;
|
||||
|
|
@ -93,6 +97,8 @@ export const loadConfig = (): AppConfig => ({
|
|||
useSSL: parseBoolean(optional('MINIO_USE_SSL', 'false')),
|
||||
accessKey: required('MINIO_ROOT_USER'),
|
||||
secretKey: required('MINIO_ROOT_PASSWORD'),
|
||||
region: optional('MINIO_REGION', 'us-east-1'),
|
||||
publicUrl: optional('MINIO_PUBLIC_URL', 'http://localhost:9000'),
|
||||
buckets: {
|
||||
media: optional('MINIO_BUCKET_MEDIA', 'media'),
|
||||
avatars: optional('MINIO_BUCKET_AVATARS', 'avatars'),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ColumnType, Generated } from 'kysely';
|
||||
import type { MediaRef } from '@altricade/core';
|
||||
|
||||
// Kysely database registry: one interface per table. Grows with each migration.
|
||||
|
||||
|
|
@ -58,6 +59,9 @@ export interface MessagesTable {
|
|||
created_at: Generated<Date>;
|
||||
edited_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
media_key: string | null;
|
||||
// jsonb: parsed to a MediaRef on read, JSON string on write.
|
||||
media_meta: ColumnType<MediaRef | null, string | null, string | null>;
|
||||
}
|
||||
|
||||
export interface ContactsTable {
|
||||
|
|
|
|||
|
|
@ -4,19 +4,12 @@ import {
|
|||
createGroupBodySchema,
|
||||
addMemberBodySchema,
|
||||
readBodySchema,
|
||||
typingBodySchema,
|
||||
conversationSchema,
|
||||
conversationListSchema,
|
||||
conversationMemberListSchema,
|
||||
errorSchema,
|
||||
} from '@altricade/core';
|
||||
import type {
|
||||
CreateDirectBody,
|
||||
CreateGroupBody,
|
||||
AddMemberBody,
|
||||
ReadBody,
|
||||
TypingBody,
|
||||
} from '@altricade/core';
|
||||
import type { CreateDirectBody, CreateGroupBody, AddMemberBody, ReadBody } from '@altricade/core';
|
||||
|
||||
const bearerAuth = [{ bearerAuth: [] }];
|
||||
const idParamsSchema = {
|
||||
|
|
@ -197,26 +190,5 @@ export const conversationsRoutes = (app: FastifyInstance): Promise<void> => {
|
|||
},
|
||||
);
|
||||
|
||||
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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import type {
|
|||
ConversationNewEvent,
|
||||
ConversationMembershipEvent,
|
||||
ReadReceiptEvent,
|
||||
TypingEvent,
|
||||
} from '@altricade/core';
|
||||
import { userChannel } from '@altricade/core';
|
||||
import { userChannel, EventType } from '@altricade/core';
|
||||
import { HttpError } from '../../shared/http-error';
|
||||
import type { Publisher } from '../../shared/publisher';
|
||||
import { toPublicUser } from '../users';
|
||||
|
|
@ -35,7 +34,6 @@ export interface ConversationsService {
|
|||
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 = (
|
||||
|
|
@ -56,7 +54,7 @@ export const createConversationsService = (
|
|||
};
|
||||
|
||||
const conversationNew = (conversation: Conversation): ConversationNewEvent => ({
|
||||
type: 'conversation.new',
|
||||
type: EventType.ConversationNew,
|
||||
conversation,
|
||||
});
|
||||
|
||||
|
|
@ -64,7 +62,7 @@ export const createConversationsService = (
|
|||
action: ConversationMembershipEvent['action'],
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): ConversationMembershipEvent => ({ type: 'conversation.membership', action, conversationId, userId });
|
||||
): ConversationMembershipEvent => ({ type: EventType.ConversationMembership, action, conversationId, userId });
|
||||
|
||||
return {
|
||||
createDirect: async (actorId, username) => {
|
||||
|
|
@ -179,17 +177,7 @@ export const createConversationsService = (
|
|||
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,
|
||||
};
|
||||
const event: ReadReceiptEvent = { type: EventType.ReadReceipt, conversationId, userId, seq };
|
||||
await deliver(conversationId, event);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
3
packages/backend/src/modules/media/index.ts
Normal file
3
packages/backend/src/modules/media/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { createMediaService } from './media.service';
|
||||
export type { MediaService, MediaServiceDeps, UploadTarget, AvatarTarget } from './media.service';
|
||||
export { mediaRoutes } from './media.routes';
|
||||
60
packages/backend/src/modules/media/media.routes.ts
Normal file
60
packages/backend/src/modules/media/media.routes.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
uploadUrlBodySchema,
|
||||
avatarUploadBodySchema,
|
||||
uploadTargetSchema,
|
||||
avatarTargetSchema,
|
||||
errorSchema,
|
||||
} from '@altricade/core';
|
||||
import type { UploadUrlBody, AvatarUploadBody } from '@altricade/core';
|
||||
|
||||
const bearerAuth = [{ bearerAuth: [] }];
|
||||
|
||||
export const mediaRoutes = (app: FastifyInstance): Promise<void> => {
|
||||
app.post<{ Body: UploadUrlBody }>(
|
||||
'/media/upload-url',
|
||||
{
|
||||
schema: {
|
||||
tags: ['media'],
|
||||
summary: 'Get a presigned upload URL for a message attachment',
|
||||
security: bearerAuth,
|
||||
body: uploadUrlBodySchema,
|
||||
response: { 200: uploadTargetSchema, 400: errorSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const target = await app.mediaService.createUploadUrl(
|
||||
user.id,
|
||||
request.body.kind,
|
||||
request.body.mime,
|
||||
request.body.size,
|
||||
);
|
||||
return reply.send(target);
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: AvatarUploadBody }>(
|
||||
'/media/avatar-url',
|
||||
{
|
||||
schema: {
|
||||
tags: ['media'],
|
||||
summary: 'Get a presigned upload URL for an avatar',
|
||||
security: bearerAuth,
|
||||
body: avatarUploadBodySchema,
|
||||
response: { 200: avatarTargetSchema, 400: errorSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) return reply.code(401).send({ error: 'unauthorized' });
|
||||
const target = await app.mediaService.createAvatarUploadUrl(user.id, request.body.mime);
|
||||
return reply.send(target);
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
77
packages/backend/src/modules/media/media.service.ts
Normal file
77
packages/backend/src/modules/media/media.service.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import type { Client } from 'minio';
|
||||
import type { MediaKind } from '@altricade/core';
|
||||
import { HttpError } from '../../shared/http-error';
|
||||
|
||||
export interface MediaServiceDeps {
|
||||
// The presigning (browser-facing) MinIO client.
|
||||
minio: Client;
|
||||
mediaBucket: string;
|
||||
avatarsBucket: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
export interface UploadTarget {
|
||||
uploadUrl: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
export interface AvatarTarget extends UploadTarget {
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
export interface MediaService {
|
||||
createUploadUrl(userId: string, kind: MediaKind, mime: string, size: number): Promise<UploadTarget>;
|
||||
createAvatarUploadUrl(userId: string, mime: string): Promise<AvatarTarget>;
|
||||
downloadUrl(objectKey: string): Promise<string>;
|
||||
avatarPublicUrl(objectKey: string): string;
|
||||
}
|
||||
|
||||
const UPLOAD_EXPIRY = 3600;
|
||||
const DOWNLOAD_EXPIRY = 3600;
|
||||
|
||||
const kindMatches = (kind: MediaKind, mime: string): boolean => {
|
||||
if (kind === 'image') return mime.startsWith('image/');
|
||||
if (kind === 'video') return mime.startsWith('video/');
|
||||
if (kind === 'voice') return mime.startsWith('audio/');
|
||||
return true;
|
||||
};
|
||||
|
||||
export const createMediaService = (deps: MediaServiceDeps): MediaService => ({
|
||||
createUploadUrl: async (userId, kind, mime, _size) => {
|
||||
if (!kindMatches(kind, mime)) {
|
||||
throw new HttpError(400, 'invalid_media', `Content type ${mime} does not match kind ${kind}`);
|
||||
}
|
||||
const objectKey = `${userId}/${randomUUID()}`;
|
||||
const uploadUrl = await deps.minio.presignedPutObject(deps.mediaBucket, objectKey, UPLOAD_EXPIRY);
|
||||
return { uploadUrl, objectKey };
|
||||
},
|
||||
|
||||
createAvatarUploadUrl: async (userId, mime) => {
|
||||
if (!mime.startsWith('image/')) {
|
||||
throw new HttpError(400, 'invalid_media', 'Avatar must be an image');
|
||||
}
|
||||
const objectKey = `${userId}/${randomUUID()}`;
|
||||
const uploadUrl = await deps.minio.presignedPutObject(
|
||||
deps.avatarsBucket,
|
||||
objectKey,
|
||||
UPLOAD_EXPIRY,
|
||||
);
|
||||
return {
|
||||
uploadUrl,
|
||||
objectKey,
|
||||
publicUrl: `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`,
|
||||
};
|
||||
},
|
||||
|
||||
downloadUrl: (objectKey) =>
|
||||
deps.minio.presignedGetObject(deps.mediaBucket, objectKey, DOWNLOAD_EXPIRY),
|
||||
|
||||
avatarPublicUrl: (objectKey) => `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`,
|
||||
});
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
mediaService: MediaService;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,4 +23,5 @@ export const toMessage = (
|
|||
editedAt: row.edited_at === null ? null : row.edited_at.toISOString(),
|
||||
deletedAt: row.deleted_at === null ? null : row.deleted_at.toISOString(),
|
||||
reactions,
|
||||
media: row.media_meta,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { sql } from 'kysely';
|
||||
import type { Kysely } from 'kysely';
|
||||
import type { ReactionSummary } from '@altricade/core';
|
||||
import type { ReactionSummary, MediaRef } from '@altricade/core';
|
||||
import type { Database } from '../../db/schema';
|
||||
|
||||
export interface MessageWithSenderRow {
|
||||
|
|
@ -15,6 +15,8 @@ export interface MessageWithSenderRow {
|
|||
created_at: Date;
|
||||
edited_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
media_key: string | null;
|
||||
media_meta: MediaRef | null;
|
||||
sender_username: string;
|
||||
sender_display_name: string;
|
||||
sender_avatar_ref: string | null;
|
||||
|
|
@ -27,6 +29,8 @@ export interface NewMessage {
|
|||
content: string;
|
||||
contentType: string;
|
||||
encryption: string | null;
|
||||
mediaKey: string | null;
|
||||
media: MediaRef | null;
|
||||
}
|
||||
|
||||
export interface MessagesRepository {
|
||||
|
|
@ -71,6 +75,8 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
|||
'messages.created_at as created_at',
|
||||
'messages.edited_at as edited_at',
|
||||
'messages.deleted_at as deleted_at',
|
||||
'messages.media_key as media_key',
|
||||
'messages.media_meta as media_meta',
|
||||
'users.username as sender_username',
|
||||
'users.display_name as sender_display_name',
|
||||
'users.avatar_ref as sender_avatar_ref',
|
||||
|
|
@ -87,6 +93,8 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
|||
content: input.content,
|
||||
content_type: input.contentType,
|
||||
encryption: input.encryption,
|
||||
media_key: input.mediaKey,
|
||||
media_meta: input.media === null ? null : JSON.stringify(input.media),
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['conversation_id', 'sender_id', 'client_msg_id']).doNothing(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
reactionBodySchema,
|
||||
messageSchema,
|
||||
messageListSchema,
|
||||
mediaUrlSchema,
|
||||
errorSchema,
|
||||
} from '@altricade/core';
|
||||
import type { SendMessageBody, EditMessageBody, ReactionBody } from '@altricade/core';
|
||||
|
|
@ -161,6 +162,26 @@ export const messagesRoutes = (app: FastifyInstance): Promise<void> => {
|
|||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string; messageId: string } }>(
|
||||
'/conversations/:id/messages/:messageId/media-url',
|
||||
{
|
||||
schema: {
|
||||
tags: ['media'],
|
||||
summary: 'Get a presigned download URL for a message attachment (member only)',
|
||||
security: bearerAuth,
|
||||
params: messageParamsSchema,
|
||||
response: { 200: mediaUrlSchema, 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 url = await app.messagesService.mediaUrl(request.params.id, request.params.messageId, user.id);
|
||||
return reply.send({ url });
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string; messageId: string; emoji: string } }>(
|
||||
'/conversations/:id/messages/:messageId/reactions/:emoji',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { EventType } from '@altricade/core';
|
||||
import type {
|
||||
Message,
|
||||
MessageNewEvent,
|
||||
|
|
@ -16,6 +17,7 @@ export interface MessagesServiceDeps {
|
|||
messages: MessagesRepository;
|
||||
conversations: ConversationsRepository;
|
||||
deliver: Deliver;
|
||||
mediaDownloadUrl: (objectKey: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface SentMessage {
|
||||
|
|
@ -50,10 +52,11 @@ export interface MessagesService {
|
|||
userId: string,
|
||||
emoji: string,
|
||||
): Promise<void>;
|
||||
mediaUrl(conversationId: string, messageId: string, userId: string): Promise<string>;
|
||||
}
|
||||
|
||||
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
|
||||
const { messages, conversations, deliver } = deps;
|
||||
const { messages, conversations, deliver, mediaDownloadUrl } = deps;
|
||||
|
||||
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
|
||||
if (!(await conversations.isMember(conversationId, userId))) {
|
||||
|
|
@ -84,9 +87,11 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
conversationId,
|
||||
senderId,
|
||||
clientMsgId: input.clientMsgId,
|
||||
content: input.content,
|
||||
content: input.content ?? '',
|
||||
contentType: input.contentType ?? 'text',
|
||||
encryption: input.encryption ?? null,
|
||||
mediaKey: input.mediaKey ?? null,
|
||||
media: input.media ?? null,
|
||||
});
|
||||
|
||||
const id =
|
||||
|
|
@ -103,7 +108,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
|
||||
if (inserted !== undefined) {
|
||||
await conversations.touchLastMessage(conversationId);
|
||||
const event: MessageNewEvent = { type: 'message.new', message };
|
||||
const event: MessageNewEvent = { type: EventType.MessageNew, message };
|
||||
await deliver(conversationId, event);
|
||||
}
|
||||
return { message, created: inserted !== undefined };
|
||||
|
|
@ -127,7 +132,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
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 };
|
||||
const event: MessageEditEvent = { type: EventType.MessageEdit, message };
|
||||
await deliver(conversationId, event);
|
||||
return message;
|
||||
},
|
||||
|
|
@ -139,7 +144,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
if (deleted === undefined) {
|
||||
throw new HttpError(403, 'not_deletable', 'You can only delete your own messages');
|
||||
}
|
||||
const event: MessageDeleteEvent = { type: 'message.delete', conversationId, messageId };
|
||||
const event: MessageDeleteEvent = { type: EventType.MessageDelete, conversationId, messageId };
|
||||
await deliver(conversationId, event);
|
||||
},
|
||||
|
||||
|
|
@ -148,7 +153,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
await assertMessageIn(conversationId, messageId);
|
||||
await messages.addReaction(messageId, userId, emoji);
|
||||
const event: ReactionEvent = {
|
||||
type: 'reaction.add',
|
||||
type: EventType.ReactionAdd,
|
||||
conversationId,
|
||||
messageId,
|
||||
emoji,
|
||||
|
|
@ -162,7 +167,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
await assertMessageIn(conversationId, messageId);
|
||||
await messages.removeReaction(messageId, userId, emoji);
|
||||
const event: ReactionEvent = {
|
||||
type: 'reaction.remove',
|
||||
type: EventType.ReactionRemove,
|
||||
conversationId,
|
||||
messageId,
|
||||
emoji,
|
||||
|
|
@ -170,6 +175,17 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
|||
};
|
||||
await deliver(conversationId, event);
|
||||
},
|
||||
|
||||
mediaUrl: async (conversationId, messageId, userId) => {
|
||||
await assertMember(conversationId, userId);
|
||||
await assertMessageIn(conversationId, messageId);
|
||||
const row = await messages.getWithSenderById(messageId);
|
||||
const mediaKey = row?.media_key ?? null;
|
||||
if (mediaKey === null) {
|
||||
throw new HttpError(404, 'not_found', 'No media on this message');
|
||||
}
|
||||
return mediaDownloadUrl(mediaKey);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { isUserChannel, conversationChannelId, userChannel } from '@altricade/core';
|
||||
import {
|
||||
isUserChannel,
|
||||
conversationChannelId,
|
||||
ephemeralChannelId,
|
||||
userChannel,
|
||||
} from '@altricade/core';
|
||||
|
||||
export type SubscribeDecision =
|
||||
| { allowed: true }
|
||||
|
|
@ -26,7 +31,9 @@ export const authorizeSubscription = async (
|
|||
return channel === userChannel(userId) ? { allowed: true } : deny('permission denied');
|
||||
}
|
||||
|
||||
const conversationId = conversationChannelId(channel);
|
||||
// Group message channel (conv:) and the ephemeral typing channel (eph:) are
|
||||
// both authorized by conversation membership.
|
||||
const conversationId = conversationChannelId(channel) ?? ephemeralChannelId(channel);
|
||||
if (conversationId !== null) {
|
||||
if (userId === '') {
|
||||
return deny('permission denied');
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface UsersRepository {
|
|||
findManyByIds(ids: string[]): Promise<UserRow[]>;
|
||||
searchByPrefix(query: string, excludeUserId: string, limit: number): Promise<UserRow[]>;
|
||||
updateProfile(id: string, patch: ProfilePatch): Promise<UserRow | undefined>;
|
||||
setAvatar(id: string, avatarRef: string): Promise<UserRow | undefined>;
|
||||
touchLastSeen(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +83,14 @@ export const createUsersRepository = (db: Kysely<Database>): UsersRepository =>
|
|||
.executeTakeFirst();
|
||||
},
|
||||
|
||||
setAvatar: (id, avatarRef) =>
|
||||
db
|
||||
.updateTable('users')
|
||||
.set({ avatar_ref: avatarRef, updated_at: new Date() })
|
||||
.where('id', '=', id)
|
||||
.returningAll()
|
||||
.executeTakeFirst(),
|
||||
|
||||
touchLastSeen: async (id) => {
|
||||
await db.updateTable('users').set({ last_seen_at: new Date() }).where('id', '=', id).execute();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
updateMeBodySchema,
|
||||
setAvatarBodySchema,
|
||||
userSchema,
|
||||
publicUserSchema,
|
||||
publicUserListSchema,
|
||||
errorSchema,
|
||||
} from '@altricade/core';
|
||||
import type { UpdateMeBody } from '@altricade/core';
|
||||
import type { UpdateMeBody, SetAvatarBody } from '@altricade/core';
|
||||
|
||||
const searchQuerySchema = {
|
||||
type: 'object',
|
||||
|
|
@ -87,6 +88,28 @@ export const usersRoutes = (app: FastifyInstance): Promise<void> => {
|
|||
},
|
||||
);
|
||||
|
||||
app.post<{ Body: SetAvatarBody }>(
|
||||
'/me/avatar',
|
||||
{
|
||||
schema: {
|
||||
tags: ['users'],
|
||||
summary: 'Set the current user avatar (from an uploaded object key)',
|
||||
security: bearerAuth,
|
||||
body: setAvatarBodySchema,
|
||||
response: { 200: userSchema, 401: errorSchema },
|
||||
},
|
||||
preHandler: app.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.authUser;
|
||||
if (user === undefined) {
|
||||
return reply.code(401).send({ error: 'unauthorized' });
|
||||
}
|
||||
const avatarUrl = app.mediaService.avatarPublicUrl(request.body.objectKey);
|
||||
return reply.send(await app.usersService.setAvatar(user.id, avatarUrl));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/me/heartbeat',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import type { User, PublicUser, UpdateMeBody } from '@altricade/core';
|
||||
import type { User, PublicUser, UpdateMeBody, ProfileUpdateEvent } from '@altricade/core';
|
||||
import { userChannel, EventType } from '@altricade/core';
|
||||
import { HttpError } from '../../shared/http-error';
|
||||
import type { Publisher } from '../../shared/publisher';
|
||||
import type { UsersRepository } from './users.repository';
|
||||
import { toUser, toPublicUser } from './users.mapper';
|
||||
|
||||
export interface UsersServiceDeps {
|
||||
users: UsersRepository;
|
||||
publish: Publisher;
|
||||
}
|
||||
|
||||
export interface UsersService {
|
||||
getMe(userId: string): Promise<User>;
|
||||
getPublicProfile(username: string): Promise<PublicUser>;
|
||||
updateProfile(userId: string, patch: UpdateMeBody): Promise<User>;
|
||||
setAvatar(userId: string, avatarUrl: string): Promise<User>;
|
||||
search(query: string, excludeUserId: string): Promise<PublicUser[]>;
|
||||
heartbeat(userId: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -16,7 +24,7 @@ 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 => ({
|
||||
export const createUsersService = ({ users, publish }: UsersServiceDeps): UsersService => ({
|
||||
getMe: async (userId) => {
|
||||
const row = await users.findById(userId);
|
||||
if (row === undefined) {
|
||||
|
|
@ -41,6 +49,22 @@ export const createUsersService = (users: UsersRepository): UsersService => ({
|
|||
return toUser(row);
|
||||
},
|
||||
|
||||
setAvatar: async (userId, avatarUrl) => {
|
||||
const row = await users.setAvatar(userId, avatarUrl);
|
||||
if (row === undefined) {
|
||||
throw new HttpError(404, 'not_found', 'User not found');
|
||||
}
|
||||
const user = toUser(row);
|
||||
const event: ProfileUpdateEvent = {
|
||||
type: EventType.ProfileUpdate,
|
||||
userId,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
await publish(userChannel(userId), event);
|
||||
return user;
|
||||
},
|
||||
|
||||
search: async (query, excludeUserId) => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) {
|
||||
|
|
|
|||
|
|
@ -4,24 +4,34 @@ import { Client } from 'minio';
|
|||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
minio: Client;
|
||||
// Client configured with the browser-facing host — used ONLY for presigning
|
||||
// upload/download URLs so the signature matches the host the browser hits.
|
||||
minioPublic: Client;
|
||||
}
|
||||
}
|
||||
|
||||
// S3-compatible object storage client (media messages + avatars). Buckets are
|
||||
// bootstrapped by the compose `minio-setup` one-shot; this client is used for
|
||||
// readiness checks now and presigned uploads from Phase 6.
|
||||
// bootstrapped by the compose `minio-setup` one-shot.
|
||||
export const minioPlugin = fp(
|
||||
(app) => {
|
||||
const { endpoint, port, useSSL, accessKey, secretKey } = app.config.minio;
|
||||
const client = new Client({
|
||||
endPoint: endpoint,
|
||||
port,
|
||||
useSSL,
|
||||
const { endpoint, port, useSSL, accessKey, secretKey, region, publicUrl } = app.config.minio;
|
||||
const client = new Client({ endPoint: endpoint, port, useSSL, accessKey, secretKey, region });
|
||||
app.decorate('minio', client);
|
||||
|
||||
const parsed = new URL(publicUrl);
|
||||
const publicSecure = parsed.protocol === 'https:';
|
||||
const publicPort = parsed.port === '' ? (publicSecure ? 443 : 80) : Number.parseInt(parsed.port, 10);
|
||||
// Explicit region so presigning is purely computational — no getBucketRegion
|
||||
// network call to the browser-facing host (unreachable from inside the container).
|
||||
const publicClient = new Client({
|
||||
endPoint: parsed.hostname,
|
||||
port: publicPort,
|
||||
useSSL: publicSecure,
|
||||
accessKey,
|
||||
secretKey,
|
||||
region,
|
||||
});
|
||||
|
||||
app.decorate('minio', client);
|
||||
app.decorate('minioPublic', publicClient);
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -61,11 +61,3 @@ export const markRead = async (
|
|||
): 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,7 +13,7 @@ export {
|
|||
getCentrifugoToken,
|
||||
} from './auth';
|
||||
export { sendEcho } from './realtime';
|
||||
export { searchUsers } from './users';
|
||||
export { searchUsers, setAvatar } from './users';
|
||||
export {
|
||||
listConversations,
|
||||
getConversation,
|
||||
|
|
@ -33,5 +33,7 @@ export {
|
|||
removeReaction,
|
||||
} from './messages';
|
||||
export type { HistoryOptions } from './messages';
|
||||
export { markRead, sendTyping } from './conversations';
|
||||
export { markRead } from './conversations';
|
||||
export { getPresence, heartbeat } from './presence';
|
||||
export { getUploadUrl, getAvatarUploadUrl, getMediaUrl, uploadToUrl } from './media';
|
||||
export type { UploadTarget, AvatarTarget } from './media';
|
||||
|
|
|
|||
55
packages/core/src/api/media.ts
Normal file
55
packages/core/src/api/media.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { uploadTargetSchema, avatarTargetSchema, mediaUrlSchema } from '../schemas/index';
|
||||
import type { UploadUrlBody, AvatarUploadBody } from '../schemas/index';
|
||||
import { compileValidator, parse, requestJson } from './http';
|
||||
import type { ApiClientConfig } from './http';
|
||||
|
||||
export interface UploadTarget {
|
||||
uploadUrl: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
export interface AvatarTarget {
|
||||
uploadUrl: string;
|
||||
objectKey: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
const uploadTargetV = compileValidator<UploadTarget>(uploadTargetSchema);
|
||||
const avatarTargetV = compileValidator<AvatarTarget>(avatarTargetSchema);
|
||||
const mediaUrlV = compileValidator<{ url: string }>(mediaUrlSchema);
|
||||
|
||||
export const getUploadUrl = async (
|
||||
config: ApiClientConfig,
|
||||
body: UploadUrlBody,
|
||||
): Promise<UploadTarget> =>
|
||||
parse(uploadTargetV, await requestJson(config, 'POST', '/media/upload-url', body));
|
||||
|
||||
export const getAvatarUploadUrl = async (
|
||||
config: ApiClientConfig,
|
||||
body: AvatarUploadBody,
|
||||
): Promise<AvatarTarget> =>
|
||||
parse(avatarTargetV, await requestJson(config, 'POST', '/media/avatar-url', body));
|
||||
|
||||
export const getMediaUrl = async (
|
||||
config: ApiClientConfig,
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
): Promise<string> => {
|
||||
const result = parse(
|
||||
mediaUrlV,
|
||||
await requestJson(config, 'GET', `/conversations/${conversationId}/messages/${messageId}/media-url`),
|
||||
);
|
||||
return result.url;
|
||||
};
|
||||
|
||||
// Direct PUT of the file bytes to the presigned MinIO URL (no auth/credentials).
|
||||
export const uploadToUrl = async (uploadUrl: string, file: Blob, mime: string): Promise<void> => {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': mime },
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status ${String(response.status)}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { publicUserListSchema } from '../schemas/index';
|
||||
import type { PublicUser } from '../types/index';
|
||||
import { publicUserListSchema, userSchema } from '../schemas/index';
|
||||
import type { SetAvatarBody } from '../schemas/index';
|
||||
import type { PublicUser, User } from '../types/index';
|
||||
import { compileValidator, parse, requestJson } from './http';
|
||||
import type { ApiClientConfig } from './http';
|
||||
|
||||
const publicUserListV = compileValidator<PublicUser[]>(publicUserListSchema);
|
||||
const userV = compileValidator<User>(userSchema);
|
||||
|
||||
export const searchUsers = async (
|
||||
config: ApiClientConfig,
|
||||
|
|
@ -13,3 +15,6 @@ export const searchUsers = async (
|
|||
publicUserListV,
|
||||
await requestJson(config, 'GET', `/users/search?q=${encodeURIComponent(query)}`),
|
||||
);
|
||||
|
||||
export const setAvatar = async (config: ApiClientConfig, body: SetAvatarBody): Promise<User> =>
|
||||
parse(userV, await requestJson(config, 'POST', '/me/avatar', body));
|
||||
|
|
|
|||
|
|
@ -10,12 +10,18 @@ export type UserId = string;
|
|||
|
||||
const CONV_PREFIX = 'conv:';
|
||||
const USER_PREFIX = 'user:';
|
||||
// Ephemeral channel (typing/recording): clients may publish here directly. Only
|
||||
// throwaway events are handled from it, so a forged publish is harmless.
|
||||
const EPH_PREFIX = 'eph:';
|
||||
|
||||
export const conversationChannel = (conversationId: ConversationId): string =>
|
||||
`${CONV_PREFIX}${conversationId}`;
|
||||
|
||||
export const userChannel = (userId: UserId): string => `${USER_PREFIX}${userId}`;
|
||||
|
||||
export const ephemeralChannel = (conversationId: ConversationId): string =>
|
||||
`${EPH_PREFIX}${conversationId}`;
|
||||
|
||||
/** True for a `conv:<id>` channel name. */
|
||||
export const isConversationChannel = (channel: string): boolean => channel.startsWith(CONV_PREFIX);
|
||||
|
||||
|
|
@ -29,3 +35,7 @@ export const conversationChannelId = (channel: string): string | null =>
|
|||
/** Extract the user id from a `user:<id>` channel, or null. */
|
||||
export const userChannelId = (channel: string): string | null =>
|
||||
channel.startsWith(USER_PREFIX) ? channel.slice(USER_PREFIX.length) : null;
|
||||
|
||||
/** Extract the conversation id from an `eph:<id>` channel, or null. */
|
||||
export const ephemeralChannelId = (channel: string): string | null =>
|
||||
channel.startsWith(EPH_PREFIX) ? channel.slice(EPH_PREFIX.length) : null;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
// Bucket A — durable, must be correct → through the backend (persist, publish)
|
||||
// Bucket B — ephemeral, throwaway → published, not persisted
|
||||
// Bucket C — connection-derived → Centrifugo built-in presence
|
||||
//
|
||||
// EventType is the single source of truth for every event `type` value — never
|
||||
// write the raw string literal anywhere; reference EventType.* instead.
|
||||
|
||||
import type { Message } from '../types/message';
|
||||
import type { Conversation } from '../types/conversation';
|
||||
|
|
@ -35,44 +38,43 @@ export const EventType = {
|
|||
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||
|
||||
// --- Realtime event payloads (published by the backend, handled by clients) ---
|
||||
// Each `type` field is derived from EventType so a typo is a compile error.
|
||||
|
||||
export interface MessageNewEvent {
|
||||
type: 'message.new';
|
||||
type: typeof EventType.MessageNew;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export interface MessageEditEvent {
|
||||
type: typeof EventType.MessageEdit;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export interface MessageDeleteEvent {
|
||||
type: typeof EventType.MessageDelete;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
// 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';
|
||||
type: typeof EventType.ConversationNew;
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
export type ConversationMembershipAction = 'added' | 'removed';
|
||||
|
||||
export interface ConversationMembershipEvent {
|
||||
type: 'conversation.membership';
|
||||
type: typeof EventType.ConversationMembership;
|
||||
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;
|
||||
type: typeof EventType.ReactionAdd | typeof EventType.ReactionRemove;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
|
|
@ -80,16 +82,21 @@ export interface ReactionEvent {
|
|||
}
|
||||
|
||||
export interface ReadReceiptEvent {
|
||||
type: 'read.receipt';
|
||||
type: typeof EventType.ReadReceipt;
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export type TypingState = 'typing.start' | 'typing.stop';
|
||||
|
||||
export interface TypingEvent {
|
||||
type: TypingState;
|
||||
type: typeof EventType.TypingStart | typeof EventType.TypingStop;
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ProfileUpdateEvent {
|
||||
type: typeof EventType.ProfileUpdate;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export interface RealtimeEvent {
|
|||
data: unknown;
|
||||
}
|
||||
|
||||
export type PresenceAction = 'join' | 'leave';
|
||||
|
||||
export interface RealtimeClientOptions {
|
||||
/** Centrifugo WebSocket URL, e.g. '/connection/websocket' (same-origin). */
|
||||
url: string;
|
||||
|
|
@ -15,11 +17,12 @@ export interface RealtimeClientOptions {
|
|||
getToken: () => Promise<string>;
|
||||
onState?: (state: ConnectionState) => void;
|
||||
onEvent?: (event: RealtimeEvent) => void;
|
||||
/** Fired when a user joins/leaves a subscribed channel (channel presence). */
|
||||
onPresence?: (channel: string, action: PresenceAction, userId: string) => void;
|
||||
}
|
||||
|
||||
// UI-agnostic wrapper over the Centrifugo SDK. Receive-only: the app never
|
||||
// publishes through it (sends go over REST). Uses the platform's global
|
||||
// WebSocket (browser + React Native).
|
||||
// UI-agnostic wrapper over the Centrifugo SDK. Receive-only for messages; also
|
||||
// surfaces channel presence (join/leave + snapshot) for online indicators.
|
||||
export class RealtimeClient {
|
||||
private readonly centrifuge: Centrifuge;
|
||||
private readonly options: RealtimeClientOptions;
|
||||
|
|
@ -58,6 +61,12 @@ export class RealtimeClient {
|
|||
const data: unknown = ctx.data;
|
||||
this.options.onEvent?.({ channel, data });
|
||||
});
|
||||
subscription.on('join', (ctx) => {
|
||||
this.options.onPresence?.(channel, 'join', ctx.info.user);
|
||||
});
|
||||
subscription.on('leave', (ctx) => {
|
||||
this.options.onPresence?.(channel, 'leave', ctx.info.user);
|
||||
});
|
||||
subscription.subscribe();
|
||||
this.subscriptions.set(channel, subscription);
|
||||
}
|
||||
|
|
@ -71,4 +80,28 @@ export class RealtimeClient {
|
|||
this.centrifuge.removeSubscription(subscription);
|
||||
this.subscriptions.delete(channel);
|
||||
}
|
||||
|
||||
// Constrained client-side publish — used ONLY for ephemeral events (typing) on
|
||||
// channels that permit subscriber publish. Durable events never use this.
|
||||
async publish(channel: string, data: unknown): Promise<void> {
|
||||
const subscription = this.subscriptions.get(channel);
|
||||
if (subscription === undefined) {
|
||||
return;
|
||||
}
|
||||
await subscription.publish(data);
|
||||
}
|
||||
|
||||
// Current set of user ids present in a channel (presence snapshot).
|
||||
async presence(channel: string): Promise<string[]> {
|
||||
const subscription = this.subscriptions.get(channel);
|
||||
if (subscription === undefined) {
|
||||
return [];
|
||||
}
|
||||
const result = await subscription.presence();
|
||||
const users = new Set<string>();
|
||||
for (const client of Object.values(result.clients)) {
|
||||
users.add(client.user);
|
||||
}
|
||||
return [...users];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,7 @@
|
|||
export { RealtimeClient } from './client';
|
||||
export type { ConnectionState, RealtimeEvent, RealtimeClientOptions } from './client';
|
||||
export type {
|
||||
ConnectionState,
|
||||
RealtimeEvent,
|
||||
RealtimeClientOptions,
|
||||
PresenceAction,
|
||||
} from './client';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
import { mediaRefSchema } from './media';
|
||||
|
||||
const USERNAME_PATTERN = '^[a-zA-Z0-9_]{3,32}$';
|
||||
|
||||
|
|
@ -37,12 +38,16 @@ export const addMemberBodySchema = {
|
|||
export const sendMessageBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['content', 'clientMsgId'],
|
||||
required: ['clientMsgId'],
|
||||
properties: {
|
||||
content: { type: 'string', minLength: 1, maxLength: 16000 },
|
||||
// Caption; may be empty for a media-only message.
|
||||
content: { type: 'string', maxLength: 16000 },
|
||||
clientMsgId: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
contentType: { type: 'string', enum: ['text', 'text/ciphertext'] },
|
||||
encryption: { type: ['string', 'null'] },
|
||||
// Object key returned by the media upload URL + its metadata.
|
||||
mediaKey: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
media: mediaRefSchema,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// OpenAPI docs and response serialization, so the documented contract cannot
|
||||
// drift from what the API returns.
|
||||
|
||||
import { mediaRefSchema } from './media';
|
||||
|
||||
export const publicUserSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
|
@ -192,6 +194,7 @@ export const messageSchema = {
|
|||
'editedAt',
|
||||
'deletedAt',
|
||||
'reactions',
|
||||
'media',
|
||||
],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
|
|
@ -207,6 +210,7 @@ export const messageSchema = {
|
|||
editedAt: { type: ['string', 'null'], format: 'date-time' },
|
||||
deletedAt: { type: ['string', 'null'], format: 'date-time' },
|
||||
reactions: { type: 'array', items: reactionSummarySchema },
|
||||
media: { oneOf: [mediaRefSchema, { type: 'null' }] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,18 @@ export type {
|
|||
SendMessageBody,
|
||||
AddContactBody,
|
||||
} from './conversation';
|
||||
export { editMessageBodySchema, reactionBodySchema, readBodySchema } from './live';
|
||||
export type { EditMessageBody, ReactionBody, ReadBody } from './live';
|
||||
export {
|
||||
editMessageBodySchema,
|
||||
reactionBodySchema,
|
||||
readBodySchema,
|
||||
typingBodySchema,
|
||||
} from './live';
|
||||
export type { EditMessageBody, ReactionBody, ReadBody, TypingBody } from './live';
|
||||
mediaRefSchema,
|
||||
uploadUrlBodySchema,
|
||||
avatarUploadBodySchema,
|
||||
setAvatarBodySchema,
|
||||
uploadTargetSchema,
|
||||
avatarTargetSchema,
|
||||
mediaUrlSchema,
|
||||
} from './media';
|
||||
export type { MediaRef, MediaKind, UploadUrlBody, AvatarUploadBody, SetAvatarBody } from './media';
|
||||
export {
|
||||
publicUserSchema,
|
||||
publicUserListSchema,
|
||||
|
|
|
|||
|
|
@ -27,16 +27,6 @@ export const readBodySchema = {
|
|||
},
|
||||
} 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>;
|
||||
|
|
|
|||
83
packages/core/src/schemas/media.ts
Normal file
83
packages/core/src/schemas/media.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
|
||||
const MEDIA_KIND = { type: 'string', enum: ['image', 'video', 'voice', 'file'] } as const;
|
||||
|
||||
// Metadata for an attached media object (no object key — that stays server-side).
|
||||
export const mediaRefSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['kind', 'mime', 'size', 'name'],
|
||||
properties: {
|
||||
kind: MEDIA_KIND,
|
||||
mime: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
size: { type: 'integer', minimum: 0 },
|
||||
name: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
width: { type: 'integer', minimum: 0 },
|
||||
height: { type: 'integer', minimum: 0 },
|
||||
durationSec: { type: 'number', minimum: 0 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const uploadUrlBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['kind', 'mime', 'size'],
|
||||
properties: {
|
||||
kind: MEDIA_KIND,
|
||||
mime: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
size: { type: 'integer', minimum: 1, maximum: 104857600 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const avatarUploadBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['mime', 'size'],
|
||||
properties: {
|
||||
mime: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
size: { type: 'integer', minimum: 1, maximum: 10485760 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const setAvatarBodySchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['objectKey'],
|
||||
properties: {
|
||||
objectKey: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const uploadTargetSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['uploadUrl', 'objectKey'],
|
||||
properties: {
|
||||
uploadUrl: { type: 'string' },
|
||||
objectKey: { type: 'string' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const avatarTargetSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['uploadUrl', 'objectKey', 'publicUrl'],
|
||||
properties: {
|
||||
uploadUrl: { type: 'string' },
|
||||
objectKey: { type: 'string' },
|
||||
publicUrl: { type: 'string' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const mediaUrlSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['url'],
|
||||
properties: { url: { type: 'string' } },
|
||||
} as const;
|
||||
|
||||
export type MediaRef = FromSchema<typeof mediaRefSchema>;
|
||||
export type MediaKind = MediaRef['kind'];
|
||||
export type UploadUrlBody = FromSchema<typeof uploadUrlBodySchema>;
|
||||
export type AvatarUploadBody = FromSchema<typeof avatarUploadBodySchema>;
|
||||
export type SetAvatarBody = FromSchema<typeof setAvatarBodySchema>;
|
||||
|
|
@ -5,5 +5,5 @@ export type { User, PublicUser } from './user';
|
|||
export type { AuthResult, Session, CentrifugoToken } from './auth';
|
||||
export type { Conversation, ConversationType, ConversationMember } from './conversation';
|
||||
export type { Contact } from './contact';
|
||||
export type { Message, ReactionSummary } from './message';
|
||||
export type { Message, ReactionSummary, MediaRef } from './message';
|
||||
export type { Presence } from './presence';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { PublicUser } from './user';
|
||||
import type { MediaRef } from '../schemas/media';
|
||||
|
||||
export type { MediaRef } from '../schemas/media';
|
||||
|
||||
export interface ReactionSummary {
|
||||
emoji: string;
|
||||
|
|
@ -23,4 +26,5 @@ export interface Message {
|
|||
editedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
reactions: ReactionSummary[];
|
||||
media: MediaRef | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ReactElement, SyntheticEvent } from 'react';
|
||||
import type { Conversation, PublicUser, User } from '@altricade/core';
|
||||
import { getPresence, heartbeat } from '@altricade/core/api';
|
||||
import { heartbeat, getAvatarUploadUrl, uploadToUrl, setAvatar } from '@altricade/core/api';
|
||||
import { SessionProvider, useSession } from '../entities/session';
|
||||
import { AuthForm } from '../features/auth';
|
||||
import { RealtimeProvider, useRealtime } from '../features/realtime';
|
||||
|
|
@ -41,45 +41,28 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
|||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
const { updateUser } = useSession();
|
||||
const { state } = useRealtime();
|
||||
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;
|
||||
const onAvatar = (event: SyntheticEvent<HTMLInputElement>): void => {
|
||||
const input = event.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file === undefined) {
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
const mime = file.type.length > 0 ? file.type : 'application/octet-stream';
|
||||
void (async () => {
|
||||
const target = await getAvatarUploadUrl(apiConfig, { mime, size: file.size });
|
||||
await uploadToUrl(target.uploadUrl, file, mime);
|
||||
updateUser(await setAvatar(apiConfig, { objectKey: target.objectKey }));
|
||||
})();
|
||||
};
|
||||
const [current, setCurrent] = useState<Conversation | null>(null);
|
||||
const { conversations, onlineMap, startDirect, createGroupChat } = useConversations(
|
||||
user.id,
|
||||
current?.id ?? null,
|
||||
);
|
||||
|
||||
// Keep-alive heartbeat so our own last-seen stays fresh while connected.
|
||||
useEffect(() => {
|
||||
|
|
@ -94,8 +77,18 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
|||
return (
|
||||
<div className="layout">
|
||||
<header className="topbar">
|
||||
<span>
|
||||
<strong>@{user.username}</strong> · socket: {state}
|
||||
<span className="topbar-user">
|
||||
<label className="avatar-edit" title="Change avatar">
|
||||
{user.avatarUrl !== null ? (
|
||||
<img src={user.avatarUrl} alt="avatar" className="avatar" />
|
||||
) : (
|
||||
<span className="avatar avatar-placeholder">{user.username.charAt(0)}</span>
|
||||
)}
|
||||
<input type="file" accept="image/*" hidden onChange={onAvatar} />
|
||||
</label>
|
||||
<span>
|
||||
<strong>@{user.username}</strong> · socket: {state}
|
||||
</span>
|
||||
</span>
|
||||
<span className="topbar-actions">
|
||||
<ThemeSwitch />
|
||||
|
|
|
|||
|
|
@ -451,3 +451,55 @@ body {
|
|||
font-style: italic;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.topbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatar-edit {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attach-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.media-img {
|
||||
max-width: 260px;
|
||||
max-height: 260px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-video {
|
||||
max-width: 320px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-file {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface SessionContextValue {
|
|||
register: (body: RegisterBody) => Promise<void>;
|
||||
login: (body: LoginBody) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (user: User) => void;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
|
@ -81,9 +82,13 @@ export const SessionProvider = ({ children }: { children: ReactNode }): ReactEle
|
|||
}
|
||||
}, [clear]);
|
||||
|
||||
const updateUser = useCallback((next: User): void => {
|
||||
setUser(next);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<SessionContextValue>(
|
||||
() => ({ status, user, register, login, logout }),
|
||||
[status, user, register, login, logout],
|
||||
() => ({ status, user, register, login, logout, updateUser }),
|
||||
[status, user, register, login, logout, updateUser],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Conversation } from '@altricade/core';
|
||||
import { userChannel } from '@altricade/core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Conversation, Message } from '@altricade/core';
|
||||
import { conversationChannel, userChannel, EventType } from '@altricade/core';
|
||||
import { listConversations, createDirect, createGroup } from '@altricade/core/api';
|
||||
import { apiConfig } from '../../shared/api';
|
||||
import { useRealtime } from '../realtime';
|
||||
|
|
@ -8,28 +8,72 @@ import { useRealtime } from '../realtime';
|
|||
export interface UseConversations {
|
||||
conversations: Conversation[];
|
||||
loading: boolean;
|
||||
onlineMap: Record<string, boolean>;
|
||||
startDirect: (username: string) => Promise<Conversation>;
|
||||
createGroupChat: (title: string, members: string[]) => Promise<Conversation>;
|
||||
}
|
||||
|
||||
const hasType = (data: unknown): data is { type: string } =>
|
||||
typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string';
|
||||
|
||||
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;
|
||||
};
|
||||
): data is { type: 'conversation.new'; conversation: Conversation } =>
|
||||
hasType(data) && data.type === EventType.ConversationNew && 'conversation' in data;
|
||||
|
||||
const isMessageNew = (data: unknown): data is { type: 'message.new'; message: Message } =>
|
||||
hasType(data) && data.type === EventType.MessageNew && 'message' 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 byRecency = (a: Conversation, b: Conversation): number =>
|
||||
b.lastMessageAt.localeCompare(a.lastMessageAt);
|
||||
|
||||
export const useConversations = (userId: string, currentId: string | null): UseConversations => {
|
||||
const { subscribe, onPresence, presence } = useRealtime();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [onlineMap, setOnlineMap] = useState<Record<string, boolean>>({});
|
||||
const presentByChannel = useRef<Map<string, Set<string>>>(new Map());
|
||||
const currentIdRef = useRef(currentId);
|
||||
currentIdRef.current = currentId;
|
||||
|
||||
const recomputeOnline = useCallback((): void => {
|
||||
const online: Record<string, boolean> = {};
|
||||
for (const set of presentByChannel.current.values()) {
|
||||
for (const uid of set) {
|
||||
online[uid] = true;
|
||||
}
|
||||
}
|
||||
setOnlineMap(online);
|
||||
}, []);
|
||||
|
||||
const bumpUnread = useCallback(
|
||||
(message: Message): void => {
|
||||
if (message.senderId === userId) {
|
||||
return;
|
||||
}
|
||||
setConversations((prev) =>
|
||||
prev
|
||||
.map((c) => {
|
||||
if (c.id !== message.conversationId) {
|
||||
return c;
|
||||
}
|
||||
const isCurrent = currentIdRef.current === c.id;
|
||||
return {
|
||||
...c,
|
||||
lastMessageAt: message.createdAt,
|
||||
unreadCount: isCurrent ? 0 : c.unreadCount + 1,
|
||||
};
|
||||
})
|
||||
.sort(byRecency),
|
||||
);
|
||||
},
|
||||
[userId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -51,16 +95,68 @@ export const useConversations = (userId: string): UseConversations => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// New conversations (a DM someone started with me, or a group I was added to)
|
||||
// arrive on my personal channel.
|
||||
// Personal channel: new conversations + direct-message arrivals (for unread).
|
||||
useEffect(() => {
|
||||
return subscribe(userChannel(userId), (event) => {
|
||||
if (isConversationNew(event.data)) {
|
||||
const { conversation } = event.data;
|
||||
setConversations((prev) => upsert(prev, conversation));
|
||||
} else if (isMessageNew(event.data)) {
|
||||
bumpUnread(event.data.message);
|
||||
}
|
||||
});
|
||||
}, [subscribe, userId]);
|
||||
}, [subscribe, userId, bumpUnread]);
|
||||
|
||||
// One subscription per conversation channel for LIVE presence (join/leave) and
|
||||
// group message arrivals (for unread). Re-runs when the set of conversations changes.
|
||||
const convKey = conversations
|
||||
.map((c) => c.id)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(',');
|
||||
useEffect(() => {
|
||||
const ids = convKey.split(',').filter((id) => id.length > 0);
|
||||
const cleanups: (() => void)[] = [];
|
||||
for (const id of ids) {
|
||||
const channel = conversationChannel(id);
|
||||
cleanups.push(
|
||||
subscribe(channel, (event) => {
|
||||
if (isMessageNew(event.data)) {
|
||||
bumpUnread(event.data.message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
cleanups.push(
|
||||
onPresence(channel, (action, uid) => {
|
||||
const set = presentByChannel.current.get(channel) ?? new Set<string>();
|
||||
if (action === 'join') {
|
||||
set.add(uid);
|
||||
} else {
|
||||
set.delete(uid);
|
||||
}
|
||||
presentByChannel.current.set(channel, set);
|
||||
recomputeOnline();
|
||||
}),
|
||||
);
|
||||
void presence(channel).then((uids) => {
|
||||
presentByChannel.current.set(channel, new Set(uids));
|
||||
recomputeOnline();
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [convKey, subscribe, onPresence, presence, recomputeOnline, bumpUnread]);
|
||||
|
||||
// Opening a conversation clears its unread badge immediately (markRead persists it).
|
||||
useEffect(() => {
|
||||
if (currentId !== null) {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === currentId ? { ...c, unreadCount: 0 } : c)),
|
||||
);
|
||||
}
|
||||
}, [currentId]);
|
||||
|
||||
const startDirect = useCallback(async (username: string): Promise<Conversation> => {
|
||||
const conversation = await createDirect(apiConfig, { username });
|
||||
|
|
@ -77,5 +173,5 @@ export const useConversations = (userId: string): UseConversations => {
|
|||
[],
|
||||
);
|
||||
|
||||
return { conversations, loading, startDirect, createGroupChat };
|
||||
return { conversations, loading, onlineMap, startDirect, createGroupChat };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,8 +10,17 @@ import type {
|
|||
TypingEvent,
|
||||
PublicUser,
|
||||
ReactionSummary,
|
||||
MediaRef,
|
||||
MediaKind,
|
||||
} from '@altricade/core';
|
||||
import {
|
||||
conversationChannel,
|
||||
userChannel,
|
||||
ephemeralChannel,
|
||||
mergeMessages,
|
||||
OPTIMISTIC_SEQ,
|
||||
EventType,
|
||||
} from '@altricade/core';
|
||||
import { conversationChannel, userChannel, mergeMessages, OPTIMISTIC_SEQ } from '@altricade/core';
|
||||
import {
|
||||
getHistory,
|
||||
sendMessage,
|
||||
|
|
@ -20,8 +29,10 @@ import {
|
|||
addReaction,
|
||||
removeReaction,
|
||||
markRead,
|
||||
sendTyping,
|
||||
getUploadUrl,
|
||||
uploadToUrl,
|
||||
} from '@altricade/core/api';
|
||||
import type { RealtimeEvent } from '@altricade/core/realtime';
|
||||
import { apiConfig } from '../../shared/api';
|
||||
import { useRealtime } from '../realtime';
|
||||
|
||||
|
|
@ -32,24 +43,32 @@ export interface UseConversationMessages {
|
|||
/** For a direct conversation: the peer's last-read seq (drives ✓✓). */
|
||||
peerReadSeq: number;
|
||||
send: (content: string) => Promise<void>;
|
||||
sendMedia: (file: File, caption: 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 mimeToKind = (mime: string): MediaKind => {
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'voice';
|
||||
return 'file';
|
||||
};
|
||||
|
||||
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;
|
||||
hasType(d) && (d.type === EventType.MessageNew || d.type === EventType.MessageEdit) && 'message' in d;
|
||||
const isDeleteEvent = (d: unknown): d is MessageDeleteEvent =>
|
||||
hasType(d) && d.type === 'message.delete' && 'messageId' in d && 'conversationId' in d;
|
||||
hasType(d) && d.type === EventType.MessageDelete && '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';
|
||||
hasType(d) && (d.type === EventType.ReactionAdd || d.type === EventType.ReactionRemove);
|
||||
const isReadEvent = (d: unknown): d is ReadReceiptEvent => hasType(d) && d.type === EventType.ReadReceipt;
|
||||
const isTypingEvent = (d: unknown): d is TypingEvent =>
|
||||
hasType(d) && (d.type === 'typing.start' || d.type === 'typing.stop');
|
||||
hasType(d) && (d.type === EventType.TypingStart || d.type === EventType.TypingStop);
|
||||
|
||||
const applyReaction = (
|
||||
reactions: ReactionSummary[],
|
||||
|
|
@ -76,7 +95,7 @@ export const useConversationMessages = (
|
|||
conversation: Conversation,
|
||||
me: PublicUser,
|
||||
): UseConversationMessages => {
|
||||
const { subscribe } = useRealtime();
|
||||
const { subscribe, publish } = useRealtime();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
|
||||
|
|
@ -100,7 +119,7 @@ export const useConversationMessages = (
|
|||
setTypingUserIds((prev) => prev.filter((id) => id !== userId));
|
||||
};
|
||||
|
||||
const unsubscribe = subscribe(channel, (event) => {
|
||||
const handler = (event: RealtimeEvent): void => {
|
||||
const data = event.data;
|
||||
|
||||
if (isMessageEvent(data)) {
|
||||
|
|
@ -124,7 +143,7 @@ export const useConversationMessages = (
|
|||
if (isReactionEvent(data)) {
|
||||
if (data.conversationId === conversationId) {
|
||||
const { messageId, emoji, userId } = data;
|
||||
const delta = data.type === 'reaction.add' ? 1 : -1;
|
||||
const delta = data.type === EventType.ReactionAdd ? 1 : -1;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === messageId
|
||||
|
|
@ -147,7 +166,7 @@ export const useConversationMessages = (
|
|||
return;
|
||||
}
|
||||
const { userId } = data;
|
||||
if (data.type === 'typing.stop') {
|
||||
if (data.type === EventType.TypingStop) {
|
||||
clearTyping(userId);
|
||||
return;
|
||||
}
|
||||
|
|
@ -163,7 +182,11 @@ export const useConversationMessages = (
|
|||
}, 4000),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Durable events arrive on the message channel; typing on the ephemeral channel.
|
||||
const unsubMessages = subscribe(channel, handler);
|
||||
const unsubEphemeral = subscribe(ephemeralChannel(conversationId), handler);
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
|
|
@ -181,7 +204,8 @@ export const useConversationMessages = (
|
|||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
unsubMessages();
|
||||
unsubEphemeral();
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
|
@ -216,6 +240,7 @@ export const useConversationMessages = (
|
|||
editedAt: null,
|
||||
deletedAt: null,
|
||||
reactions: [],
|
||||
media: null,
|
||||
};
|
||||
setMessages((prev) => mergeMessages(prev, [optimistic]));
|
||||
const confirmed = await sendMessage(apiConfig, conversationId, { content, clientMsgId });
|
||||
|
|
@ -224,6 +249,25 @@ export const useConversationMessages = (
|
|||
[conversationId, me],
|
||||
);
|
||||
|
||||
const sendMedia = useCallback(
|
||||
async (file: File, caption: string): Promise<void> => {
|
||||
const mime = file.type.length > 0 ? file.type : 'application/octet-stream';
|
||||
const kind = mimeToKind(mime);
|
||||
const { uploadUrl, objectKey } = await getUploadUrl(apiConfig, { kind, mime, size: file.size });
|
||||
await uploadToUrl(uploadUrl, file, mime);
|
||||
const media: MediaRef = { kind, mime, size: file.size, name: file.name };
|
||||
const clientMsgId = crypto.randomUUID();
|
||||
const confirmed = await sendMessage(apiConfig, conversationId, {
|
||||
content: caption,
|
||||
clientMsgId,
|
||||
mediaKey: objectKey,
|
||||
media,
|
||||
});
|
||||
setMessages((prev) => mergeMessages(prev, [confirmed]));
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const edit = useCallback(
|
||||
async (messageId: string, content: string): Promise<void> => {
|
||||
const updated = await apiEdit(apiConfig, conversationId, messageId, { content });
|
||||
|
|
@ -255,9 +299,11 @@ export const useConversationMessages = (
|
|||
const now = Date.now();
|
||||
if (now - lastTypingSent.current > 2500) {
|
||||
lastTypingSent.current = now;
|
||||
void sendTyping(apiConfig, conversationId, 'start');
|
||||
// Client-side publish straight to Centrifugo (ephemeral, no backend round-trip).
|
||||
const event: TypingEvent = { type: EventType.TypingStart, conversationId, userId: me.id };
|
||||
void publish(ephemeralChannel(conversationId), event);
|
||||
}
|
||||
}, [conversationId]);
|
||||
}, [conversationId, me.id, publish]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
|
@ -265,6 +311,7 @@ export const useConversationMessages = (
|
|||
typingUserIds,
|
||||
peerReadSeq,
|
||||
send,
|
||||
sendMedia,
|
||||
edit,
|
||||
remove,
|
||||
toggleReaction,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement, SyntheticEvent } from 'react';
|
||||
import type { Conversation, Message, PublicUser } from '@altricade/core';
|
||||
import { getMediaUrl } from '@altricade/core/api';
|
||||
import { apiConfig } from '../../../shared/api';
|
||||
import { useConversationMessages } from '../model';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -17,9 +19,70 @@ const headerTitle = (conversation: Conversation): string => {
|
|||
return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`;
|
||||
};
|
||||
|
||||
const MediaView = ({
|
||||
conversationId,
|
||||
message,
|
||||
}: {
|
||||
conversationId: string;
|
||||
message: Message;
|
||||
}): ReactElement | null => {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const media = message.media;
|
||||
|
||||
useEffect(() => {
|
||||
if (media === null) {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
getMediaUrl(apiConfig, conversationId, message.id)
|
||||
.then((resolved) => {
|
||||
if (!cancelled) {
|
||||
setUrl(resolved);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* media may be unavailable */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId, message.id, media]);
|
||||
|
||||
if (media === null) {
|
||||
return null;
|
||||
}
|
||||
if (url === null) {
|
||||
return <span className="muted">loading media…</span>;
|
||||
}
|
||||
if (media.kind === 'image') {
|
||||
return <img src={url} alt={media.name} className="media-img" />;
|
||||
}
|
||||
if (media.kind === 'video') {
|
||||
return <video src={url} controls className="media-video" />;
|
||||
}
|
||||
if (media.kind === 'voice') {
|
||||
return <audio src={url} controls />;
|
||||
}
|
||||
return (
|
||||
<a href={url} download={media.name} className="media-file">
|
||||
📎 {media.name}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
||||
const { messages, loading, typingUserIds, peerReadSeq, send, edit, remove, toggleReaction, notifyTyping } =
|
||||
useConversationMessages(conversation, me);
|
||||
const {
|
||||
messages,
|
||||
loading,
|
||||
typingUserIds,
|
||||
peerReadSeq,
|
||||
send,
|
||||
sendMedia,
|
||||
edit,
|
||||
remove,
|
||||
toggleReaction,
|
||||
notifyTyping,
|
||||
} = useConversationMessages(conversation, me);
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const submit = async (event: SyntheticEvent): Promise<void> => {
|
||||
|
|
@ -32,6 +95,15 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
|||
await send(trimmed);
|
||||
};
|
||||
|
||||
const onAttach = (event: SyntheticEvent<HTMLInputElement>): void => {
|
||||
const input = event.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
if (file !== undefined) {
|
||||
void sendMedia(file, '');
|
||||
}
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const onEdit = (message: Message): void => {
|
||||
const next = window.prompt('Edit message', message.content);
|
||||
if (next !== null && next.trim() !== '') {
|
||||
|
|
@ -55,9 +127,21 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
|||
<li key={message.id} className={message.senderId === me.id ? 'mine' : ''}>
|
||||
<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.deletedAt === null && message.media !== null ? (
|
||||
<MediaView conversationId={conversation.id} message={message} />
|
||||
) : null}
|
||||
{message.content.length > 0 ? (
|
||||
<span className="msg-body">
|
||||
{message.deletedAt !== null ? (
|
||||
<em className="muted">message deleted</em>
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{message.deletedAt !== null && message.media === null && message.content.length === 0 ? (
|
||||
<em className="muted">message deleted</em>
|
||||
) : null}
|
||||
{message.editedAt !== null && message.deletedAt === null ? (
|
||||
<span className="muted"> (edited)</span>
|
||||
) : null}
|
||||
|
|
@ -121,6 +205,10 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
|
|||
void submit(event);
|
||||
}}
|
||||
>
|
||||
<label className="attach-btn">
|
||||
📎
|
||||
<input type="file" hidden onChange={onAttach} />
|
||||
</label>
|
||||
<input
|
||||
value={text}
|
||||
placeholder="Write a message…"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { RealtimeClient } from '@altricade/core/realtime';
|
||||
import type { ConnectionState, RealtimeEvent } from '@altricade/core/realtime';
|
||||
import type { ConnectionState, RealtimeEvent, PresenceAction } from '@altricade/core/realtime';
|
||||
import { getCentrifugoToken } from '@altricade/core/api';
|
||||
import { apiConfig } from '../../shared/api';
|
||||
import { env } from '../../shared/config';
|
||||
import { useSession } from '../../entities/session';
|
||||
|
||||
type EventHandler = (event: RealtimeEvent) => void;
|
||||
type PresenceHandler = (action: PresenceAction, userId: string) => void;
|
||||
|
||||
export interface RealtimeContextValue {
|
||||
state: ConnectionState;
|
||||
/** Subscribe to a channel; returns an unsubscribe cleanup. */
|
||||
subscribe: (channel: string, handler: EventHandler) => () => void;
|
||||
onPresence: (channel: string, handler: PresenceHandler) => () => void;
|
||||
presence: (channel: string) => Promise<string[]>;
|
||||
publish: (channel: string, data: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
const RealtimeContext = createContext<RealtimeContextValue | null>(null);
|
||||
|
|
@ -29,7 +32,16 @@ export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactEl
|
|||
const { status } = useSession();
|
||||
const [state, setState] = useState<ConnectionState>('disconnected');
|
||||
const clientRef = useRef<RealtimeClient | null>(null);
|
||||
const handlersRef = useRef<Map<string, Set<EventHandler>>>(new Map());
|
||||
const pubHandlers = useRef<Map<string, Set<EventHandler>>>(new Map());
|
||||
const presHandlers = useRef<Map<string, Set<PresenceHandler>>>(new Map());
|
||||
|
||||
const releaseIfIdle = useCallback((channel: string): void => {
|
||||
const pubs = pubHandlers.current.get(channel);
|
||||
const pres = presHandlers.current.get(channel);
|
||||
if ((pubs === undefined || pubs.size === 0) && (pres === undefined || pres.size === 0)) {
|
||||
clientRef.current?.unsubscribe(channel);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'authenticated') {
|
||||
|
|
@ -40,17 +52,29 @@ export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactEl
|
|||
getToken: async () => (await getCentrifugoToken(apiConfig)).token,
|
||||
onState: setState,
|
||||
onEvent: (event) => {
|
||||
const handlers = handlersRef.current.get(event.channel);
|
||||
const handlers = pubHandlers.current.get(event.channel);
|
||||
if (handlers !== undefined) {
|
||||
for (const handler of handlers) {
|
||||
handler(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
onPresence: (channel, action, userId) => {
|
||||
const handlers = presHandlers.current.get(channel);
|
||||
if (handlers !== undefined) {
|
||||
for (const handler of handlers) {
|
||||
handler(action, userId);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
for (const channel of handlersRef.current.keys()) {
|
||||
const channels = new Set<string>([
|
||||
...pubHandlers.current.keys(),
|
||||
...presHandlers.current.keys(),
|
||||
]);
|
||||
for (const channel of channels) {
|
||||
client.subscribe(channel);
|
||||
}
|
||||
return () => {
|
||||
|
|
@ -59,29 +83,55 @@ export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactEl
|
|||
};
|
||||
}, [status]);
|
||||
|
||||
const subscribe = useCallback((channel: string, handler: EventHandler): (() => void) => {
|
||||
let handlers = handlersRef.current.get(channel);
|
||||
if (handlers === undefined) {
|
||||
handlers = new Set();
|
||||
handlersRef.current.set(channel, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
clientRef.current?.subscribe(channel);
|
||||
|
||||
return () => {
|
||||
const current = handlersRef.current.get(channel);
|
||||
if (current === undefined) {
|
||||
return;
|
||||
const subscribe = useCallback(
|
||||
(channel: string, handler: EventHandler): (() => void) => {
|
||||
let handlers = pubHandlers.current.get(channel);
|
||||
if (handlers === undefined) {
|
||||
handlers = new Set();
|
||||
pubHandlers.current.set(channel, handlers);
|
||||
}
|
||||
current.delete(handler);
|
||||
if (current.size === 0) {
|
||||
handlersRef.current.delete(channel);
|
||||
clientRef.current?.unsubscribe(channel);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
handlers.add(handler);
|
||||
clientRef.current?.subscribe(channel);
|
||||
return () => {
|
||||
pubHandlers.current.get(channel)?.delete(handler);
|
||||
releaseIfIdle(channel);
|
||||
};
|
||||
},
|
||||
[releaseIfIdle],
|
||||
);
|
||||
|
||||
const value = useMemo<RealtimeContextValue>(() => ({ state, subscribe }), [state, subscribe]);
|
||||
const onPresence = useCallback(
|
||||
(channel: string, handler: PresenceHandler): (() => void) => {
|
||||
let handlers = presHandlers.current.get(channel);
|
||||
if (handlers === undefined) {
|
||||
handlers = new Set();
|
||||
presHandlers.current.set(channel, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
clientRef.current?.subscribe(channel);
|
||||
return () => {
|
||||
presHandlers.current.get(channel)?.delete(handler);
|
||||
releaseIfIdle(channel);
|
||||
};
|
||||
},
|
||||
[releaseIfIdle],
|
||||
);
|
||||
|
||||
const presence = useCallback(
|
||||
(channel: string): Promise<string[]> => clientRef.current?.presence(channel) ?? Promise.resolve([]),
|
||||
[],
|
||||
);
|
||||
|
||||
const publish = useCallback(
|
||||
(channel: string, data: unknown): Promise<void> =>
|
||||
clientRef.current?.publish(channel, data) ?? Promise.resolve(),
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<RealtimeContextValue>(
|
||||
() => ({ state, subscribe, onPresence, presence, publish }),
|
||||
[state, subscribe, onPresence, presence, publish],
|
||||
);
|
||||
|
||||
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue