diff --git a/packages/backend/migrations/1720000000007_folders_pins_deletion.cjs b/packages/backend/migrations/1720000000007_folders_pins_deletion.cjs new file mode 100644 index 0000000..bf4327c --- /dev/null +++ b/packages/backend/migrations/1720000000007_folders_pins_deletion.cjs @@ -0,0 +1,86 @@ +// Phase 7 UX — per-user deletion modes, per-user chat state, folders + pins. +// +// All of this is per-user *view* state (Telegram semantics): hiding a message +// or clearing a chat never mutates the shared message rows; it records what +// this user no longer sees. Folders and pins are per-user and server-stored so +// they sync across devices via the personal channel. + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + // "Delete for me": tombstone per (user, message). History queries anti-join. + pgm.createTable('message_hidden', { + user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + message_id: { type: 'uuid', notNull: true, references: 'messages', onDelete: 'CASCADE' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('message_hidden', 'message_hidden_pkey', { + primaryKey: ['user_id', 'message_id'], + }); + + // "Clear history" / "delete chat" for me. Messages with seq <= cleared_up_to_seq + // are invisible to this member. hidden_at removes the chat from the list until + // new activity arrives (last_message_at > hidden_at brings it back). + pgm.addColumns('conversation_members', { + cleared_up_to_seq: { type: 'bigint', notNull: true, default: 0 }, + hidden_at: { type: 'timestamptz' }, + }); + + // Manual chat folders (per user), ordered by position. + pgm.createTable('chat_folders', { + id: { type: 'uuid', notNull: true, default: pgm.func('gen_random_uuid()'), primaryKey: true }, + user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + title: { type: 'text', notNull: true }, + position: { type: 'integer', notNull: true, default: 0 }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.createIndex('chat_folders', 'user_id'); + + pgm.createTable('chat_folder_items', { + folder_id: { type: 'uuid', notNull: true, references: 'chat_folders', onDelete: 'CASCADE' }, + conversation_id: { + type: 'uuid', + notNull: true, + references: 'conversations', + onDelete: 'CASCADE', + }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('chat_folder_items', 'chat_folder_items_pkey', { + primaryKey: ['folder_id', 'conversation_id'], + }); + + // Pins are scoped: folder_id NULL = pinned in the "All chats" tab. Pinning in + // one folder deliberately does not pin anywhere else. + pgm.createTable('chat_pins', { + user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + conversation_id: { + type: 'uuid', + notNull: true, + references: 'conversations', + onDelete: 'CASCADE', + }, + folder_id: { type: 'uuid', references: 'chat_folders', onDelete: 'CASCADE' }, + pinned_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + // Uniqueness needs two partial indexes because folder_id is nullable. + pgm.createIndex('chat_pins', ['user_id', 'conversation_id'], { + name: 'chat_pins_all_scope_unique', + unique: true, + where: 'folder_id IS NULL', + }); + pgm.createIndex('chat_pins', ['user_id', 'conversation_id', 'folder_id'], { + name: 'chat_pins_folder_scope_unique', + unique: true, + where: 'folder_id IS NOT NULL', + }); + pgm.createIndex('chat_pins', 'user_id'); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.dropTable('chat_pins'); + pgm.dropTable('chat_folder_items'); + pgm.dropTable('chat_folders'); + pgm.dropColumns('conversation_members', ['cleared_up_to_seq', 'hidden_at']); + pgm.dropTable('message_hidden'); +}; diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index eb93414..d33ae0a 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -24,6 +24,7 @@ import { } from './modules/conversations'; import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages'; import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts'; +import { createFoldersRepository, createFoldersService, foldersRoutes } from './modules/folders'; import { createPresenceService, presenceRoutes } from './modules/presence'; import { createMediaService, mediaRoutes } from './modules/media'; import { @@ -155,6 +156,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise mediaService.downloadUrl(objectKey), notify: (message) => { void notificationQueue @@ -173,6 +175,14 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise; joined_at: Generated; + // Per-user view state: messages with seq <= cleared_up_to_seq are invisible; + // hidden_at removes the chat from the list until new activity arrives. + cleared_up_to_seq: Generated; + hidden_at: Date | null; } export interface MessagesTable { @@ -114,6 +118,35 @@ export interface ConversationMutesTable { created_at: Generated; } +// "Delete for me" tombstones — history queries anti-join against this. +export interface MessageHiddenTable { + user_id: string; + message_id: string; + created_at: Generated; +} + +export interface ChatFoldersTable { + id: Generated; + user_id: string; + title: string; + position: Generated; + created_at: Generated; +} + +export interface ChatFolderItemsTable { + folder_id: string; + conversation_id: string; + created_at: Generated; +} + +// Pin scope: folder_id NULL = the "All chats" tab. Per-folder pins are independent. +export interface ChatPinsTable { + user_id: string; + conversation_id: string; + folder_id: string | null; + pinned_at: Generated; +} + export interface Database { users: UsersTable; refresh_tokens: RefreshTokensTable; @@ -126,4 +159,8 @@ export interface Database { device_tokens: DeviceTokensTable; notification_settings: NotificationSettingsTable; conversation_mutes: ConversationMutesTable; + message_hidden: MessageHiddenTable; + chat_folders: ChatFoldersTable; + chat_folder_items: ChatFolderItemsTable; + chat_pins: ChatPinsTable; } diff --git a/packages/backend/src/modules/conversations/conversations.repository.ts b/packages/backend/src/modules/conversations/conversations.repository.ts index 30f51ab..6ecc863 100644 --- a/packages/backend/src/modules/conversations/conversations.repository.ts +++ b/packages/backend/src/modules/conversations/conversations.repository.ts @@ -1,3 +1,4 @@ +import { sql } from 'kysely'; import type { Kysely, Selectable } from 'kysely'; import type { Database, ConversationsTable } from '../../db/schema'; @@ -38,9 +39,46 @@ export interface ConversationsRepository { listMembers(conversationId: string): Promise; getDeliveryInfo(conversationId: string): Promise; touchLastMessage(conversationId: string): Promise; + /** "Clear history" for me: hide everything up to the current max seq; resets unread. Returns that seq. */ + clearForUser(conversationId: string, userId: string): Promise; + /** "Delete chat" for me: clear + drop from the list until new activity arrives. Returns cleared seq. */ + hideForUser(conversationId: string, userId: string): Promise; } export const createConversationsRepository = (db: Kysely): ConversationsRepository => { + const clearForUser = (conversationId: string, userId: string): Promise => + db.transaction().execute(async (trx) => { + const max = await trx + .selectFrom('messages') + .select((eb) => eb.fn.max('seq').as('max_seq')) + .where('conversation_id', '=', conversationId) + .executeTakeFirst(); + const upTo = max?.max_seq ?? 0; + await trx + .updateTable('conversation_members') + .set({ cleared_up_to_seq: sql`greatest(cleared_up_to_seq, ${upTo})` }) + .where('conversation_id', '=', conversationId) + .where('user_id', '=', userId) + .execute(); + // Clearing also zeroes the unread badge. + await trx + .insertInto('read_state') + .values({ + conversation_id: conversationId, + user_id: userId, + last_read_seq: upTo, + 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(); + return upTo; + }); + const membersWithUser = (conversationId: string) => db .selectFrom('conversation_members') @@ -130,6 +168,13 @@ export const createConversationsRepository = (db: Kysely): Conversatio 'conversations.id', ) .where('conversation_members.user_id', '=', userId) + // A chat "deleted for me" stays gone until new activity arrives. + .where((eb) => + eb.or([ + eb('conversation_members.hidden_at', 'is', null), + eb('conversations.last_message_at', '>', eb.ref('conversation_members.hidden_at')), + ]), + ) .selectAll('conversations') .orderBy('conversations.last_message_at', 'desc') .execute(), @@ -247,5 +292,18 @@ export const createConversationsRepository = (db: Kysely): Conversatio .where('id', '=', conversationId) .execute(); }, + + clearForUser, + + hideForUser: async (conversationId, userId) => { + const upTo = await clearForUser(conversationId, userId); + await db + .updateTable('conversation_members') + .set({ hidden_at: new Date() }) + .where('conversation_id', '=', conversationId) + .where('user_id', '=', userId) + .execute(); + return upTo; + }, }; }; diff --git a/packages/backend/src/modules/conversations/conversations.routes.ts b/packages/backend/src/modules/conversations/conversations.routes.ts index a8a93b0..701e4b1 100644 --- a/packages/backend/src/modules/conversations/conversations.routes.ts +++ b/packages/backend/src/modules/conversations/conversations.routes.ts @@ -169,6 +169,46 @@ export const conversationsRoutes = (app: FastifyInstance): Promise => { }, ); + app.post<{ Params: { id: string } }>( + '/conversations/:id/clear', + { + schema: { + tags: ['conversations'], + summary: 'Clear history for me (chat stays listed)', + security: bearerAuth, + params: idParamsSchema, + 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.clear(request.params.id, user.id); + return reply.code(204).send(); + }, + ); + + app.post<{ Params: { id: string } }>( + '/conversations/:id/hide', + { + schema: { + tags: ['conversations'], + summary: 'Delete chat for me (returns on new activity)', + security: bearerAuth, + params: idParamsSchema, + 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.hide(request.params.id, user.id); + return reply.code(204).send(); + }, + ); + app.post<{ Params: { id: string }; Body: ReadBody }>( '/conversations/:id/read', { diff --git a/packages/backend/src/modules/conversations/conversations.service.ts b/packages/backend/src/modules/conversations/conversations.service.ts index c3a6c76..e66b613 100644 --- a/packages/backend/src/modules/conversations/conversations.service.ts +++ b/packages/backend/src/modules/conversations/conversations.service.ts @@ -3,6 +3,8 @@ import type { ConversationMember, ConversationNewEvent, ConversationMembershipEvent, + ConversationClearedEvent, + ConversationHiddenEvent, ReadReceiptEvent, } from '@altricade/core'; import { userChannel, EventType } from '@altricade/core'; @@ -34,6 +36,8 @@ export interface ConversationsService { isMember(conversationId: string, userId: string): Promise; getDeliveryInfo(conversationId: string): Promise; markRead(conversationId: string, userId: string, seq: number): Promise; + clear(conversationId: string, userId: string): Promise; + hide(conversationId: string, userId: string): Promise; } export const createConversationsService = ( @@ -180,6 +184,28 @@ export const createConversationsService = ( const event: ReadReceiptEvent = { type: EventType.ReadReceipt, conversationId, userId, seq }; await deliver(conversationId, event); }, + + // Per-user view state: events go to the acting user's personal channel only. + clear: async (conversationId, userId) => { + await assertMember(conversationId, userId); + const upToSeq = await conversations.clearForUser(conversationId, userId); + const event: ConversationClearedEvent = { + type: EventType.ConversationCleared, + conversationId, + upToSeq, + }; + await publish(userChannel(userId), event); + }, + + hide: async (conversationId, userId) => { + await assertMember(conversationId, userId); + await conversations.hideForUser(conversationId, userId); + const event: ConversationHiddenEvent = { + type: EventType.ConversationHidden, + conversationId, + }; + await publish(userChannel(userId), event); + }, }; }; diff --git a/packages/backend/src/modules/folders/folders.repository.ts b/packages/backend/src/modules/folders/folders.repository.ts new file mode 100644 index 0000000..64f921c --- /dev/null +++ b/packages/backend/src/modules/folders/folders.repository.ts @@ -0,0 +1,137 @@ +import type { Kysely } from 'kysely'; +import type { ChatFolder, ChatPin, FoldersState } from '@altricade/core'; +import type { Database } from '../../db/schema'; + +export interface FoldersRepository { + /** The user's full folders + pins snapshot (the shape every mutation returns). */ + getState(userId: string): Promise; + create(userId: string, title: string): Promise; + /** Returns false when the folder does not belong to the user. */ + owns(userId: string, folderId: string): Promise; + rename(folderId: string, title: string): Promise; + setPosition(folderId: string, position: number): Promise; + setChats(folderId: string, conversationIds: string[]): Promise; + remove(folderId: string): Promise; + pin(userId: string, conversationId: string, folderId: string | null): Promise; + unpin(userId: string, conversationId: string, folderId: string | null): Promise; +} + +export const createFoldersRepository = (db: Kysely): FoldersRepository => ({ + getState: async (userId) => { + const folders = await db + .selectFrom('chat_folders') + .select(['id', 'title', 'position']) + .where('user_id', '=', userId) + .orderBy('position', 'asc') + .orderBy('created_at', 'asc') + .execute(); + const items = + folders.length === 0 + ? [] + : await db + .selectFrom('chat_folder_items') + .select(['folder_id', 'conversation_id']) + .where( + 'folder_id', + 'in', + folders.map((folder) => folder.id), + ) + .orderBy('created_at', 'asc') + .execute(); + const pins = await db + .selectFrom('chat_pins') + .select(['conversation_id', 'folder_id', 'pinned_at']) + .where('user_id', '=', userId) + .orderBy('pinned_at', 'desc') + .execute(); + + const folderList: ChatFolder[] = folders.map((folder) => ({ + id: folder.id, + title: folder.title, + position: folder.position, + chatIds: items + .filter((item) => item.folder_id === folder.id) + .map((item) => item.conversation_id), + })); + const pinList: ChatPin[] = pins.map((pin) => ({ + conversationId: pin.conversation_id, + folderId: pin.folder_id, + pinnedAt: pin.pinned_at.toISOString(), + })); + return { folders: folderList, pins: pinList }; + }, + + create: async (userId, title) => { + // New folders go last. + const max = await db + .selectFrom('chat_folders') + .select((eb) => eb.fn.max('position').as('max_position')) + .where('user_id', '=', userId) + .executeTakeFirst(); + await db + .insertInto('chat_folders') + .values({ user_id: userId, title, position: (max?.max_position ?? -1) + 1 }) + .execute(); + }, + + owns: async (userId, folderId) => { + const row = await db + .selectFrom('chat_folders') + .select('id') + .where('id', '=', folderId) + .where('user_id', '=', userId) + .executeTakeFirst(); + return row !== undefined; + }, + + rename: async (folderId, title) => { + await db.updateTable('chat_folders').set({ title }).where('id', '=', folderId).execute(); + }, + + setPosition: async (folderId, position) => { + await db.updateTable('chat_folders').set({ position }).where('id', '=', folderId).execute(); + }, + + setChats: async (folderId, conversationIds) => { + await db.transaction().execute(async (trx) => { + await trx.deleteFrom('chat_folder_items').where('folder_id', '=', folderId).execute(); + if (conversationIds.length > 0) { + await trx + .insertInto('chat_folder_items') + .values( + conversationIds.map((conversationId) => ({ + folder_id: folderId, + conversation_id: conversationId, + })), + ) + .onConflict((oc) => oc.columns(['folder_id', 'conversation_id']).doNothing()) + .execute(); + } + }); + }, + + remove: async (folderId) => { + // Items + folder-scoped pins cascade via FK. + await db.deleteFrom('chat_folders').where('id', '=', folderId).execute(); + }, + + pin: async (userId, conversationId, folderId) => { + await db + .insertInto('chat_pins') + .values({ user_id: userId, conversation_id: conversationId, folder_id: folderId }) + .onConflict((oc) => oc.doNothing()) + .execute(); + }, + + unpin: async (userId, conversationId, folderId) => { + let query = db + .deleteFrom('chat_pins') + .where('user_id', '=', userId) + .where('conversation_id', '=', conversationId); + query = + folderId === null + ? query.where('folder_id', 'is', null) + : query.where('folder_id', '=', folderId); + await query.execute(); + }, +}); diff --git a/packages/backend/src/modules/folders/folders.routes.ts b/packages/backend/src/modules/folders/folders.routes.ts new file mode 100644 index 0000000..eb61e9e --- /dev/null +++ b/packages/backend/src/modules/folders/folders.routes.ts @@ -0,0 +1,119 @@ +import type { FastifyInstance } from 'fastify'; +import { + foldersStateSchema, + createFolderBodySchema, + updateFolderBodySchema, + setPinBodySchema, + errorSchema, +} from '@altricade/core'; +import type { CreateFolderBody, UpdateFolderBody, SetPinBody } from '@altricade/core'; + +const bearerAuth = [{ bearerAuth: [] }]; +const folderParamsSchema = { + type: 'object', + required: ['folderId'], + properties: { folderId: { type: 'string', format: 'uuid' } }, +} as const; + +export const foldersRoutes = (app: FastifyInstance): Promise => { + app.get( + '/folders', + { + schema: { + tags: ['folders'], + summary: 'Get chat folders + pins (full snapshot)', + security: bearerAuth, + response: { 200: foldersStateSchema, 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.foldersService.get(user.id)); + }, + ); + + app.post<{ Body: CreateFolderBody }>( + '/folders', + { + schema: { + tags: ['folders'], + summary: 'Create a chat folder', + security: bearerAuth, + body: createFolderBodySchema, + response: { 200: foldersStateSchema, 400: errorSchema, 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.foldersService.create(user.id, request.body)); + }, + ); + + // Registered before the :folderId routes is irrelevant for PUT (distinct + // method), but keep the static path clearly separate. + app.put<{ Body: SetPinBody }>( + '/folders/pins', + { + schema: { + tags: ['folders'], + summary: 'Pin/unpin a chat within a scope (folderId null = All chats)', + security: bearerAuth, + body: setPinBodySchema, + response: { 200: foldersStateSchema, 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.foldersService.setPin(user.id, request.body)); + }, + ); + + app.patch<{ Params: { folderId: string }; Body: UpdateFolderBody }>( + '/folders/:folderId', + { + schema: { + tags: ['folders'], + summary: 'Rename / reorder / set the chats of a folder', + security: bearerAuth, + params: folderParamsSchema, + body: updateFolderBodySchema, + response: { 200: foldersStateSchema, 401: 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.foldersService.update(user.id, request.params.folderId, request.body), + ); + }, + ); + + app.delete<{ Params: { folderId: string } }>( + '/folders/:folderId', + { + schema: { + tags: ['folders'], + summary: 'Delete a folder (its pins go with it; chats are untouched)', + security: bearerAuth, + params: folderParamsSchema, + response: { 200: foldersStateSchema, 401: 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.foldersService.remove(user.id, request.params.folderId)); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/folders/folders.service.ts b/packages/backend/src/modules/folders/folders.service.ts new file mode 100644 index 0000000..210fa24 --- /dev/null +++ b/packages/backend/src/modules/folders/folders.service.ts @@ -0,0 +1,106 @@ +import { EventType, userChannel } from '@altricade/core'; +import type { + FoldersState, + FoldersUpdateEvent, + CreateFolderBody, + UpdateFolderBody, + SetPinBody, +} from '@altricade/core'; +import { HttpError } from '../../shared/http-error'; +import type { Publisher } from '../../shared/publisher'; +import type { ConversationsRepository } from '../conversations'; +import type { FoldersRepository } from './folders.repository'; + +export interface FoldersServiceDeps { + folders: FoldersRepository; + conversations: ConversationsRepository; + publish: Publisher; +} + +export interface FoldersService { + get(userId: string): Promise; + create(userId: string, body: CreateFolderBody): Promise; + update(userId: string, folderId: string, body: UpdateFolderBody): Promise; + remove(userId: string, folderId: string): Promise; + setPin(userId: string, body: SetPinBody): Promise; +} + +const MAX_FOLDERS = 20; + +export const createFoldersService = (deps: FoldersServiceDeps): FoldersService => { + const { folders, conversations, publish } = deps; + + const assertOwnsFolder = async (userId: string, folderId: string): Promise => { + if (!(await folders.owns(userId, folderId))) { + throw new HttpError(404, 'not_found', 'Folder not found'); + } + }; + + // Every mutation returns the fresh snapshot AND pushes it to the user's + // personal channel so their other devices converge without refetching. + const snapshotAndBroadcast = async (userId: string): Promise => { + const state = await folders.getState(userId); + const event: FoldersUpdateEvent = { type: EventType.FoldersUpdate, state }; + await publish(userChannel(userId), event); + return state; + }; + + return { + get: (userId) => folders.getState(userId), + + create: async (userId, body) => { + const current = await folders.getState(userId); + if (current.folders.length >= MAX_FOLDERS) { + throw new HttpError(400, 'too_many_folders', `At most ${String(MAX_FOLDERS)} folders`); + } + await folders.create(userId, body.title); + return snapshotAndBroadcast(userId); + }, + + update: async (userId, folderId, body) => { + await assertOwnsFolder(userId, folderId); + if (body.title !== undefined) { + await folders.rename(folderId, body.title); + } + if (body.position !== undefined) { + await folders.setPosition(folderId, body.position); + } + if (body.chatIds !== undefined) { + // Only chats the user is actually in can enter a folder. + const memberships = await Promise.all( + body.chatIds.map((chatId) => conversations.isMember(chatId, userId)), + ); + const allowed = body.chatIds.filter((_, index) => memberships[index] === true); + await folders.setChats(folderId, allowed); + } + return snapshotAndBroadcast(userId); + }, + + remove: async (userId, folderId) => { + await assertOwnsFolder(userId, folderId); + await folders.remove(folderId); + return snapshotAndBroadcast(userId); + }, + + setPin: async (userId, body) => { + if (!(await conversations.isMember(body.conversationId, userId))) { + throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation'); + } + if (body.folderId !== null) { + await assertOwnsFolder(userId, body.folderId); + } + if (body.pinned) { + await folders.pin(userId, body.conversationId, body.folderId); + } else { + await folders.unpin(userId, body.conversationId, body.folderId); + } + return snapshotAndBroadcast(userId); + }, + }; +}; + +declare module 'fastify' { + interface FastifyInstance { + foldersService: FoldersService; + } +} diff --git a/packages/backend/src/modules/folders/index.ts b/packages/backend/src/modules/folders/index.ts new file mode 100644 index 0000000..8ad56e7 --- /dev/null +++ b/packages/backend/src/modules/folders/index.ts @@ -0,0 +1,5 @@ +export { createFoldersRepository } from './folders.repository'; +export type { FoldersRepository } from './folders.repository'; +export { createFoldersService } from './folders.service'; +export type { FoldersService, FoldersServiceDeps } from './folders.service'; +export { foldersRoutes } from './folders.routes'; diff --git a/packages/backend/src/modules/media/media.service.ts b/packages/backend/src/modules/media/media.service.ts index d5b3267..45eae7a 100644 --- a/packages/backend/src/modules/media/media.service.ts +++ b/packages/backend/src/modules/media/media.service.ts @@ -32,7 +32,7 @@ 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 === 'video' || kind === 'video_note') return mime.startsWith('video/'); if (kind === 'voice') return mime.startsWith('audio/'); return true; }; diff --git a/packages/backend/src/modules/messages/messages.repository.ts b/packages/backend/src/modules/messages/messages.repository.ts index 9a9b111..fe09e66 100644 --- a/packages/backend/src/modules/messages/messages.repository.ts +++ b/packages/backend/src/modules/messages/messages.repository.ts @@ -41,8 +41,18 @@ export interface MessagesRepository { clientMsgId: string, ): Promise; getWithSenderById(id: string): Promise; + /** History as seen by `userId`: excludes their hidden messages and anything at or below their clear mark. */ listHistory( conversationId: string, + userId: string, + beforeSeq: number | null, + limit: number, + ): Promise; + /** Media messages as seen by `userId` (profile panel tabs), newest first. */ + listMedia( + conversationId: string, + userId: string, + kinds: string[], beforeSeq: number | null, limit: number, ): Promise; @@ -53,6 +63,10 @@ export interface MessagesRepository { content: string, ): Promise<{ id: string } | undefined>; softDelete(messageId: string, senderId: string): Promise<{ id: string } | undefined>; + /** Moderation variant: no sender check (permission enforced in the service). */ + softDeleteAny(messageId: string): Promise<{ id: string } | undefined>; + /** "Delete for me": per-user tombstone; idempotent. */ + hide(messageId: string, userId: string): Promise; addReaction(messageId: string, userId: string, emoji: string): Promise; removeReaction(messageId: string, userId: string, emoji: string): Promise; reactionsFor(messageIds: string[], userId: string): Promise>; @@ -82,6 +96,28 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito 'users.avatar_ref as sender_avatar_ref', ]); + // The per-user view: joins the member row to honor the clear mark and + // anti-joins the "delete for me" tombstones. + const visibleTo = (userId: string) => + withSender() + .innerJoin('conversation_members', (join) => + join + .onRef('conversation_members.conversation_id', '=', 'messages.conversation_id') + .on('conversation_members.user_id', '=', userId), + ) + .whereRef('messages.seq', '>', 'conversation_members.cleared_up_to_seq') + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('message_hidden') + .select('message_hidden.message_id') + .whereRef('message_hidden.message_id', '=', 'messages.id') + .where('message_hidden.user_id', '=', userId), + ), + ), + ); + return { insert: (input) => db @@ -115,8 +151,20 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(), - listHistory: (conversationId, beforeSeq, limit) => { - let query = withSender().where('messages.conversation_id', '=', conversationId); + listHistory: (conversationId, userId, beforeSeq, limit) => { + let query = visibleTo(userId).where('messages.conversation_id', '=', conversationId); + if (beforeSeq !== null) { + query = query.where('messages.seq', '<', beforeSeq); + } + return query.orderBy('messages.seq', 'desc').limit(limit).execute(); + }, + + listMedia: (conversationId, userId, kinds, beforeSeq, limit) => { + let query = visibleTo(userId) + .where('messages.conversation_id', '=', conversationId) + .where('messages.deleted_at', 'is', null) + .where('messages.media_key', 'is not', null) + .where((eb) => eb(sql`messages.media_meta->>'kind'`, 'in', kinds)); if (beforeSeq !== null) { query = query.where('messages.seq', '<', beforeSeq); } @@ -152,6 +200,23 @@ export const createMessagesRepository = (db: Kysely): MessagesReposito .returning('id') .executeTakeFirst(), + softDeleteAny: (messageId) => + db + .updateTable('messages') + .set({ deleted_at: new Date(), content: '' }) + .where('id', '=', messageId) + .where('deleted_at', 'is', null) + .returning('id') + .executeTakeFirst(), + + hide: async (messageId, userId) => { + await db + .insertInto('message_hidden') + .values({ user_id: userId, message_id: messageId }) + .onConflict((oc) => oc.columns(['user_id', 'message_id']).doNothing()) + .execute(); + }, + addReaction: async (messageId, userId, emoji) => { await db .insertInto('reactions') diff --git a/packages/backend/src/modules/messages/messages.routes.ts b/packages/backend/src/modules/messages/messages.routes.ts index eb4226b..0d0833a 100644 --- a/packages/backend/src/modules/messages/messages.routes.ts +++ b/packages/backend/src/modules/messages/messages.routes.ts @@ -6,9 +6,10 @@ import { messageSchema, messageListSchema, mediaUrlSchema, + mediaListQuerySchema, errorSchema, } from '@altricade/core'; -import type { SendMessageBody, EditMessageBody, ReactionBody } from '@altricade/core'; +import type { SendMessageBody, EditMessageBody, ReactionBody, MediaTab } from '@altricade/core'; const bearerAuth = [{ bearerAuth: [] }]; const idParamsSchema = { @@ -136,6 +137,53 @@ export const messagesRoutes = (app: FastifyInstance): Promise => { }, ); + app.post<{ Params: { id: string; messageId: string } }>( + '/conversations/:id/messages/:messageId/hide', + { + schema: { + tags: ['messages'], + summary: 'Delete a message for me only (per-user hide)', + 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.hide(request.params.id, request.params.messageId, user.id); + return reply.code(204).send(); + }, + ); + + app.get<{ Params: { id: string }; Querystring: { tab: MediaTab; before?: number; limit: number } }>( + '/conversations/:id/media', + { + schema: { + tags: ['messages'], + summary: 'List shared media messages (profile panel tabs), newest first', + security: bearerAuth, + params: idParamsSchema, + querystring: mediaListQuerySchema, + response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const items = await app.messagesService.media( + request.params.id, + user.id, + request.query.tab, + request.query.before ?? null, + request.query.limit, + ); + return reply.send(items); + }, + ); + app.post<{ Params: { id: string; messageId: string }; Body: ReactionBody }>( '/conversations/:id/messages/:messageId/reactions', { diff --git a/packages/backend/src/modules/messages/messages.service.ts b/packages/backend/src/modules/messages/messages.service.ts index 8e9cd68..4263d7b 100644 --- a/packages/backend/src/modules/messages/messages.service.ts +++ b/packages/backend/src/modules/messages/messages.service.ts @@ -1,13 +1,16 @@ -import { EventType } from '@altricade/core'; +import { EventType, userChannel } from '@altricade/core'; import type { Message, MessageNewEvent, MessageEditEvent, MessageDeleteEvent, + MessageHiddenEvent, ReactionEvent, SendMessageBody, + MediaTab, } from '@altricade/core'; import { HttpError } from '../../shared/http-error'; +import type { Publisher } from '../../shared/publisher'; import type { ConversationsRepository } from '../conversations'; import type { Deliver } from '../conversations'; import type { MessagesRepository } from './messages.repository'; @@ -17,11 +20,20 @@ export interface MessagesServiceDeps { messages: MessagesRepository; conversations: ConversationsRepository; deliver: Deliver; + /** Personal-channel publisher for per-user view-state events. */ + publish: Publisher; mediaDownloadUrl: (objectKey: string) => Promise; /** Fire-and-forget push-notification hook, called for each newly-created message. */ notify: (message: Message) => void; } +// Telegram-style shared-media tabs → media kinds. +const TAB_KINDS: Record = { + media: ['image', 'video', 'video_note'], + files: ['file'], + voice: ['voice'], +}; + export interface SentMessage { message: Message; created: boolean; @@ -42,6 +54,14 @@ export interface MessagesService { content: string, ): Promise; remove(conversationId: string, messageId: string, userId: string): Promise; + hide(conversationId: string, messageId: string, userId: string): Promise; + media( + conversationId: string, + userId: string, + tab: MediaTab, + beforeSeq: number | null, + limit: number, + ): Promise; addReaction( conversationId: string, messageId: string, @@ -58,7 +78,7 @@ export interface MessagesService { } export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => { - const { messages, conversations, deliver, mediaDownloadUrl, notify } = deps; + const { messages, conversations, deliver, publish, mediaDownloadUrl, notify } = deps; const assertMember = async (conversationId: string, userId: string): Promise => { if (!(await conversations.isMember(conversationId, userId))) { @@ -120,7 +140,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic history: async (conversationId, userId, beforeSeq, limit) => { await assertMember(conversationId, userId); - const rows = await messages.listHistory(conversationId, beforeSeq, limit); + const rows = await messages.listHistory(conversationId, userId, beforeSeq, limit); const reactions = await messages.reactionsFor( rows.map((row) => row.id), userId, @@ -128,6 +148,12 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic return rows.map((row) => toMessage(row, reactions.get(row.id) ?? [])); }, + media: async (conversationId, userId, tab, beforeSeq, limit) => { + await assertMember(conversationId, userId); + const rows = await messages.listMedia(conversationId, userId, TAB_KINDS[tab], beforeSeq, limit); + return rows.map((row) => toMessage(row, [])); + }, + edit: async (conversationId, messageId, userId, content) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); @@ -144,14 +170,37 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic remove: async (conversationId, messageId, userId) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); - const deleted = await messages.softDelete(messageId, userId); + let deleted = await messages.softDelete(messageId, userId); if (deleted === undefined) { - throw new HttpError(403, 'not_deletable', 'You can only delete your own messages'); + // Not the sender: the owner of a group may moderate any message in it. + const conversation = await conversations.findById(conversationId); + const role = await conversations.getRole(conversationId, userId); + if (conversation?.type !== 'group' || role !== 'owner') { + throw new HttpError( + 403, + 'not_deletable', + 'You can only delete your own messages (group owners can delete any)', + ); + } + deleted = await messages.softDeleteAny(messageId); + } + if (deleted === undefined) { + // Already deleted — idempotent success, nothing to broadcast. + return; } const event: MessageDeleteEvent = { type: EventType.MessageDelete, conversationId, messageId }; await deliver(conversationId, event); }, + hide: async (conversationId, messageId, userId) => { + await assertMember(conversationId, userId); + await assertMessageIn(conversationId, messageId); + await messages.hide(messageId, userId); + // Personal channel only — other participants are unaffected by design. + const event: MessageHiddenEvent = { type: EventType.MessageHidden, conversationId, messageId }; + await publish(userChannel(userId), event); + }, + addReaction: async (conversationId, messageId, userId, emoji) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); diff --git a/packages/backend/src/modules/notifications/notifications.dispatch.ts b/packages/backend/src/modules/notifications/notifications.dispatch.ts index 4ee2341..2700c77 100644 --- a/packages/backend/src/modules/notifications/notifications.dispatch.ts +++ b/packages/backend/src/modules/notifications/notifications.dispatch.ts @@ -29,6 +29,8 @@ const mediaPlaceholder = (kind: MediaKind): string => { return 'Photo'; case 'video': return 'Video'; + case 'video_note': + return 'Video message'; case 'voice': return 'Voice message'; default: diff --git a/packages/core/src/api/conversations.ts b/packages/core/src/api/conversations.ts index 0821151..314bf25 100644 --- a/packages/core/src/api/conversations.ts +++ b/packages/core/src/api/conversations.ts @@ -61,3 +61,13 @@ export const markRead = async ( ): Promise => { await requestJson(config, 'POST', `/conversations/${id}/read`, { seq }); }; + +// "Clear history" (for me): hides all current messages; the chat stays listed. +export const clearConversation = async (config: ApiClientConfig, id: string): Promise => { + await requestJson(config, 'POST', `/conversations/${id}/clear`); +}; + +// "Delete chat" (for me): clear + remove from the list until new activity. +export const hideConversation = async (config: ApiClientConfig, id: string): Promise => { + await requestJson(config, 'POST', `/conversations/${id}/hide`); +}; diff --git a/packages/core/src/api/folders.ts b/packages/core/src/api/folders.ts new file mode 100644 index 0000000..5a0d8c0 --- /dev/null +++ b/packages/core/src/api/folders.ts @@ -0,0 +1,35 @@ +import { foldersStateSchema } from '../schemas/index'; +import type { CreateFolderBody, UpdateFolderBody, SetPinBody } from '../schemas/index'; +import type { FoldersState } from '../types/index'; +import { compileValidator, parse, requestJson } from './http'; +import type { ApiClientConfig } from './http'; + +const foldersStateV = compileValidator(foldersStateSchema); + +// Every mutation returns the full fresh snapshot (and the backend broadcasts +// the same snapshot to the user's personal channel for other devices). + +export const getFolders = async (config: ApiClientConfig): Promise => + parse(foldersStateV, await requestJson(config, 'GET', '/folders')); + +export const createFolder = async ( + config: ApiClientConfig, + body: CreateFolderBody, +): Promise => + parse(foldersStateV, await requestJson(config, 'POST', '/folders', body)); + +export const updateFolder = async ( + config: ApiClientConfig, + folderId: string, + body: UpdateFolderBody, +): Promise => + parse(foldersStateV, await requestJson(config, 'PATCH', `/folders/${folderId}`, body)); + +export const deleteFolder = async ( + config: ApiClientConfig, + folderId: string, +): Promise => + parse(foldersStateV, await requestJson(config, 'DELETE', `/folders/${folderId}`)); + +export const setPin = async (config: ApiClientConfig, body: SetPinBody): Promise => + parse(foldersStateV, await requestJson(config, 'PUT', '/folders/pins', body)); diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 14d7099..a512e1c 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -22,6 +22,8 @@ export { listMembers, addMember, removeMember, + clearConversation, + hideConversation, } from './conversations'; export { listContacts, addContact, removeContact } from './contacts'; export { @@ -29,9 +31,12 @@ export { getHistory, editMessage, deleteMessage, + hideMessage, + listConversationMedia, addReaction, removeReaction, } from './messages'; +export { getFolders, createFolder, updateFolder, deleteFolder, setPin } from './folders'; export type { HistoryOptions } from './messages'; export { markRead } from './conversations'; export { getPresence, heartbeat } from './presence'; diff --git a/packages/core/src/api/messages.ts b/packages/core/src/api/messages.ts index 12f909c..d00b04d 100644 --- a/packages/core/src/api/messages.ts +++ b/packages/core/src/api/messages.ts @@ -1,5 +1,5 @@ import { messageSchema, messageListSchema } from '../schemas/index'; -import type { SendMessageBody, EditMessageBody, ReactionBody } from '../schemas/index'; +import type { SendMessageBody, EditMessageBody, ReactionBody, MediaTab } from '../schemas/index'; import type { Message } from '../types/index'; import { compileValidator, parse, requestJson } from './http'; import type { ApiClientConfig } from './http'; @@ -48,6 +48,7 @@ export const editMessage = async ( await requestJson(config, 'PATCH', `/conversations/${conversationId}/messages/${messageId}`, body), ); +// "Delete for everyone" — allowed for the sender, or the group owner (moderation). export const deleteMessage = async ( config: ApiClientConfig, conversationId: string, @@ -56,6 +57,32 @@ export const deleteMessage = async ( await requestJson(config, 'DELETE', `/conversations/${conversationId}/messages/${messageId}`); }; +// "Delete for me" — hides the message for the acting user only. +export const hideMessage = async ( + config: ApiClientConfig, + conversationId: string, + messageId: string, +): Promise => { + await requestJson(config, 'POST', `/conversations/${conversationId}/messages/${messageId}/hide`); +}; + +// Shared media for the profile panel, newest first, keyset-paginated by seq. +export const listConversationMedia = async ( + config: ApiClientConfig, + conversationId: string, + tab: MediaTab, + before?: number, +): Promise => { + const params = new URLSearchParams({ tab }); + if (before !== undefined) { + params.set('before', String(before)); + } + return parse( + messageListV, + await requestJson(config, 'GET', `/conversations/${conversationId}/media?${params.toString()}`), + ); +}; + export const addReaction = async ( config: ApiClientConfig, conversationId: string, diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index bb7fc13..c4d22c1 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -11,6 +11,7 @@ import type { Message } from '../types/message'; import type { Conversation } from '../types/conversation'; +import type { FoldersState } from '../types/folders'; export const EventType = { // Bucket A @@ -24,6 +25,12 @@ export const EventType = { ConversationNew: 'conversation.new', ConversationMembership: 'conversation.membership', ProfileUpdate: 'profile.update', + // Per-user view state, published to the acting user's personal channel so + // their other devices stay in sync. + MessageHidden: 'message.hidden', + ConversationCleared: 'conversation.cleared', + ConversationHidden: 'conversation.hidden', + FoldersUpdate: 'folders.update', // Bucket B TypingStart: 'typing.start', @@ -100,3 +107,29 @@ export interface ProfileUpdateEvent { displayName: string; avatarUrl: string | null; } + +// "Delete for me" — only the acting user's devices hide the message. +export interface MessageHiddenEvent { + type: typeof EventType.MessageHidden; + conversationId: string; + messageId: string; +} + +// "Clear history" (for me) — drop everything up to and including upToSeq. +export interface ConversationClearedEvent { + type: typeof EventType.ConversationCleared; + conversationId: string; + upToSeq: number; +} + +// "Delete chat" (for me) — remove from the list until new activity arrives. +export interface ConversationHiddenEvent { + type: typeof EventType.ConversationHidden; + conversationId: string; +} + +// Folders/pins changed on some device; payload is the fresh full snapshot. +export interface FoldersUpdateEvent { + type: typeof EventType.FoldersUpdate; + state: FoldersState; +} diff --git a/packages/core/src/schemas/folders.ts b/packages/core/src/schemas/folders.ts new file mode 100644 index 0000000..eb8df98 --- /dev/null +++ b/packages/core/src/schemas/folders.ts @@ -0,0 +1,85 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +// Chat folders + pins. Response shape is always the full FoldersState snapshot. + +export const chatFolderSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'title', 'position', 'chatIds'], + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + position: { type: 'integer' }, + chatIds: { type: 'array', items: { type: 'string' } }, + }, +} as const; + +export const chatPinSchema = { + type: 'object', + additionalProperties: false, + required: ['conversationId', 'folderId', 'pinnedAt'], + properties: { + conversationId: { type: 'string' }, + folderId: { type: ['string', 'null'] }, + pinnedAt: { type: 'string' }, + }, +} as const; + +export const foldersStateSchema = { + type: 'object', + additionalProperties: false, + required: ['folders', 'pins'], + properties: { + folders: { type: 'array', items: chatFolderSchema }, + pins: { type: 'array', items: chatPinSchema }, + }, +} as const; + +export const createFolderBodySchema = { + type: 'object', + additionalProperties: false, + required: ['title'], + properties: { + title: { type: 'string', minLength: 1, maxLength: 64 }, + }, +} as const; + +export const updateFolderBodySchema = { + type: 'object', + additionalProperties: false, + minProperties: 1, + properties: { + title: { type: 'string', minLength: 1, maxLength: 64 }, + position: { type: 'integer', minimum: 0 }, + chatIds: { type: 'array', items: { type: 'string' }, maxItems: 200 }, + }, +} as const; + +// Idempotent pin toggle within a scope (folderId null = the "All chats" tab). +export const setPinBodySchema = { + type: 'object', + additionalProperties: false, + required: ['conversationId', 'folderId', 'pinned'], + properties: { + conversationId: { type: 'string' }, + folderId: { type: ['string', 'null'] }, + pinned: { type: 'boolean' }, + }, +} as const; + +// Shared-media listing for the profile panel, grouped Telegram-style. +export const mediaListQuerySchema = { + type: 'object', + additionalProperties: false, + required: ['tab'], + properties: { + tab: { type: 'string', enum: ['media', 'files', 'voice'] }, + before: { type: 'integer', minimum: 1 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + }, +} as const; + +export type CreateFolderBody = FromSchema; +export type UpdateFolderBody = FromSchema; +export type SetPinBody = FromSchema; +export type MediaTab = FromSchema['tab']; diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index e0c1a86..725902b 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -26,6 +26,16 @@ export { mediaUrlSchema, } from './media'; export type { MediaRef, MediaKind, UploadUrlBody, AvatarUploadBody, SetAvatarBody } from './media'; +export { + chatFolderSchema, + chatPinSchema, + foldersStateSchema, + createFolderBodySchema, + updateFolderBodySchema, + setPinBodySchema, + mediaListQuerySchema, +} from './folders'; +export type { CreateFolderBody, UpdateFolderBody, SetPinBody, MediaTab } from './folders'; export { registerDeviceBodySchema, updateSettingsBodySchema, diff --git a/packages/core/src/schemas/media.ts b/packages/core/src/schemas/media.ts index a0a9b1f..aaa5c37 100644 --- a/packages/core/src/schemas/media.ts +++ b/packages/core/src/schemas/media.ts @@ -1,6 +1,12 @@ import type { FromSchema } from 'json-schema-to-ts'; -const MEDIA_KIND = { type: 'string', enum: ['image', 'video', 'voice', 'file'] } as const; +// `video_note` is a circular "video message" (Telegram-style), distinct from a +// regular `video` file so clients render it as a round player. `voice` covers +// both recorded voice notes and audio files (both use the waveform player). +const MEDIA_KIND = { + type: 'string', + enum: ['image', 'video', 'video_note', 'voice', 'file'], +} as const; // Metadata for an attached media object (no object key — that stays server-side). export const mediaRefSchema = { diff --git a/packages/core/src/types/folders.ts b/packages/core/src/types/folders.ts new file mode 100644 index 0000000..6b24585 --- /dev/null +++ b/packages/core/src/types/folders.ts @@ -0,0 +1,24 @@ +// Manual chat folders + pins (Telegram-style tabs). All per-user, server-stored +// so they sync across devices; every mutation returns (and broadcasts) the full +// FoldersState snapshot — it is small and keeps clients trivially consistent. + +export interface ChatFolder { + id: string; + title: string; + position: number; + /** Conversations manually added to this folder. */ + chatIds: string[]; +} + +// A pin is scoped: folderId null = pinned in the "All chats" tab. Pinning in +// one folder deliberately does not pin the chat anywhere else. +export interface ChatPin { + conversationId: string; + folderId: string | null; + pinnedAt: string; +} + +export interface FoldersState { + folders: ChatFolder[]; + pins: ChatPin[]; +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 89e9e40..0fd2c8e 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -7,6 +7,7 @@ export type { Conversation, ConversationType, ConversationMember } from './conve export type { Contact } from './contact'; export type { Message, ReactionSummary, MediaRef } from './message'; export type { Presence } from './presence'; +export type { ChatFolder, ChatPin, FoldersState } from './folders'; export type { NotificationSettings, Device, diff --git a/packages/web/src/app/App.tsx b/packages/web/src/app/App.tsx index 20e9c3b..bf8cc45 100644 --- a/packages/web/src/app/App.tsx +++ b/packages/web/src/app/App.tsx @@ -1,32 +1,35 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { ReactElement, SyntheticEvent } from 'react'; import type { Conversation, Message, MediaRef, PublicUser, User } from '@altricade/core'; -import { heartbeat, getAvatarUploadUrl, uploadToUrl, setAvatar } from '@altricade/core/api'; +import { heartbeat } from '@altricade/core/api'; import { SessionProvider, useSession } from '../entities/session'; import { AuthForm } from '../features/auth'; import { RealtimeProvider } from '../features/realtime'; import { useConversations, ConversationSidebar } from '../features/conversations'; +import { useFolders } from '../features/folders'; import { ContactsPanel } from '../features/contacts'; import { ChatView } from '../features/messaging'; +import { ProfilePanel } from '../features/profile'; +import { SettingsPanel } from '../features/settings'; import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications'; import { apiConfig } from '../shared/api'; -import { useTheme } from '../shared/theme'; -import type { ThemePreference } from '../shared/theme'; import { - SunIcon, - MoonIcon, - MonitorIcon, PlusIcon, CloseIcon, - LogOutIcon, UsersIcon, + UserIcon, + ChatsIcon, + SettingsIcon, + ChevronLeftIcon, } from '../shared/ui'; -const PREFERENCES: readonly { value: ThemePreference; label: string; icon: ReactElement }[] = [ - { value: 'light', label: 'Light', icon: }, - { value: 'dark', label: 'Dark', icon: }, - { value: 'system', label: 'System', icon: }, -]; +type RailView = 'chats' | 'contacts' | 'compose' | 'settings'; + +const RAIL_TITLES: Record, string> = { + contacts: 'Contacts', + compose: 'New group', + settings: 'Settings', +}; const mediaLabel = (media: MediaRef | null): string => { if (media === null) { @@ -37,6 +40,8 @@ const mediaLabel = (media: MediaRef | null): string => { return 'Photo'; case 'video': return 'Video'; + case 'video_note': + return 'Video message'; case 'voice': return 'Voice message'; default: @@ -44,29 +49,6 @@ const mediaLabel = (media: MediaRef | null): string => { } }; -const ThemeSwitch = (): ReactElement => { - const { preference, setPreference } = useTheme(); - return ( -
- {PREFERENCES.map((option) => ( - - ))} -
- ); -}; - const GroupComposer = ({ onCreate, }: { @@ -133,11 +115,13 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re displayName: user.displayName, avatarUrl: user.avatarUrl, }; - const { updateUser } = useSession(); const { notify, setOpener } = useNotifications(); const [current, setCurrent] = useState(null); - const [composing, setComposing] = useState(false); + const [railView, setRailView] = useState('chats'); + const [activeFolderId, setActiveFolderId] = useState(null); + const [profileUser, setProfileUser] = useState(null); const convRef = useRef([]); + const folders = useFolders(user.id); // Toast (or OS notification when hidden) for messages arriving in other chats. const onIncoming = useCallback( @@ -154,18 +138,39 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re [notify], ); - const { conversations, onlineMap, startDirect, createGroupChat } = useConversations( - user.id, - current?.id ?? null, - onIncoming, - ); + const { conversations, onlineMap, startDirect, createGroupChat, clearChat, deleteChat } = + useConversations(user.id, current?.id ?? null, onIncoming); convRef.current = conversations; const openConversation = useCallback((conversation: Conversation): void => { setCurrent(conversation); - setComposing(false); + setRailView('chats'); + setProfileUser(null); }, []); + // The DM shared with the profiled user, when one exists (drives shared media). + const profileConversation = + profileUser === null + ? null + : (conversations.find( + (item) => item.type === 'direct' && item.peer !== null && item.peer.id === profileUser.id, + ) ?? null); + + // If the open chat disappears from the list (deleted for me, possibly on + // another device), close it. + useEffect(() => { + if (current !== null && !conversations.some((c) => c.id === current.id)) { + setCurrent(null); + } + }, [conversations, current]); + + // If the active folder was deleted (any device), fall back to All. + useEffect(() => { + if (activeFolderId !== null && !folders.folders.some((f) => f.id === activeFolderId)) { + setActiveFolderId(null); + } + }, [folders.folders, activeFolderId]); + // Let notification taps (in-app toast or SW message) open the right chat. useEffect(() => { setOpener((conversationId) => { @@ -190,21 +195,6 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re } }, [conversations]); - const onAvatar = (event: SyntheticEvent): void => { - const input = event.currentTarget; - const file = input.files?.[0]; - input.value = ''; - if (file === undefined) { - return; - } - 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 })); - })(); - }; - // Keep-alive heartbeat so our own last-seen stays fresh while connected. useEffect(() => { const interval = setInterval(() => { @@ -215,6 +205,8 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re }; }, []); + const totalUnread = conversations.reduce((sum, item) => sum + item.unreadCount, 0); + const startDirectAndOpen = async (username: string): Promise => { openConversation(await startDirect(username)); }; @@ -223,70 +215,174 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re openConversation(await createGroupChat(title, members)); }; + const shellClass = [ + 'app-shell', + current !== null ? 'has-chat' : '', + profileUser !== null ? 'has-profile' : '', + ] + .filter((token) => token !== '') + .join(' '); + return ( -
+
{current !== null ? ( - + { + if (target.id !== user.id) { + setProfileUser(target); + } + }} + onBack={() => { + setCurrent(null); + }} + /> ) : (
@@ -298,6 +394,20 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
)} + + {profileUser !== null ? ( + { + setProfileUser(null); + }} + onMessage={(username) => { + void startDirectAndOpen(username); + }} + /> + ) : null}
); }; diff --git a/packages/web/src/app/index.css b/packages/web/src/app/index.css index e5df004..a68e946 100644 --- a/packages/web/src/app/index.css +++ b/packages/web/src/app/index.css @@ -1783,3 +1783,899 @@ a { transform: translateY(0); } } + +/* ============================================================================= + Context menus + dialogs (Telegram-style) + ========================================================================== */ + +.ctx-backdrop { + position: fixed; + inset: 0; + z-index: 1200; +} + +.ctx-menu { + position: fixed; + min-width: 200px; + max-width: 280px; + padding: 5px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-surfacePanel) 92%, transparent); + backdrop-filter: blur(18px); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); + animation: ctx-in 0.12s var(--ease-out); +} + +@keyframes ctx-in { + from { + opacity: 0; + transform: scale(0.94); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.ctx-item { + display: flex; + align-items: center; + gap: 0.7rem; + width: 100%; + padding: 0.5rem 0.7rem; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--color-text); + font-size: var(--text-base); + text-align: left; + transition: background var(--dur-fast) var(--ease); +} + +.ctx-item:hover { + background: var(--color-surfaceHover); +} + +.ctx-item-icon { + display: inline-flex; + color: var(--color-textMuted); +} + +.ctx-item-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ctx-danger { + color: var(--color-danger); +} + +.ctx-danger .ctx-item-icon { + color: var(--color-danger); +} + +.ctx-danger:hover { + background: color-mix(in srgb, var(--color-danger) 10%, transparent); +} + +.ctx-sep { + height: 1px; + margin: 4px 6px; + background: var(--color-border); +} + +/* Quick-reactions row on top of the message menu */ +.ctx-reactions { + display: flex; + gap: 2px; + padding: 2px; +} + +.ctx-react { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: var(--radius-full); + background: none; + font-size: 1.15rem; + line-height: 1; + transition: transform var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease); +} + +.ctx-react:hover { + background: var(--color-surfaceHover); + transform: scale(1.22); +} + +/* Confirm dialog */ +.dialog-backdrop { + position: fixed; + inset: 0; + z-index: 1300; + display: grid; + place-items: center; + background: rgba(8, 9, 13, 0.5); + backdrop-filter: blur(4px); + animation: viewer-in var(--dur) var(--ease); +} + +.dialog { + width: min(340px, calc(100vw - 2.5rem)); + padding: 1.25rem 1.25rem 0.75rem; + border-radius: var(--radius-lg); + background: var(--color-surfacePanel); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); +} + +.dialog-title { + font-size: var(--text-md); + font-weight: 700; +} + +.dialog-body { + margin-top: 0.4rem; + font-size: var(--text-sm); +} + +.dialog-actions { + display: flex; + flex-direction: column; + align-items: stretch; + margin-top: 0.9rem; +} + +.dialog-btn { + padding: 0.6rem 0.5rem; + border: none; + border-top: 1px solid var(--color-border); + background: none; + color: var(--color-accent); + font-weight: 600; + text-align: center; + transition: background var(--dur-fast) var(--ease); +} + +.dialog-btn:hover { + background: var(--color-surfaceHover); +} + +.dialog-danger { + color: var(--color-danger); +} + +.dialog-cancel { + color: var(--color-textMuted); +} + +/* ============================================================================= + Composer edit banner (Telegram-style) + ========================================================================== */ + +.composer-area { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + background: var(--color-surfacePanel); + border-top: 1px solid var(--color-border); +} + +.composer-area .composer { + border-top: none; +} + +.edit-banner { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.45rem clamp(0.6rem, 3vw, 1.5rem) 0; +} + +.edit-banner-icon { + flex: 0 0 auto; + color: var(--color-accent); +} + +.edit-banner-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding-left: 0.7rem; + border-left: 2px solid var(--color-accent); + line-height: 1.3; +} + +.edit-banner-title { + font-size: var(--text-xs); + font-weight: 700; + color: var(--color-accent); +} + +.edit-banner-preview { + font-size: var(--text-sm); + color: var(--color-textMuted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.edit-banner .icon-btn { + width: 34px; + height: 34px; +} + +/* Chat header is now clickable (opens profile) + optional back button */ +.chat-header-main { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; + padding: 0.25rem 0.4rem; + margin-left: -0.4rem; + border: none; + border-radius: var(--radius-md); + background: none; + text-align: left; + transition: background var(--dur-fast) var(--ease); +} + +.chat-header-main:not(:disabled):hover { + background: var(--color-surfaceHover); + cursor: pointer; +} + +.chat-back { + display: none; +} + +.msg-avatar-btn { + padding: 0; + border: none; + background: none; + line-height: 0; + cursor: pointer; + align-self: flex-end; +} + +/* ============================================================================= + Folder tabs (rail) + ========================================================================== */ + +.folder-tabs { + display: flex; + align-items: center; + gap: 2px; + padding: 0 0.85rem 0.4rem; + overflow-x: auto; + scrollbar-width: none; + flex: 0 0 auto; +} + +.folder-tabs::-webkit-scrollbar { + display: none; +} + +.folder-tab { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + border: none; + border-radius: var(--radius-full); + background: none; + color: var(--color-textMuted); + font-size: var(--text-sm); + font-weight: 600; + white-space: nowrap; + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} + +.folder-tab:hover { + background: var(--color-surfaceHover); + color: var(--color-text); +} + +.folder-tab[aria-selected='true'] { + background: var(--color-accentSoft); + color: var(--color-accent); +} + +.folder-add { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; +} + +.conv-badges { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.pin-mark { + color: var(--color-textFaint); +} + +.dialog-form { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.75rem; +} + +.dialog-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================================================= + Profile panel (Telegram-style right sidebar) + ========================================================================== */ + +.app-shell.has-profile { + grid-template-columns: var(--rail-width) 1fr 360px; +} + +.profile-panel { + display: flex; + flex-direction: column; + min-height: 0; + background: var(--color-surfacePanel); + border-left: 1px solid var(--color-border); +} + +.profile-header { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--header-height); + padding: 0 0.6rem 0 1.1rem; + flex: 0 0 auto; + border-bottom: 1px solid var(--color-border); +} + +.profile-header-title { + font-weight: 700; + font-size: var(--text-md); +} + +.profile-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.profile-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + padding: 1.5rem 1rem 1.25rem; + text-align: center; +} + +.profile-avatar { + width: 110px; + height: 110px; + border-radius: var(--radius-full); + object-fit: cover; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 2.4rem; + font-weight: 700; + margin-bottom: 0.4rem; +} + +.profile-name { + font-size: var(--text-lg); + font-weight: 700; +} + +.profile-presence { + font-size: var(--text-sm); + color: var(--color-textMuted); +} + +.profile-presence.is-online { + color: var(--color-online); +} + +.profile-message-btn { + margin-top: 0.7rem; +} + +.profile-info { + margin: 0 1rem; + padding: 0.35rem 0; + border-radius: var(--radius-md); + background: var(--color-background); + border: 1px solid var(--color-border); +} + +.profile-info-row { + display: flex; + flex-direction: column; + padding: 0.5rem 0.9rem; +} + +.profile-info-value { + font-size: var(--text-base); +} + +.profile-info-label { + font-size: var(--text-xs); + color: var(--color-textFaint); +} + +/* Shared media */ +.profile-shared { + margin-top: 1rem; + padding: 0 1rem 1.25rem; +} + +.profile-tabs { + display: flex; + gap: 2px; + margin-bottom: 0.6rem; + border-bottom: 1px solid var(--color-border); +} + +.profile-tab { + padding: 0.45rem 0.8rem; + border: none; + border-bottom: 2px solid transparent; + background: none; + color: var(--color-textMuted); + font-size: var(--text-sm); + font-weight: 600; + transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease); +} + +.profile-tab:hover { + color: var(--color-text); +} + +.profile-tab[aria-selected='true'] { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +.media-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3px; +} + +.media-cell { + position: relative; + aspect-ratio: 1; + padding: 0; + border: none; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--color-surface); + cursor: pointer; +} + +.media-cell img, +.media-cell video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.media-cell-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.5); + color: #fff; +} + +.media-cell-empty { + display: block; + aspect-ratio: 1; + border-radius: var(--radius-sm); + background: var(--color-surface); +} + +.profile-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.profile-file { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.5rem 0.6rem; + border-radius: var(--radius-md); + background: var(--color-background); + border: 1px solid var(--color-border); + color: var(--color-text); + text-decoration: none; +} + +.profile-file:hover { + background: var(--color-surfaceHover); +} + +.profile-voice { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-md); + background: var(--color-background); + border: 1px solid var(--color-border); +} + +.profile-voice .voice { + flex: 1; + min-width: 0; +} + +.profile-voice-date { + flex: 0 0 auto; + font-size: var(--text-xs); +} + +.profile-more { + display: block; + width: 100%; + margin-top: 0.75rem; + padding: 0.5rem; + border: none; + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-accent); + font-weight: 600; + transition: background var(--dur-fast) var(--ease); +} + +.profile-more:hover { + background: var(--color-surfaceHover); +} + +/* ============================================================================= + Responsive — single-pane navigation under 900px (list ↔ chat ↔ profile) + ========================================================================== */ + +@media (max-width: 900px) { + .app-shell, + .app-shell.has-profile { + grid-template-columns: 1fr; + } + + /* One pane at a time: the rail by default, the chat once one is open. */ + .rail { + border-right: none; + } + + .app-shell.has-chat .rail { + display: none; + } + + .app-shell:not(.has-chat) .chat-pane { + display: none; + } + + .chat-back { + display: inline-flex; + } + + /* Profile becomes a full-screen layer. */ + .profile-panel { + position: fixed; + inset: 0; + z-index: 900; + border-left: none; + } + + /* Roomier touch targets, tighter chrome. */ + .message-scroll { + padding: 0.75rem 0.75rem; + } + + .bubble-wrap { + max-width: 86%; + } + + .media-img { + max-width: min(280px, 74vw); + } + + .media-video { + max-width: min(300px, 78vw); + } + + .round-video { + width: 176px; + height: 176px; + } + + .voice { + min-width: 200px; + } + + .rail-header { + padding: 0 0.6rem; + } + + .composer { + padding: 0.5rem 0.6rem; + } + + /* Viewer toolbar clear of notches. */ + .viewer-toolbar { + padding-top: max(0.9rem, env(safe-area-inset-top)); + } +} + +@media (max-width: 480px) { + .auth-card { + padding: 1.5rem 1.25rem; + border: none; + box-shadow: none; + background: transparent; + } + + .auth { + background: var(--color-background); + } +} + +/* ============================================================================= + Jump-to-newest button + ========================================================================== */ + +.jump-latest { + position: absolute; + right: 1rem; + bottom: 84px; + z-index: 5; + display: grid; + place-items: center; + width: 44px; + height: 44px; + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + background: var(--color-surfacePanel); + color: var(--color-textMuted); + box-shadow: var(--shadow-md); + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease), + transform var(--dur-fast) var(--ease-out); + animation: ctx-in 0.15s var(--ease-out); +} + +.jump-latest:hover { + background: var(--color-surfaceHover); + color: var(--color-text); + transform: translateY(-2px); +} + +/* ============================================================================= + Settings page + ========================================================================== */ + +.settings { + display: flex; + flex-direction: column; + padding: 0.5rem 1rem 1.25rem; +} + +.settings-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 1.25rem 0 1rem; + text-align: center; +} + +.settings-avatar-wrap { + position: relative; + cursor: pointer; + margin-bottom: 0.5rem; +} + +.settings-avatar { + width: 96px; + height: 96px; + border-radius: var(--radius-full); + object-fit: cover; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 700; +} + +.settings-avatar-edit { + position: absolute; + right: 2px; + bottom: 2px; + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: var(--radius-full); + background: var(--color-accent); + color: var(--color-onAccent); + border: 2px solid var(--color-surfacePanel); +} + +.settings-name { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: none; + background: none; + font-size: var(--text-lg); + font-weight: 700; + color: var(--color-text); + padding: 0.1rem 0.4rem; + border-radius: var(--radius-sm); +} + +.settings-name:hover { + background: var(--color-surfaceHover); +} + +.settings-name-edit { + color: var(--color-textFaint); +} + +.settings-name-form { + display: flex; + align-items: center; + gap: 0.4rem; + width: min(260px, 100%); +} + +.settings-name-input { + text-align: center; + font-weight: 700; +} + +.settings-handle { + font-size: var(--text-sm); +} + +.settings-section { + padding: 0.5rem 0; + border-top: 1px solid var(--color-border); + margin-top: 0.75rem; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.4rem 0.2rem; +} + +.settings-row-label { + font-size: var(--text-base); +} + +.settings-logout { + display: flex; + align-items: center; + gap: 0.7rem; + width: 100%; + padding: 0.6rem 0.5rem; + border: none; + border-radius: var(--radius-md); + background: none; + color: var(--color-danger); + font-weight: 600; + text-align: left; + transition: background var(--dur-fast) var(--ease); +} + +.settings-logout:hover { + background: color-mix(in srgb, var(--color-danger) 10%, transparent); +} + +/* ============================================================================= + Mobile bottom navigation (Telegram-style) + profile back button + ========================================================================== */ + +.rail-nav { + display: none; +} + +.profile-back { + display: none; +} + +@media (max-width: 900px) { + .rail-nav { + display: flex; + align-items: stretch; + flex: 0 0 auto; + border-top: 1px solid var(--color-border); + background: var(--color-surfacePanel); + padding-bottom: env(safe-area-inset-bottom); + } + + .rail-nav-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0.65rem 0; + border: none; + background: none; + color: var(--color-textFaint); + transition: color var(--dur-fast) var(--ease); + } + + .rail-nav-btn[aria-pressed='true'] { + color: var(--color-accent); + } + + .rail-nav-icon { + position: relative; + display: inline-flex; + } + + .rail-nav-badge { + position: absolute; + top: -6px; + right: -12px; + } + + /* Header shortcuts are redundant next to the bottom nav. */ + .rail-desktop-action { + display: none; + } + + /* Profile page: back arrow left (Telegram-style), no X. */ + .profile-back { + display: inline-flex; + } + + .profile-close { + display: none; + } + + .profile-header { + gap: 0.4rem; + padding: 0 1.1rem 0 0.5rem; + } + + .profile-header-title { + flex: 1; + } + + .jump-latest { + bottom: 76px; + right: 0.75rem; + } +} diff --git a/packages/web/src/features/conversations/model.ts b/packages/web/src/features/conversations/model.ts index 747363d..bd48505 100644 --- a/packages/web/src/features/conversations/model.ts +++ b/packages/web/src/features/conversations/model.ts @@ -1,7 +1,13 @@ 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 { + listConversations, + createDirect, + createGroup, + clearConversation, + hideConversation, +} from '@altricade/core/api'; import { apiConfig } from '../../shared/api'; import { useRealtime } from '../realtime'; @@ -11,6 +17,10 @@ export interface UseConversations { onlineMap: Record; startDirect: (username: string) => Promise; createGroupChat: (title: string, members: string[]) => Promise; + /** Clear history for me — the chat stays listed with an empty scrollback. */ + clearChat: (conversationId: string) => Promise; + /** Delete chat for me — removed from the list until new activity arrives. */ + deleteChat: (conversationId: string) => Promise; } const hasType = (data: unknown): data is { type: string } => @@ -24,6 +34,16 @@ const isConversationNew = ( const isMessageNew = (data: unknown): data is { type: 'message.new'; message: Message } => hasType(data) && data.type === EventType.MessageNew && 'message' in data; +const isConversationHidden = ( + data: unknown, +): data is { type: 'conversation.hidden'; conversationId: string } => + hasType(data) && data.type === EventType.ConversationHidden && 'conversationId' in data; + +const isConversationCleared = ( + data: unknown, +): data is { type: 'conversation.cleared'; conversationId: string } => + hasType(data) && data.type === EventType.ConversationCleared && 'conversationId' in data; + const upsert = (list: Conversation[], conversation: Conversation): Conversation[] => { const rest = list.filter((item) => item.id !== conversation.id); return [conversation, ...rest]; @@ -113,6 +133,15 @@ export const useConversations = ( setConversations((prev) => upsert(prev, conversation)); } else if (isMessageNew(event.data)) { bumpUnread(event.data.message); + } else if (isConversationHidden(event.data)) { + // "Delete chat" done here or on another of my devices. + const { conversationId } = event.data; + setConversations((prev) => prev.filter((c) => c.id !== conversationId)); + } else if (isConversationCleared(event.data)) { + const { conversationId } = event.data; + setConversations((prev) => + prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c)), + ); } }); }, [subscribe, userId, bumpUnread]); @@ -183,5 +212,18 @@ export const useConversations = ( [], ); - return { conversations, loading, onlineMap, startDirect, createGroupChat }; + const clearChat = useCallback(async (conversationId: string): Promise => { + await clearConversation(apiConfig, conversationId); + setConversations((prev) => + prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c)), + ); + }, []); + + const deleteChat = useCallback(async (conversationId: string): Promise => { + // Optimistic removal; the personal-channel event covers other devices. + setConversations((prev) => prev.filter((c) => c.id !== conversationId)); + await hideConversation(apiConfig, conversationId); + }, []); + + return { conversations, loading, onlineMap, startDirect, createGroupChat, clearChat, deleteChat }; }; diff --git a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx index 2cf88d5..effa64d 100644 --- a/packages/web/src/features/conversations/ui/ConversationSidebar.tsx +++ b/packages/web/src/features/conversations/ui/ConversationSidebar.tsx @@ -1,18 +1,48 @@ import { useState } from 'react'; -import type { ReactElement } from 'react'; -import type { Conversation, PublicUser } from '@altricade/core'; +import type { ReactElement, SyntheticEvent } from 'react'; +import type { ChatFolder, Conversation, PublicUser } from '@altricade/core'; import { searchUsers } from '@altricade/core/api'; import { apiConfig } from '../../../shared/api'; -import { SearchIcon, UsersIcon, CloseIcon } from '../../../shared/ui'; +import { + SearchIcon, + UsersIcon, + CloseIcon, + PlusIcon, + PinIcon, + PinOffIcon, + FolderIcon, + CheckIcon, + EraserIcon, + TrashIcon, + EditIcon, + ContextMenu, + MenuItem, + MenuSeparator, + useContextMenu, + ConfirmDialog, +} from '../../../shared/ui'; +import type { UseFolders } from '../../folders'; interface Props { conversations: Conversation[]; currentId: string | null; onlineMap: Record; + folders: UseFolders; + activeFolderId: string | null; + onSelectFolder: (folderId: string | null) => void; onSelect: (conversation: Conversation) => void; onStartDirect: (username: string) => Promise; + onClearChat: (conversationId: string) => Promise; + onDeleteChat: (conversationId: string) => Promise; } +type SidebarDialog = + | { kind: 'create-folder' } + | { kind: 'rename-folder'; folder: ChatFolder } + | { kind: 'delete-folder'; folder: ChatFolder } + | { kind: 'clear-chat'; conversation: Conversation } + | { kind: 'delete-chat'; conversation: Conversation }; + const title = (conversation: Conversation): string => { if (conversation.type === 'group') { return conversation.title ?? 'Group'; @@ -62,15 +92,87 @@ const Avatar = ({ conversation }: { conversation: Conversation }): ReactElement ); }; +// Small centered dialog with a single text input (folder create/rename). +const NameDialog = ({ + heading, + initialValue, + submitLabel, + onSubmit, + onClose, +}: { + heading: string; + initialValue: string; + submitLabel: string; + onSubmit: (value: string) => void; + onClose: () => void; +}): ReactElement => { + const [value, setValue] = useState(initialValue); + const submit = (event: SyntheticEvent): void => { + event.preventDefault(); + const trimmed = value.trim(); + if (trimmed !== '') { + onSubmit(trimmed); + } + }; + return ( +
+
{ + event.stopPropagation(); + }} + > +

{heading}

+
+ { + setValue(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + onClose(); + } + }} + /> +
+ + +
+
+
+
+ ); +}; + export const ConversationSidebar = ({ conversations, currentId, onlineMap, + folders, + activeFolderId, + onSelectFolder, onSelect, onStartDirect, + onClearChat, + onDeleteChat, }: Props): ReactElement => { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); + const [dialog, setDialog] = useState(null); + const chatMenu = useContextMenu(); + const folderMenu = useContextMenu(); const runSearch = async (value: string): Promise => { setQuery(value); @@ -92,6 +194,27 @@ export const ConversationSidebar = ({ }; const searching = query.trim().length > 0; + const activeFolder = + activeFolderId === null + ? null + : (folders.folders.find((folder) => folder.id === activeFolderId) ?? null); + + // Folder scope filter, then pinned-first (per-scope) ordering. + const scoped = + activeFolder === null + ? conversations + : conversations.filter((conversation) => activeFolder.chatIds.includes(conversation.id)); + const sorted = [...scoped].sort((a, b) => { + const pinA = folders.pinnedAt(a.id, activeFolderId); + const pinB = folders.pinnedAt(b.id, activeFolderId); + if (pinA !== null && pinB !== null) { + return pinB.localeCompare(pinA); + } + if (pinA !== null || pinB !== null) { + return pinA !== null ? -1 : 1; + } + return b.lastMessageAt.localeCompare(a.lastMessageAt); + }); return ( <> @@ -120,6 +243,50 @@ export const ConversationSidebar = ({ ) : null}
+ {!searching ? ( +
+ + {folders.folders.map((folder) => ( + + ))} + +
+ ) : null} +
{searching ? (
@@ -157,13 +324,16 @@ export const ConversationSidebar = ({
) : (
- {conversations.length === 0 ? ( -

No conversations yet

+ {sorted.length === 0 ? ( +

+ {activeFolder === null ? 'No conversations yet' : 'This folder is empty'} +

) : ( - conversations.map((conversation) => { + sorted.map((conversation) => { const online = conversation.peer !== null && onlineMap[conversation.peer.id] === true; const active = conversation.id === currentId; + const pinned = folders.isPinned(conversation.id, activeFolderId); return ( @@ -197,6 +373,206 @@ export const ConversationSidebar = ({
)}
+ + {chatMenu.state !== null ? ( + + + ) : ( + + ) + } + label={ + folders.isPinned(chatMenu.state.payload.id, activeFolderId) + ? activeFolder === null + ? 'Unpin' + : `Unpin in ${activeFolder.title}` + : activeFolder === null + ? 'Pin' + : `Pin in ${activeFolder.title}` + } + onClick={() => { + const target = chatMenu.state?.payload; + chatMenu.close(); + if (target !== undefined) { + void folders.togglePin(target.id, activeFolderId); + } + }} + /> + {folders.folders.length > 0 ? : null} + {folders.folders.map((folder) => { + const target = chatMenu.state?.payload; + const member = target !== undefined && folder.chatIds.includes(target.id); + return ( + : } + label={member ? `Remove from ${folder.title}` : `Add to ${folder.title}`} + onClick={() => { + chatMenu.close(); + if (target !== undefined) { + void folders.toggleChat(folder.id, target.id); + } + }} + /> + ); + })} + + } + label="Clear history" + onClick={() => { + const target = chatMenu.state?.payload; + chatMenu.close(); + if (target !== undefined) { + setDialog({ kind: 'clear-chat', conversation: target }); + } + }} + /> + } + label="Delete chat" + danger + onClick={() => { + const target = chatMenu.state?.payload; + chatMenu.close(); + if (target !== undefined) { + setDialog({ kind: 'delete-chat', conversation: target }); + } + }} + /> + + ) : null} + + {folderMenu.state !== null ? ( + + } + label="Rename folder" + onClick={() => { + const target = folderMenu.state?.payload; + folderMenu.close(); + if (target !== undefined) { + setDialog({ kind: 'rename-folder', folder: target }); + } + }} + /> + } + label="Delete folder" + danger + onClick={() => { + const target = folderMenu.state?.payload; + folderMenu.close(); + if (target !== undefined) { + setDialog({ kind: 'delete-folder', folder: target }); + } + }} + /> + + ) : null} + + {dialog?.kind === 'create-folder' ? ( + { + setDialog(null); + void folders.create(value); + }} + onClose={() => { + setDialog(null); + }} + /> + ) : null} + + {dialog?.kind === 'rename-folder' ? ( + { + const folderId = dialog.folder.id; + setDialog(null); + void folders.rename(folderId, value); + }} + onClose={() => { + setDialog(null); + }} + /> + ) : null} + + {dialog?.kind === 'delete-folder' ? ( + Chats in it are not deleted.

} + actions={[ + { + key: 'delete', + label: 'Delete folder', + danger: true, + onSelect: () => { + const folderId = dialog.folder.id; + setDialog(null); + if (activeFolderId === folderId) { + onSelectFolder(null); + } + void folders.removeFolder(folderId); + }, + }, + ]} + onClose={() => { + setDialog(null); + }} + /> + ) : null} + + {dialog?.kind === 'clear-chat' ? ( + Messages are removed for you only.

} + actions={[ + { + key: 'clear', + label: 'Clear history', + danger: true, + onSelect: () => { + const conversationId = dialog.conversation.id; + setDialog(null); + void onClearChat(conversationId); + }, + }, + ]} + onClose={() => { + setDialog(null); + }} + /> + ) : null} + + {dialog?.kind === 'delete-chat' ? ( + The chat is removed for you only and returns on new activity.

} + actions={[ + { + key: 'delete', + label: 'Delete chat', + danger: true, + onSelect: () => { + const conversationId = dialog.conversation.id; + setDialog(null); + void onDeleteChat(conversationId); + }, + }, + ]} + onClose={() => { + setDialog(null); + }} + /> + ) : null} ); }; diff --git a/packages/web/src/features/folders/index.ts b/packages/web/src/features/folders/index.ts new file mode 100644 index 0000000..2a292e0 --- /dev/null +++ b/packages/web/src/features/folders/index.ts @@ -0,0 +1,2 @@ +export { useFolders } from './model'; +export type { UseFolders } from './model'; diff --git a/packages/web/src/features/folders/model.ts b/packages/web/src/features/folders/model.ts new file mode 100644 index 0000000..efecfab --- /dev/null +++ b/packages/web/src/features/folders/model.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { FoldersState } from '@altricade/core'; +import { userChannel, EventType } from '@altricade/core'; +import { + getFolders, + createFolder, + updateFolder, + deleteFolder, + setPin, +} from '@altricade/core/api'; +import { apiConfig } from '../../shared/api'; +import { useRealtime } from '../realtime'; + +export interface UseFolders { + folders: FoldersState['folders']; + pins: FoldersState['pins']; + create: (title: string) => Promise; + rename: (folderId: string, title: string) => Promise; + removeFolder: (folderId: string) => Promise; + /** Add/remove a chat from a folder (toggle). */ + toggleChat: (folderId: string, conversationId: string) => Promise; + /** Pin/unpin a chat within a scope (folderId null = "All chats"). */ + togglePin: (conversationId: string, folderId: string | null) => Promise; + isPinned: (conversationId: string, folderId: string | null) => boolean; + /** Pin timestamp for ordering, or null when not pinned in the scope. */ + pinnedAt: (conversationId: string, folderId: string | null) => string | null; +} + +const EMPTY: FoldersState = { folders: [], pins: [] }; + +const hasType = (data: unknown): data is { type: string } => + typeof data === 'object' && data !== null && 'type' in data && typeof data.type === 'string'; + +const isFoldersUpdate = (data: unknown): data is { type: 'folders.update'; state: FoldersState } => + hasType(data) && data.type === EventType.FoldersUpdate && 'state' in data; + +// Server-stored folders + pins, kept in sync across devices via the personal +// channel (every mutation returns and broadcasts a full snapshot). +export const useFolders = (userId: string): UseFolders => { + const { subscribe } = useRealtime(); + const [state, setState] = useState(EMPTY); + + useEffect(() => { + let cancelled = false; + void getFolders(apiConfig).then((snapshot) => { + if (!cancelled) { + setState(snapshot); + } + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + return subscribe(userChannel(userId), (event) => { + if (isFoldersUpdate(event.data)) { + setState(event.data.state); + } + }); + }, [subscribe, userId]); + + const create = useCallback(async (title: string): Promise => { + setState(await createFolder(apiConfig, { title })); + }, []); + + const rename = useCallback(async (folderId: string, title: string): Promise => { + setState(await updateFolder(apiConfig, folderId, { title })); + }, []); + + const removeFolder = useCallback(async (folderId: string): Promise => { + setState(await deleteFolder(apiConfig, folderId)); + }, []); + + const toggleChat = useCallback( + async (folderId: string, conversationId: string): Promise => { + const folder = state.folders.find((item) => item.id === folderId); + if (folder === undefined) { + return; + } + const chatIds = folder.chatIds.includes(conversationId) + ? folder.chatIds.filter((id) => id !== conversationId) + : [...folder.chatIds, conversationId]; + setState(await updateFolder(apiConfig, folderId, { chatIds })); + }, + [state.folders], + ); + + const isPinned = useCallback( + (conversationId: string, folderId: string | null): boolean => + state.pins.some((pin) => pin.conversationId === conversationId && pin.folderId === folderId), + [state.pins], + ); + + const pinnedAt = useCallback( + (conversationId: string, folderId: string | null): string | null => + state.pins.find((pin) => pin.conversationId === conversationId && pin.folderId === folderId) + ?.pinnedAt ?? null, + [state.pins], + ); + + const togglePin = useCallback( + async (conversationId: string, folderId: string | null): Promise => { + const pinned = !isPinned(conversationId, folderId); + setState(await setPin(apiConfig, { conversationId, folderId, pinned })); + }, + [isPinned], + ); + + return { + folders: state.folders, + pins: state.pins, + create, + rename, + removeFolder, + toggleChat, + togglePin, + isPinned, + pinnedAt, + }; +}; diff --git a/packages/web/src/features/messaging/index.ts b/packages/web/src/features/messaging/index.ts index 3ab0c60..ce8c1a0 100644 --- a/packages/web/src/features/messaging/index.ts +++ b/packages/web/src/features/messaging/index.ts @@ -1,3 +1,5 @@ export { useConversationMessages } from './model'; export type { UseConversationMessages } from './model'; export { ChatView } from './ui/ChatView'; +export { VoiceMessage } from './ui/VoiceMessage'; +export { MediaViewerProvider, useMediaViewer } from './ui/MediaViewer'; diff --git a/packages/web/src/features/messaging/model.ts b/packages/web/src/features/messaging/model.ts index 4299c9a..3502538 100644 --- a/packages/web/src/features/messaging/model.ts +++ b/packages/web/src/features/messaging/model.ts @@ -5,6 +5,8 @@ import type { MessageNewEvent, MessageEditEvent, MessageDeleteEvent, + MessageHiddenEvent, + ConversationClearedEvent, ReactionEvent, ReadReceiptEvent, TypingEvent, @@ -26,6 +28,7 @@ import { sendMessage, editMessage as apiEdit, deleteMessage as apiDelete, + hideMessage as apiHide, addReaction, removeReaction, markRead, @@ -43,9 +46,12 @@ export interface UseConversationMessages { /** For a direct conversation: the peer's last-read seq (drives ✓✓). */ peerReadSeq: number; send: (content: string) => Promise; - sendMedia: (file: File, caption: string) => Promise; + sendMedia: (file: File, caption: string, kindOverride?: MediaKind) => Promise; edit: (messageId: string, content: string) => Promise; + /** Delete for everyone (sender, or group owner as moderation). */ remove: (messageId: string) => Promise; + /** Delete for me only. */ + hide: (messageId: string) => Promise; toggleReaction: (message: Message, emoji: string) => Promise; notifyTyping: () => void; } @@ -67,6 +73,10 @@ const isDeleteEvent = (d: unknown): d is MessageDeleteEvent => const isReactionEvent = (d: unknown): d is ReactionEvent => hasType(d) && (d.type === EventType.ReactionAdd || d.type === EventType.ReactionRemove); const isReadEvent = (d: unknown): d is ReadReceiptEvent => hasType(d) && d.type === EventType.ReadReceipt; +const isHiddenEvent = (d: unknown): d is MessageHiddenEvent => + hasType(d) && d.type === EventType.MessageHidden; +const isClearedEvent = (d: unknown): d is ConversationClearedEvent => + hasType(d) && d.type === EventType.ConversationCleared; const isTypingEvent = (d: unknown): d is TypingEvent => hasType(d) && (d.type === EventType.TypingStart || d.type === EventType.TypingStop); @@ -104,8 +114,10 @@ export const useConversationMessages = ( const lastTypingSent = useRef(0); const conversationId = conversation.id; - const channel = - conversation.type === 'group' ? conversationChannel(conversationId) : userChannel(me.id); + // Groups deliver on the conversation channel; DMs on the personal channel. + // Per-user view-state events (hide/clear) always arrive on the personal one, + // so that subscription is unconditional. + const groupChannel = conversation.type === 'group' ? conversationChannel(conversationId) : null; useEffect(() => { let cancelled = false; @@ -161,6 +173,22 @@ export const useConversationMessages = ( } return; } + if (isHiddenEvent(data)) { + // "Delete for me" done on another of my devices. + if (data.conversationId === conversationId) { + const { messageId } = data; + setMessages((prev) => prev.filter((m) => m.id !== messageId)); + } + return; + } + if (isClearedEvent(data)) { + if (data.conversationId === conversationId) { + const { upToSeq } = data; + // Optimistic sends carry OPTIMISTIC_SEQ (MAX_SAFE_INTEGER) — they survive. + setMessages((prev) => prev.filter((m) => m.seq > upToSeq)); + } + return; + } if (isTypingEvent(data)) { if (data.conversationId !== conversationId || data.userId === me.id) { return; @@ -184,8 +212,11 @@ export const useConversationMessages = ( } }; - // Durable events arrive on the message channel; typing on the ephemeral channel. - const unsubMessages = subscribe(channel, handler); + // Durable events arrive on the personal channel (DMs + per-user view state) + // and, for groups, additionally on the conversation channel; typing on the + // ephemeral channel. + const unsubUser = subscribe(userChannel(me.id), handler); + const unsubGroup = groupChannel !== null ? subscribe(groupChannel, handler) : null; const unsubEphemeral = subscribe(ephemeralChannel(conversationId), handler); const load = async (): Promise => { @@ -204,14 +235,15 @@ export const useConversationMessages = ( return () => { cancelled = true; - unsubMessages(); + unsubUser(); + unsubGroup?.(); unsubEphemeral(); for (const timer of timers.values()) { clearTimeout(timer); } timers.clear(); }; - }, [conversationId, channel, subscribe, me.id]); + }, [conversationId, groupChannel, subscribe, me.id]); // Mark the conversation read up to the newest confirmed message. useEffect(() => { @@ -250,9 +282,9 @@ export const useConversationMessages = ( ); const sendMedia = useCallback( - async (file: File, caption: string): Promise => { + async (file: File, caption: string, kindOverride?: MediaKind): Promise => { const mime = file.type.length > 0 ? file.type : 'application/octet-stream'; - const kind = mimeToKind(mime); + const kind = kindOverride ?? 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 }; @@ -283,6 +315,15 @@ export const useConversationMessages = ( [conversationId], ); + const hide = useCallback( + async (messageId: string): Promise => { + // Optimistic local removal; the personal-channel event covers other devices. + setMessages((prev) => prev.filter((m) => m.id !== messageId)); + await apiHide(apiConfig, conversationId, messageId); + }, + [conversationId], + ); + const toggleReaction = useCallback( async (message: Message, emoji: string): Promise => { const mine = message.reactions.some((r) => r.emoji === emoji && r.mine); @@ -314,6 +355,7 @@ export const useConversationMessages = ( sendMedia, edit, remove, + hide, toggleReaction, notifyTyping, }; diff --git a/packages/web/src/features/messaging/recorder.ts b/packages/web/src/features/messaging/recorder.ts index b84c39d..bd5dcf3 100644 --- a/packages/web/src/features/messaging/recorder.ts +++ b/packages/web/src/features/messaging/recorder.ts @@ -16,8 +16,9 @@ export interface Recorder { } // In-app voice/video message capture via MediaRecorder. Produces a webm File and -// hands it to `onComplete`, which uploads + sends it as a media message. -export const useRecorder = (onComplete: (file: File) => void): Recorder => { +// hands it to `onComplete` with the record kind, which uploads + sends it as a +// media message (video → circular note, voice → waveform). +export const useRecorder = (onComplete: (file: File, kind: RecordKind) => void): Recorder => { const [recording, setRecording] = useState(null); const [elapsedMs, setElapsedMs] = useState(0); const [previewStream, setPreviewStream] = useState(null); @@ -75,7 +76,7 @@ export const useRecorder = (onComplete: (file: File) => void): Recorder => { const isVideo = kindRef.current === 'video'; const type = isVideo ? 'video/webm' : 'audio/webm'; const name = isVideo ? 'video-message.webm' : 'voice-message.webm'; - onCompleteRef.current(new File(chunks, name, { type })); + onCompleteRef.current(new File(chunks, name, { type }), isVideo ? 'video' : 'voice'); } recorderRef.current = null; setRecording(null); diff --git a/packages/web/src/features/messaging/ui/ChatView.tsx b/packages/web/src/features/messaging/ui/ChatView.tsx index 62a73a0..f512992 100644 --- a/packages/web/src/features/messaging/ui/ChatView.tsx +++ b/packages/web/src/features/messaging/ui/ChatView.tsx @@ -12,15 +12,30 @@ import { DoubleCheckIcon, EditIcon, TrashIcon, + CopyIcon, UsersIcon, + ChevronLeftIcon, + ChevronDownIcon, + ContextMenu, + MenuItem, + MenuSeparator, + useContextMenu, + ConfirmDialog, } from '../../../shared/ui'; +import type { DialogAction } from '../../../shared/ui'; import { useConversationMessages } from '../model'; import { useRecorder } from '../recorder'; -import { MediaMessage, isRoundVideo } from './MediaMessage'; +import { MediaMessage } from './MediaMessage'; import { MediaViewerProvider } from './MediaViewer'; -const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉']; +const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉', '🔥', '👎']; const GROUP_WINDOW_MS = 5 * 60 * 1000; +const NEAR_BOTTOM_PX = 80; + +// Per-conversation scroll positions for this session: leaving a chat and +// reopening it restores where you were; chats without a saved position open +// at the newest message. +const scrollMemory = new Map(); const formatElapsed = (ms: number): string => { const total = Math.floor(ms / 1000); @@ -66,6 +81,10 @@ interface Props { conversation: Conversation; me: PublicUser; onlineMap: Record; + /** Open the profile panel for a user (chat header on DMs, sender avatars in groups). */ + onOpenProfile?: (user: PublicUser) => void; + /** Mobile: navigate back to the conversation list. */ + onBack?: () => void; } const headerTitle = (conversation: Conversation): string => { @@ -82,41 +101,56 @@ const ChatHeader = ({ conversation, online, typing, + onOpenProfile, + onBack, }: { conversation: Conversation; online: boolean; typing: boolean; + onOpenProfile?: ((user: PublicUser) => void) | undefined; + onBack?: (() => void) | undefined; }): ReactElement => { const peer = conversation.peer; const isGroup = conversation.type === 'group'; - const subtitle = typing - ? 'typing…' - : isGroup - ? 'Group' - : online - ? 'online' - : 'offline'; + const subtitle = typing ? 'typing…' : isGroup ? 'Group' : online ? 'online' : 'offline'; + const openable = peer !== null && onOpenProfile !== undefined; return (
- {peer !== null && peer.avatarUrl !== null ? ( - - ) : ( - - {isGroup ? : headerTitle(conversation).charAt(0)} + {onBack !== undefined ? ( + + ) : null} +
); }; -export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement => { +export const ChatView = ({ conversation, me, onlineMap, onOpenProfile, onBack }: Props): ReactElement => { const { messages, loading, @@ -126,20 +160,28 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement = sendMedia, edit, remove, + hide, toggleReaction, notifyTyping, } = useConversationMessages(conversation, me); const [text, setText] = useState(''); - const [editingId, setEditingId] = useState(null); - const [draft, setDraft] = useState(''); - const recorder = useRecorder((file) => { - void sendMedia(file, ''); + const [editing, setEditing] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const menu = useContextMenu(); + const recorder = useRecorder((file, kind) => { + void sendMedia(file, '', kind === 'video' ? 'video_note' : 'voice'); }); const previewRef = useRef(null); const scrollRef = useRef(null); + const listRef = useRef(null); + const composerRef = useRef(null); const atBottomRef = useRef(true); + const restoredRef = useRef(false); + const [showJump, setShowJump] = useState(false); const isGroup = conversation.type === 'group'; const peerOnline = conversation.peer !== null && onlineMap[conversation.peer.id] === true; + // Group owners moderate: they may delete anyone's message for everyone. + const canModerate = isGroup && conversation.createdBy === me.id; // Mirror the live camera stream into the in-composer preview while recording. useEffect(() => { @@ -149,25 +191,95 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement = } }, [recorder.previewStream]); - // Jump to the newest message when opening a conversation. + // Reset per-conversation view state; the scroll position is restored below + // once history has loaded. useEffect(() => { + restoredRef.current = false; atBottomRef.current = true; + setShowJump(false); + setEditing(null); + setText(''); + setDeleteTarget(null); }, [conversation.id]); + // Initial position: the saved spot from the last visit, else the newest message. + useLayoutEffect(() => { + const node = scrollRef.current; + if (node === null || loading || restoredRef.current) { + return; + } + restoredRef.current = true; + const saved = scrollMemory.get(conversation.id); + node.scrollTop = saved ?? node.scrollHeight; + const nearBottom = node.scrollHeight - node.scrollTop - node.clientHeight < NEAR_BOTTOM_PX; + atBottomRef.current = nearBottom; + setShowJump(!nearBottom); + }, [loading, conversation.id]); + // Keep the scrollback pinned to the newest message — but only when the reader // is already near the bottom, so scrolling up through history isn't yanked. useLayoutEffect(() => { const node = scrollRef.current; - if (node !== null && atBottomRef.current) { + if (node !== null && restoredRef.current && atBottomRef.current) { node.scrollTop = node.scrollHeight; } }, [messages.length, typingUserIds.length]); + // Media (images, players) grows the list after render; while pinned to the + // bottom, follow that growth so opening a chat truly lands on the newest + // message instead of drifting up as content loads. + useEffect(() => { + const list = listRef.current; + const node = scrollRef.current; + if (list === null || node === null) { + return undefined; + } + const observer = new ResizeObserver(() => { + if (atBottomRef.current) { + node.scrollTop = node.scrollHeight; + } + }); + observer.observe(list); + return () => { + observer.disconnect(); + }; + }, [conversation.id]); + const onScroll = (): void => { const node = scrollRef.current; - if (node !== null) { - atBottomRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80; + if (node === null || !restoredRef.current) { + return; } + const nearBottom = node.scrollHeight - node.scrollTop - node.clientHeight < NEAR_BOTTOM_PX; + atBottomRef.current = nearBottom; + setShowJump(!nearBottom); + // Leaving from the bottom means "reopen at the newest message" — only a + // position somewhere up in history is worth remembering. + if (nearBottom) { + scrollMemory.delete(conversation.id); + } else { + scrollMemory.set(conversation.id, node.scrollTop); + } + }; + + const jumpToLatest = (): void => { + const node = scrollRef.current; + if (node !== null) { + node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' }); + atBottomRef.current = true; + setShowJump(false); + } + }; + + const startEdit = (message: Message): void => { + setEditing(message); + setText(message.content); + composerRef.current?.focus(); + }; + + const cancelEdit = (): void => { + setEditing(null); + setText(''); }; const submit = async (event: SyntheticEvent): Promise => { @@ -176,6 +288,15 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement = if (trimmed === '') { return; } + if (editing !== null) { + const target = editing; + setEditing(null); + setText(''); + if (trimmed !== target.content) { + await edit(target.id, trimmed); + } + return; + } setText(''); await send(trimmed); }; @@ -185,6 +306,9 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement = event.preventDefault(); void submit(event); } + if (event.key === 'Escape' && editing !== null) { + cancelEdit(); + } }; const onAttach = (event: SyntheticEvent): void => { @@ -196,17 +320,29 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement = input.value = ''; }; - const startEdit = (message: Message): void => { - setEditingId(message.id); - setDraft(message.content); - }; - - const commitEdit = async (messageId: string): Promise => { - const next = draft.trim(); - setEditingId(null); - if (next !== '') { - await edit(messageId, next); + const deleteActions = (message: Message): DialogAction[] => { + const actions: DialogAction[] = []; + if (message.senderId === me.id || canModerate) { + actions.push({ + key: 'everyone', + label: 'Delete for everyone', + danger: true, + onSelect: () => { + setDeleteTarget(null); + void remove(message.id); + }, + }); } + actions.push({ + key: 'me', + label: 'Delete for me', + danger: true, + onSelect: () => { + setDeleteTarget(null); + void hide(message.id); + }, + }); + return actions; }; const tick = (message: Message): ReactElement | null => { @@ -228,279 +364,324 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement = conversation={conversation} online={peerOnline} typing={typingUserIds.length > 0} + onOpenProfile={onOpenProfile} + onBack={onBack} /> -
- {loading ?

Loading…

: null} -
- {messages.map((message, index) => { - const prev = index > 0 ? messages[index - 1] : undefined; - const next = index < messages.length - 1 ? messages[index + 1] : undefined; - const mine = message.senderId === me.id; - const newDay = prev === undefined || dayKey(prev.createdAt) !== dayKey(message.createdAt); - const groupStart = newDay || !inSameGroup(message, prev); - const groupEnd = !inSameGroup(message, next); - const showAvatar = !mine && isGroup; - const deleted = message.deletedAt !== null; - const editing = editingId === message.id; - const media = message.media; - // Round video notes render without bubble chrome (like Telegram). - const bareMedia = - media !== null && !deleted && media.kind === 'video' && isRoundVideo(media.name); - const hasMedia = media !== null && !deleted && !bareMedia; +
+ {loading ?

Loading…

: null} +
+ {messages.map((message, index) => { + const prev = index > 0 ? messages[index - 1] : undefined; + const next = index < messages.length - 1 ? messages[index + 1] : undefined; + const mine = message.senderId === me.id; + const newDay = + prev === undefined || dayKey(prev.createdAt) !== dayKey(message.createdAt); + const groupStart = newDay || !inSameGroup(message, prev); + const groupEnd = !inSameGroup(message, next); + const showAvatar = !mine && isGroup; + const deleted = message.deletedAt !== null; + const media = message.media; + // Round video notes render without bubble chrome (like Telegram). + const bareMedia = media !== null && !deleted && media.kind === 'video_note'; + const hasMedia = media !== null && !deleted && !bareMedia; - const lineClass = [ - 'msg-line', - mine ? 'mine' : 'theirs', - groupStart ? 'group-start' : '', - groupEnd ? 'group-end' : '', - ] - .filter((token) => token !== '') - .join(' '); + const lineClass = [ + 'msg-line', + mine ? 'mine' : 'theirs', + groupStart ? 'group-start' : '', + groupEnd ? 'group-end' : '', + ] + .filter((token) => token !== '') + .join(' '); - const bubbleClass = [ - 'bubble', - mine ? 'bubble-mine' : 'bubble-theirs', - groupStart ? 'is-start' : '', - groupEnd ? 'is-end' : '', - bareMedia ? 'bubble-bare' : '', - hasMedia ? 'has-media' : '', - ] - .filter((token) => token !== '') - .join(' '); + const bubbleClass = [ + 'bubble', + mine ? 'bubble-mine' : 'bubble-theirs', + groupStart ? 'is-start' : '', + groupEnd ? 'is-end' : '', + bareMedia ? 'bubble-bare' : '', + hasMedia ? 'has-media' : '', + ] + .filter((token) => token !== '') + .join(' '); - return ( - - {newDay ? ( -
- {dayLabel(message.createdAt)} -
- ) : null} -
- {showAvatar ? ( - groupEnd ? ( - message.sender.avatarUrl !== null ? ( - - ) : ( - - {message.sender.displayName.charAt(0)} - - ) - ) : ( - - ) + return ( + + {newDay ? ( +
+ {dayLabel(message.createdAt)} +
) : null} - -
- {showAvatar && groupStart && !deleted ? ( - {message.sender.displayName} +
+ {showAvatar ? ( + groupEnd ? ( + + ) : ( + + ) ) : null} -
- {editing ? ( -
-