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
}
+ 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 ? (
-
- ) : deleted ? (
-
Message deleted
- ) : (
- <>
- {message.media !== null ? (
-
- ) : null}
- {message.content.length > 0 ? (
-
{message.content}
- ) : null}
-
- {message.editedAt !== null ? (
- edited
- ) : null}
- {clock(message.createdAt)}
- {tick(message)}
-
- >
- )}
+
+ {showAvatar && groupStart && !deleted ? (
+
{message.sender.displayName}
+ ) : null}
- {!deleted && !editing ? (
-
- {QUICK_REACTIONS.map((emoji) => (
+
{
+ menu.openAt(event, message);
+ }
+ }
+ >
+ {deleted ? (
+ Message deleted
+ ) : (
+ <>
+ {media !== null ? (
+
+ ) : null}
+ {message.content.length > 0 ? (
+ {message.content}
+ ) : null}
+
+ {message.editedAt !== null ? (
+ edited
+ ) : null}
+ {clock(message.createdAt)}
+ {tick(message)}
+
+ >
+ )}
+
+
+ {message.reactions.length > 0 ? (
+
+ {message.reactions.map((reaction) => (
))}
- {mine ? (
- <>
-
-
-
- >
- ) : null}
) : null}
-
- {message.reactions.length > 0 ? (
-
- {message.reactions.map((reaction) => (
-
- ))}
-
- ) : null}
-
-
- );
- })}
+
+ );
+ })}
+
-
- {recorder.recording !== null ? (
-
- {recorder.recording === 'video' ? (
-
- ) : null}
-
-
- {recorder.recording === 'video' ? 'Recording video' : 'Recording voice'} ·{' '}
- {formatElapsed(recorder.elapsedMs)}
-
-
+ {showJump ? (
-
- ) : (
-
- )}
+ ) : null}
);
diff --git a/packages/web/src/features/messaging/ui/MediaMessage.tsx b/packages/web/src/features/messaging/ui/MediaMessage.tsx
index 00ce684..db77e71 100644
--- a/packages/web/src/features/messaging/ui/MediaMessage.tsx
+++ b/packages/web/src/features/messaging/ui/MediaMessage.tsx
@@ -14,10 +14,6 @@ const formatSize = (bytes: number): string => {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
-// Recorded video messages carry this fixed name (see features/messaging/recorder.ts)
-// and render as a circular "video note"; any other video is a regular file player.
-export const isRoundVideo = (name: string): boolean => name.startsWith('video-message');
-
interface Props {
conversationId: string;
message: Message;
@@ -61,10 +57,11 @@ export const MediaMessage = ({ conversationId, message, mine }: Props): ReactEle
);
}
+ if (media.kind === 'video_note') {
+ return ;
+ }
+
if (media.kind === 'video') {
- if (isRoundVideo(media.name)) {
- return ;
- }
return (