Compare commits

...

4 commits

Author SHA1 Message Date
Заид Омар Медхат | Zaid Omar Medhat
cf432bd803 Telegram-style UX: folders, deletion modes, profile, settings, mobile nav
Data model / backend (migration 1720000000007):
- video_note as a first-class MediaRef kind — round video messages are
  explicit, no filename heuristics; mime-validated, own push label.
- Delete for me (message_hidden tombstones) alongside delete-for-everyone;
  group owners can moderate-delete any message in their groups.
- Clear history / delete chat per user (cleared_up_to_seq + hidden_at on
  conversation_members); deleted chats return on new activity.
- Manual chat folders + per-folder pins (chat_folders, chat_folder_items,
  chat_pins; folderId null = "All" tab). Every mutation returns and
  broadcasts a full snapshot on the personal channel (folders.update),
  syncing devices. New events: message.hidden, conversation.cleared,
  conversation.hidden.
- Shared-media listing: GET /conversations/:id/media?tab=media|files|voice.

Web:
- Right-click context menus (shared ContextMenu/ConfirmDialog): messages get
  a reactions row + copy/edit/delete; chats get pin/unpin per folder scope,
  folder membership, clear, delete; folder tabs get rename/delete.
- Telegram-style editing in the composer (banner + prefill), hover toolbar
  removed; delete dialog offers for-me / for-everyone per permissions.
- Folder tabs in the rail with per-folder pinned-first ordering.
- Profile panel (avatar, name, last seen, username, Message button) with
  shared media tabs; back arrow on mobile.
- Settings page (avatar, display name edit, theme, log out); contacts as a
  separate page; Telegram-style mobile bottom nav (Contacts|Chats|Settings)
  with total-unread badge.
- Chat scroll: opens at newest (ResizeObserver keeps bottom pinned while
  media loads), per-chat position memory, jump-to-newest button.
- Responsive single-pane layout under 900px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-11 01:53:34 +05:00
Заид Омар Медхат | Zaid Omar Medhat
55825662d7 infra: self-heal nginx after unordered stack restarts
A concurrent restart (docker compose restart / Docker Desktop) can start
nginx before the backend's DNS entry exists; "host not found in upstream"
is fatal at config load and left the gateway dead (502s). restart:
unless-stopped retries until dependencies are up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-11 01:53:15 +05:00
Заид Омар Медхат | Zaid Omar Medhat
71e39c7e88 Fix refresh-token rotation race that logged users out
Two concurrent refresh calls (double-mounted bootstrap effect, second tab)
replayed the same cookie; the loser tripped reuse detection and revoked the
whole token family, forcing re-login.

- Client: single-flight /auth/refresh — concurrent callers share one request.
- Server: 30s grace window for a just-rotated token, but only while the
  family still has a live successor, so theft detection still kills a
  genuinely compromised family and logout stays final.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-11 01:53:04 +05:00
Заид Омар Медхат | Zaid Omar Medhat
7ab6b72866 Redesign web client: modern Telegram-shaped chat UI
Full presentation-layer rebuild of packages/web (no core/API/model changes).

- Design system: indigo light+dark token sets, 25 inline SVG icons, CSS
  foundation (spacing/radius/type/motion scales, per-theme elevation, reset,
  scrollbars).
- Shell: two-pane rail (brand, icon theme switch, live search, rich
  conversation rows, account footer, group/contacts compose) + auth card.
- Chat pane: sticky header with presence/typing, grouped bubbles with date
  separators, icon read-ticks, hover toolbar, inline edit, reaction pills,
  auto-growing composer with Enter-to-send, near-bottom-aware autoscroll.
- Media renderers: waveform voice player (WebAudio decode + pseudo-waveform
  fallback), circular video notes (rendered bare), image lightbox with
  zoom/pan, framed video->lightbox player, file cards.
- Restore + modernize notification toast styles.

Emoji kept only as reaction content; all chrome is icons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-11 00:30:49 +05:00
54 changed files with 6973 additions and 1000 deletions

View file

@ -137,6 +137,10 @@ services:
nginx:
image: nginx:1.27-alpine
# Restart on failure: a concurrent stack restart can start nginx before the
# backend's DNS entry exists ("host not found in upstream"), which is fatal
# at config load — retrying once dependencies are up self-heals the gateway.
restart: unless-stopped
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:

View file

@ -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');
};

View file

@ -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<Fastif
messages: messagesRepository,
conversations: conversationsRepository,
deliver,
publish,
mediaDownloadUrl: (objectKey) => mediaService.downloadUrl(objectKey),
notify: (message) => {
void notificationQueue
@ -173,6 +175,14 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
'contactsService',
createContactsService({ contacts: contactsRepository, users: usersRepository }),
);
app.decorate(
'foldersService',
createFoldersService({
folders: createFoldersRepository(app.db),
conversations: conversationsRepository,
publish,
}),
);
app.decorate(
'presenceService',
createPresenceService({
@ -191,6 +201,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
await app.register(conversationsRoutes);
await app.register(messagesRoutes);
await app.register(contactsRoutes);
await app.register(foldersRoutes);
await app.register(presenceRoutes);
await app.register(mediaRoutes);
await app.register(notificationsRoutes);

View file

@ -44,6 +44,10 @@ export interface ConversationMembersTable {
user_id: string;
role: Generated<string>;
joined_at: Generated<Date>;
// 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<number>;
hidden_at: Date | null;
}
export interface MessagesTable {
@ -114,6 +118,35 @@ export interface ConversationMutesTable {
created_at: Generated<Date>;
}
// "Delete for me" tombstones — history queries anti-join against this.
export interface MessageHiddenTable {
user_id: string;
message_id: string;
created_at: Generated<Date>;
}
export interface ChatFoldersTable {
id: Generated<string>;
user_id: string;
title: string;
position: Generated<number>;
created_at: Generated<Date>;
}
export interface ChatFolderItemsTable {
folder_id: string;
conversation_id: string;
created_at: Generated<Date>;
}
// 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<Date>;
}
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;
}

View file

@ -17,6 +17,8 @@ export interface RefreshTokensRepository {
findByHash(tokenHash: string): Promise<RefreshTokenRow | undefined>;
revokeById(id: string): Promise<void>;
revokeFamily(familyId: string): Promise<void>;
/** True when the family still has a live (unrevoked, unexpired) token. */
hasActiveInFamily(familyId: string): Promise<boolean>;
revokeAllForUser(userId: string): Promise<void>;
listActiveForUser(userId: string): Promise<RefreshTokenRow[]>;
}
@ -50,6 +52,18 @@ export const createRefreshTokensRepository = (
.execute();
},
hasActiveInFamily: async (familyId) => {
const row = await db
.selectFrom('refresh_tokens')
.select('id')
.where('family_id', '=', familyId)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.limit(1)
.executeTakeFirst();
return row !== undefined;
},
revokeFamily: async (familyId) => {
await db
.updateTable('refresh_tokens')

View file

@ -135,8 +135,18 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
}
if (row.revoked_at !== null) {
await tokens.revokeFamily(row.family_id);
throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected');
// Grace window: the same cookie replayed moments after rotation is a
// parallel client (second tab, double-mounted bootstrap), not theft.
// Re-issue within the family instead of nuking it. The grace only
// applies while the family still has a live successor — a family
// killed by reuse-detection or logout stays dead, so genuine theft
// can't ride the window back in.
const graceMs = 30_000;
const withinGrace = Date.now() - row.revoked_at.getTime() <= graceMs;
if (!withinGrace || !(await tokens.hasActiveInFamily(row.family_id))) {
await tokens.revokeFamily(row.family_id);
throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected');
}
}
if (row.expires_at.getTime() <= Date.now()) {
throw new HttpError(401, 'invalid_token', 'Refresh token expired');
@ -145,7 +155,9 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
if (user === undefined) {
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
}
await tokens.revokeById(row.id);
if (row.revoked_at === null) {
await tokens.revokeById(row.id);
}
return issue(user, ctx, row.family_id);
},

View file

@ -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<MemberWithUser[]>;
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
touchLastMessage(conversationId: string): Promise<void>;
/** "Clear history" for me: hide everything up to the current max seq; resets unread. Returns that seq. */
clearForUser(conversationId: string, userId: string): Promise<number>;
/** "Delete chat" for me: clear + drop from the list until new activity arrives. Returns cleared seq. */
hideForUser(conversationId: string, userId: string): Promise<number>;
}
export const createConversationsRepository = (db: Kysely<Database>): ConversationsRepository => {
const clearForUser = (conversationId: string, userId: string): Promise<number> =>
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<Database>): 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<Database>): 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;
},
};
};

View file

@ -169,6 +169,46 @@ export const conversationsRoutes = (app: FastifyInstance): Promise<void> => {
},
);
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',
{

View file

@ -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<boolean>;
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
markRead(conversationId: string, userId: string, seq: number): Promise<void>;
clear(conversationId: string, userId: string): Promise<void>;
hide(conversationId: string, userId: string): Promise<void>;
}
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);
},
};
};

View file

@ -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<FoldersState>;
create(userId: string, title: string): Promise<void>;
/** Returns false when the folder does not belong to the user. */
owns(userId: string, folderId: string): Promise<boolean>;
rename(folderId: string, title: string): Promise<void>;
setPosition(folderId: string, position: number): Promise<void>;
setChats(folderId: string, conversationIds: string[]): Promise<void>;
remove(folderId: string): Promise<void>;
pin(userId: string, conversationId: string, folderId: string | null): Promise<void>;
unpin(userId: string, conversationId: string, folderId: string | null): Promise<void>;
}
export const createFoldersRepository = (db: Kysely<Database>): 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();
},
});

View file

@ -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<void> => {
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();
};

View file

@ -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<FoldersState>;
create(userId: string, body: CreateFolderBody): Promise<FoldersState>;
update(userId: string, folderId: string, body: UpdateFolderBody): Promise<FoldersState>;
remove(userId: string, folderId: string): Promise<FoldersState>;
setPin(userId: string, body: SetPinBody): Promise<FoldersState>;
}
const MAX_FOLDERS = 20;
export const createFoldersService = (deps: FoldersServiceDeps): FoldersService => {
const { folders, conversations, publish } = deps;
const assertOwnsFolder = async (userId: string, folderId: string): Promise<void> => {
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<FoldersState> => {
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;
}
}

View file

@ -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';

View file

@ -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;
};

View file

@ -41,8 +41,18 @@ export interface MessagesRepository {
clientMsgId: string,
): Promise<string | undefined>;
getWithSenderById(id: string): Promise<MessageWithSenderRow | undefined>;
/** 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<MessageWithSenderRow[]>;
/** Media messages as seen by `userId` (profile panel tabs), newest first. */
listMedia(
conversationId: string,
userId: string,
kinds: string[],
beforeSeq: number | null,
limit: number,
): Promise<MessageWithSenderRow[]>;
@ -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<void>;
addReaction(messageId: string, userId: string, emoji: string): Promise<void>;
removeReaction(messageId: string, userId: string, emoji: string): Promise<void>;
reactionsFor(messageIds: string[], userId: string): Promise<Map<string, ReactionSummary[]>>;
@ -82,6 +96,28 @@ export const createMessagesRepository = (db: Kysely<Database>): 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<Database>): 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<string>`messages.media_meta->>'kind'`, 'in', kinds));
if (beforeSeq !== null) {
query = query.where('messages.seq', '<', beforeSeq);
}
@ -152,6 +200,23 @@ export const createMessagesRepository = (db: Kysely<Database>): 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')

View file

@ -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<void> => {
},
);
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',
{

View file

@ -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<string>;
/** 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<MediaTab, string[]> = {
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<Message>;
remove(conversationId: string, messageId: string, userId: string): Promise<void>;
hide(conversationId: string, messageId: string, userId: string): Promise<void>;
media(
conversationId: string,
userId: string,
tab: MediaTab,
beforeSeq: number | null,
limit: number,
): Promise<Message[]>;
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<void> => {
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);

View file

@ -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:

View file

@ -22,9 +22,22 @@ export const register = async (config: ApiClientConfig, body: RegisterBody): Pro
export const login = async (config: ApiClientConfig, body: LoginBody): Promise<AuthResult> =>
parse(authResultV, await requestJson(config, 'POST', '/auth/login', body));
// Uses the httpOnly refresh cookie — no body.
export const refresh = async (config: ApiClientConfig): Promise<AuthResult> =>
parse(authResultV, await requestJson(config, 'POST', '/auth/refresh'));
// Uses the httpOnly refresh cookie — no body. Single-flighted: refresh rotates
// the cookie, so two concurrent calls (double-mounted bootstrap effect, several
// features racing on a 401) would replay the same token and trip the server's
// reuse detection. All concurrent callers share one in-flight request.
let inflightRefresh: Promise<AuthResult> | null = null;
export const refresh = (config: ApiClientConfig): Promise<AuthResult> => {
inflightRefresh ??= (async () => {
try {
return parse(authResultV, await requestJson(config, 'POST', '/auth/refresh'));
} finally {
inflightRefresh = null;
}
})();
return inflightRefresh;
};
export const logout = async (config: ApiClientConfig): Promise<void> => {
await requestJson(config, 'POST', '/auth/logout');

View file

@ -61,3 +61,13 @@ export const markRead = async (
): Promise<void> => {
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<void> => {
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<void> => {
await requestJson(config, 'POST', `/conversations/${id}/hide`);
};

View file

@ -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<FoldersState>(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<FoldersState> =>
parse(foldersStateV, await requestJson(config, 'GET', '/folders'));
export const createFolder = async (
config: ApiClientConfig,
body: CreateFolderBody,
): Promise<FoldersState> =>
parse(foldersStateV, await requestJson(config, 'POST', '/folders', body));
export const updateFolder = async (
config: ApiClientConfig,
folderId: string,
body: UpdateFolderBody,
): Promise<FoldersState> =>
parse(foldersStateV, await requestJson(config, 'PATCH', `/folders/${folderId}`, body));
export const deleteFolder = async (
config: ApiClientConfig,
folderId: string,
): Promise<FoldersState> =>
parse(foldersStateV, await requestJson(config, 'DELETE', `/folders/${folderId}`));
export const setPin = async (config: ApiClientConfig, body: SetPinBody): Promise<FoldersState> =>
parse(foldersStateV, await requestJson(config, 'PUT', '/folders/pins', body));

View file

@ -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';

View file

@ -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<void> => {
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<Message[]> => {
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,

View file

@ -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;
}

View file

@ -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<typeof createFolderBodySchema>;
export type UpdateFolderBody = FromSchema<typeof updateFolderBodySchema>;
export type SetPinBody = FromSchema<typeof setPinBodySchema>;
export type MediaTab = FromSchema<typeof mediaListQuerySchema>['tab'];

View file

@ -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,

View file

@ -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 = {

View file

@ -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[];
}

View file

@ -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,

View file

@ -1,19 +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, useRealtime } from '../features/realtime';
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 {
PlusIcon,
CloseIcon,
UsersIcon,
UserIcon,
ChatsIcon,
SettingsIcon,
ChevronLeftIcon,
} from '../shared/ui';
const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'];
type RailView = 'chats' | 'contacts' | 'compose' | 'settings';
const RAIL_TITLES: Record<Exclude<RailView, 'chats'>, string> = {
contacts: 'Contacts',
compose: 'New group',
settings: 'Settings',
};
const mediaLabel = (media: MediaRef | null): string => {
if (media === null) {
@ -24,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:
@ -31,22 +49,61 @@ const mediaLabel = (media: MediaRef | null): string => {
}
};
const ThemeSwitch = (): ReactElement => {
const { preference, setPreference } = useTheme();
const GroupComposer = ({
onCreate,
}: {
onCreate: (title: string, members: string[]) => Promise<void>;
}): ReactElement => {
const [title, setTitle] = useState('');
const [members, setMembers] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async (event: SyntheticEvent): Promise<void> => {
event.preventDefault();
const trimmed = title.trim();
if (trimmed === '') {
return;
}
const list = members
.split(',')
.map((name) => name.trim())
.filter((name) => name.length > 0);
setError(null);
try {
await onCreate(trimmed, list);
setTitle('');
setMembers('');
} catch {
setError('Could not create group');
}
};
return (
<div className="theme-switch">
{PREFERENCES.map((option) => (
<button
key={option}
type="button"
aria-pressed={preference === option}
onClick={() => {
setPreference(option);
<div className="compose-block">
<p className="rail-section-title">New group</p>
<form className="stack-form" onSubmit={(event) => void submit(event)}>
<input
className="field"
placeholder="Group name"
value={title}
onChange={(event) => {
setTitle(event.target.value);
}}
>
{option}
/>
<input
className="field"
placeholder="Members (comma-separated usernames)"
value={members}
onChange={(event) => {
setMembers(event.target.value);
}}
/>
<button type="submit" className="btn-primary btn-sm">
<UsersIcon size={17} />
Create group
</button>
))}
</form>
{error !== null ? <p className="form-error">{error}</p> : null}
</div>
);
};
@ -58,11 +115,13 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
displayName: user.displayName,
avatarUrl: user.avatarUrl,
};
const { updateUser } = useSession();
const { state } = useRealtime();
const { notify, setOpener } = useNotifications();
const [current, setCurrent] = useState<Conversation | null>(null);
const [railView, setRailView] = useState<RailView>('chats');
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
const [profileUser, setProfileUser] = useState<PublicUser | null>(null);
const convRef = useRef<Conversation[]>([]);
const folders = useFolders(user.id);
// Toast (or OS notification when hidden) for messages arriving in other chats.
const onIncoming = useCallback(
@ -79,22 +138,48 @@ 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);
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) => {
const conversation = convRef.current.find((item) => item.id === conversationId);
if (conversation !== undefined) {
setCurrent(conversation);
openConversation(conversation);
}
});
}, [setOpener]);
}, [setOpener, openConversation]);
// Deep-link: /?conversation=<id> (from a push opened in a fresh tab).
useEffect(() => {
@ -110,21 +195,6 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
}
}, [conversations]);
const onAvatar = (event: SyntheticEvent<HTMLInputElement>): 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(() => {
@ -135,57 +205,209 @@ 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<void> => {
openConversation(await startDirect(username));
};
const createGroupAndOpen = async (title: string, members: string[]): Promise<void> => {
openConversation(await createGroupChat(title, members));
};
const shellClass = [
'app-shell',
current !== null ? 'has-chat' : '',
profileUser !== null ? 'has-profile' : '',
]
.filter((token) => token !== '')
.join(' ');
return (
<div className="layout">
<header className="topbar">
<span className="topbar-user">
<label className="avatar-edit" title="Change avatar">
{user.avatarUrl !== null ? (
<img src={user.avatarUrl} alt="avatar" className="avatar" />
<div className={shellClass}>
<aside className="rail">
<div className="rail-header">
{railView === 'chats' ? (
<span className="rail-brand">
<span className="rail-mark" aria-hidden="true">
A
</span>
<span className="rail-title">Altricade</span>
</span>
) : (
<span className="rail-brand">
<button
type="button"
className="icon-btn"
aria-label="Back to chats"
onClick={() => {
setRailView('chats');
}}
>
<ChevronLeftIcon />
</button>
<span className="rail-title">{RAIL_TITLES[railView]}</span>
</span>
)}
<div className="rail-header-actions">
{railView === 'chats' ? (
<>
<button
type="button"
className="icon-btn rail-desktop-action"
aria-label="Contacts"
title="Contacts"
onClick={() => {
setRailView('contacts');
}}
>
<UsersIcon />
</button>
<button
type="button"
className="icon-btn rail-desktop-action"
aria-label="Settings"
title="Settings"
onClick={() => {
setRailView('settings');
}}
>
<SettingsIcon />
</button>
<button
type="button"
className="icon-btn"
aria-label="New group"
title="New group"
onClick={() => {
setRailView('compose');
}}
>
<PlusIcon />
</button>
</>
) : (
<span className="avatar avatar-placeholder">{user.username.charAt(0)}</span>
<button
type="button"
className="icon-btn"
aria-label="Close"
onClick={() => {
setRailView('chats');
}}
>
<CloseIcon />
</button>
)}
<input type="file" accept="image/*" hidden onChange={onAvatar} />
</label>
<span>
<strong>@{user.username}</strong> · socket: {state}
</span>
</span>
<span className="topbar-actions">
<ThemeSwitch />
<button type="button" onClick={onLogout}>
Log out
</button>
</span>
</header>
<div className="workspace">
<div className="sidebar-column">
</div>
</div>
{railView === 'chats' ? (
<ConversationSidebar
conversations={conversations}
currentId={current?.id ?? null}
onlineMap={onlineMap}
onSelect={setCurrent}
onStartDirect={async (username) => {
setCurrent(await startDirect(username));
}}
onCreateGroup={async (title, members) => {
setCurrent(await createGroupChat(title, members));
}}
folders={folders}
activeFolderId={activeFolderId}
onSelectFolder={setActiveFolderId}
onSelect={openConversation}
onStartDirect={startDirectAndOpen}
onClearChat={clearChat}
onDeleteChat={deleteChat}
/>
<ContactsPanel
onStartDirect={async (username) => {
setCurrent(await startDirect(username));
}}
/>
</div>
{current !== null ? (
<ChatView conversation={current} me={me} />
) : railView === 'contacts' ? (
<div className="rail-scroll compose">
<ContactsPanel onStartDirect={startDirectAndOpen} />
</div>
) : railView === 'compose' ? (
<div className="rail-scroll compose">
<GroupComposer onCreate={createGroupAndOpen} />
</div>
) : (
<section className="chat chat-empty">
<p>Search for a user or pick a contact to start chatting.</p>
</section>
<div className="rail-scroll">
<SettingsPanel onLogout={onLogout} />
</div>
)}
</div>
{/* Mobile bottom navigation (Telegram-style): Contacts | Chats | Settings */}
<nav className="rail-nav" aria-label="Main">
<button
type="button"
className="rail-nav-btn"
aria-pressed={railView === 'contacts'}
aria-label="Contacts"
onClick={() => {
setRailView('contacts');
}}
>
<UserIcon size={24} />
</button>
<button
type="button"
className="rail-nav-btn"
aria-pressed={railView === 'chats'}
aria-label="Chats"
onClick={() => {
setRailView('chats');
}}
>
<span className="rail-nav-icon">
<ChatsIcon size={24} />
{totalUnread > 0 ? <span className="unread-badge rail-nav-badge">{totalUnread}</span> : null}
</span>
</button>
<button
type="button"
className="rail-nav-btn"
aria-pressed={railView === 'settings'}
aria-label="Settings"
onClick={() => {
setRailView('settings');
}}
>
<SettingsIcon size={24} />
</button>
</nav>
</aside>
{current !== null ? (
<ChatView
conversation={current}
me={me}
onlineMap={onlineMap}
onOpenProfile={(target) => {
if (target.id !== user.id) {
setProfileUser(target);
}
}}
onBack={() => {
setCurrent(null);
}}
/>
) : (
<section className="chat-pane chat-empty">
<div className="chat-empty-inner">
<span className="chat-empty-mark" aria-hidden="true">
A
</span>
<h2>Select a conversation</h2>
<p className="muted">Search for someone or start a new chat to begin messaging.</p>
</div>
</section>
)}
{profileUser !== null ? (
<ProfilePanel
user={profileUser}
conversation={profileConversation}
online={onlineMap[profileUser.id] === true}
onClose={() => {
setProfileUser(null);
}}
onMessage={(username) => {
void startDirectAndOpen(username);
}}
/>
) : null}
</div>
);
};
@ -195,8 +417,10 @@ const Shell = (): ReactElement => {
if (status === 'loading') {
return (
<main className="app">
<p>Loading</p>
<main className="splash">
<span className="splash-mark" aria-hidden="true">
A
</span>
</main>
);
}

File diff suppressed because it is too large Load diff

View file

@ -32,56 +32,75 @@ export const AuthForm = (): ReactElement => {
};
return (
<main className="app">
<h1>Altricade</h1>
<p>{mode === 'login' ? 'Log in' : 'Create an account'}</p>
<form
className="auth-form"
onSubmit={(event) => {
void submit(event);
}}
>
<input
placeholder="username"
autoComplete="username"
value={username}
onChange={(event) => {
setUsername(event.target.value);
}}
/>
{mode === 'register' ? (
<input
placeholder="display name"
value={displayName}
onChange={(event) => {
setDisplayName(event.target.value);
<main className="auth">
<div className="auth-card">
<div className="auth-brand">
<span className="auth-mark" aria-hidden="true">
A
</span>
<h1 className="auth-wordmark">Altricade</h1>
<p className="auth-tagline">
{mode === 'login' ? 'Welcome back.' : 'Create your account.'}
</p>
</div>
<form className="auth-form" onSubmit={(event) => void submit(event)}>
<label className="field-label">
Username
<input
className="field"
placeholder="username"
autoComplete="username"
value={username}
onChange={(event) => {
setUsername(event.target.value);
}}
/>
</label>
{mode === 'register' ? (
<label className="field-label">
Display name
<input
className="field"
placeholder="Your name"
value={displayName}
onChange={(event) => {
setDisplayName(event.target.value);
}}
/>
</label>
) : null}
<label className="field-label">
Password
<input
className="field"
type="password"
placeholder="••••••••"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
value={password}
onChange={(event) => {
setPassword(event.target.value);
}}
/>
</label>
{error !== null ? <p className="form-error">{error}</p> : null}
<button type="submit" className="btn-primary" disabled={busy}>
{busy ? 'Please wait…' : mode === 'login' ? 'Log in' : 'Create account'}
</button>
</form>
<p className="auth-switch">
{mode === 'login' ? "Don't have an account?" : 'Already have an account?'}{' '}
<button
type="button"
className="link"
onClick={() => {
setMode(mode === 'login' ? 'register' : 'login');
setError(null);
}}
/>
) : null}
<input
type="password"
placeholder="password"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
value={password}
onChange={(event) => {
setPassword(event.target.value);
}}
/>
{error !== null ? <p className="auth-error">{error}</p> : null}
<button type="submit" disabled={busy}>
{mode === 'login' ? 'Log in' : 'Register'}
</button>
</form>
<button
type="button"
className="auth-toggle"
onClick={() => {
setMode(mode === 'login' ? 'register' : 'login');
setError(null);
}}
>
{mode === 'login' ? 'Need an account? Register' : 'Have an account? Log in'}
</button>
>
{mode === 'login' ? 'Sign up' : 'Log in'}
</button>
</p>
</div>
</main>
);
};

View file

@ -1,6 +1,7 @@
import { useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react';
import { ApiError } from '@altricade/core/api';
import { PlusIcon, TrashIcon } from '../../../shared/ui';
import { useContacts } from '../model';
interface Props {
@ -28,48 +29,61 @@ export const ContactsPanel = ({ onStartDirect }: Props): ReactElement => {
};
return (
<div className="contacts">
<h3 className="sidebar-heading">Contacts</h3>
<form
className="sidebar-form"
onSubmit={(event) => {
void submit(event);
}}
>
<div className="compose-block">
<p className="rail-section-title">Contacts</p>
<form className="inline-add" onSubmit={(event) => void submit(event)}>
<input
placeholder="add contact username"
className="field"
placeholder="Add by username"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
<button type="submit">Add</button>
<button type="submit" className="icon-btn accent" aria-label="Add contact">
<PlusIcon size={18} />
</button>
</form>
<ul className="contact-list">
{contacts.map((contact) => (
<li key={contact.userId}>
<button
type="button"
onClick={() => {
void onStartDirect(contact.user.username);
}}
>
@{contact.user.username}
</button>
<button
type="button"
className="contact-remove"
aria-label="remove contact"
onClick={() => {
void remove(contact.userId);
}}
>
</button>
</li>
))}
</ul>
{error !== null ? <p className="auth-error">{error}</p> : null}
{error !== null ? <p className="form-error">{error}</p> : null}
{contacts.length === 0 ? (
<p className="rail-empty">No contacts yet</p>
) : (
<ul className="contact-list">
{contacts.map((contact) => (
<li key={contact.userId} className="contact-row">
<button
type="button"
className="contact-open"
onClick={() => {
void onStartDirect(contact.user.username);
}}
>
{contact.user.avatarUrl !== null ? (
<img src={contact.user.avatarUrl} alt="" className="avatar contact-avatar" />
) : (
<span className="avatar contact-avatar avatar-placeholder">
{contact.user.displayName.charAt(0)}
</span>
)}
<span className="contact-names">
<span className="contact-name">{contact.user.displayName}</span>
<span className="muted contact-handle">@{contact.user.username}</span>
</span>
</button>
<button
type="button"
className="icon-btn contact-remove"
aria-label="Remove contact"
onClick={() => {
void remove(contact.userId);
}}
>
<TrashIcon size={17} />
</button>
</li>
))}
</ul>
)}
</div>
);
};

View file

@ -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<string, boolean>;
startDirect: (username: string) => Promise<Conversation>;
createGroupChat: (title: string, members: string[]) => Promise<Conversation>;
/** Clear history for me — the chat stays listed with an empty scrollback. */
clearChat: (conversationId: string) => Promise<void>;
/** Delete chat for me — removed from the list until new activity arrives. */
deleteChat: (conversationId: string) => Promise<void>;
}
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<void> => {
await clearConversation(apiConfig, conversationId);
setConversations((prev) =>
prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c)),
);
}, []);
const deleteChat = useCallback(async (conversationId: string): Promise<void> => {
// 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 };
};

View file

@ -1,38 +1,178 @@
import { useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react';
import type { Conversation, PublicUser } from '@altricade/core';
import { searchUsers, ApiError } from '@altricade/core/api';
import type { ChatFolder, Conversation, PublicUser } from '@altricade/core';
import { searchUsers } from '@altricade/core/api';
import { apiConfig } from '../../../shared/api';
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<string, boolean>;
folders: UseFolders;
activeFolderId: string | null;
onSelectFolder: (folderId: string | null) => void;
onSelect: (conversation: Conversation) => void;
onStartDirect: (username: string) => Promise<void>;
onCreateGroup: (title: string, members: string[]) => Promise<void>;
onClearChat: (conversationId: string) => Promise<void>;
onDeleteChat: (conversationId: string) => Promise<void>;
}
const label = (conversation: Conversation): string => {
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';
}
return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`;
return conversation.peer === null ? 'Direct' : conversation.peer.displayName;
};
const subtitle = (conversation: Conversation): string => {
if (conversation.type === 'group') {
return 'Group';
}
return conversation.peer === null ? '' : `@${conversation.peer.username}`;
};
const initial = (conversation: Conversation): string => {
const source =
conversation.type === 'group'
? (conversation.title ?? 'G')
: (conversation.peer?.displayName ?? conversation.peer?.username ?? '?');
return source.charAt(0);
};
// Time as HH:MM today, weekday this week, else DD.MM.YY.
const formatTime = (iso: string): string => {
const then = new Date(iso);
const now = new Date();
const sameDay = then.toDateString() === now.toDateString();
if (sameDay) {
return then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const dayMs = 86_400_000;
if (now.getTime() - then.getTime() < 6 * dayMs) {
return then.toLocaleDateString([], { weekday: 'short' });
}
return then.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: '2-digit' });
};
const Avatar = ({ conversation }: { conversation: Conversation }): ReactElement => {
const peer = conversation.peer;
if (peer !== null && peer.avatarUrl !== null) {
return <img src={peer.avatarUrl} alt="" className="avatar conv-avatar" />;
}
return (
<span className="avatar conv-avatar avatar-placeholder">
{conversation.type === 'group' ? <UsersIcon size={20} /> : initial(conversation)}
</span>
);
};
// 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 (
<div className="dialog-backdrop" onClick={onClose} role="presentation">
<div
className="dialog"
role="dialog"
aria-modal="true"
aria-label={heading}
onClick={(event) => {
event.stopPropagation();
}}
>
<h3 className="dialog-title">{heading}</h3>
<form className="dialog-form" onSubmit={submit}>
<input
className="field"
autoFocus
placeholder="Folder name"
value={value}
maxLength={64}
onChange={(event) => {
setValue(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
/>
<div className="dialog-actions">
<button type="submit" className="dialog-btn" disabled={value.trim() === ''}>
{submitLabel}
</button>
<button type="button" className="dialog-btn dialog-cancel" onClick={onClose}>
Cancel
</button>
</div>
</form>
</div>
</div>
);
};
export const ConversationSidebar = ({
conversations,
currentId,
onlineMap,
folders,
activeFolderId,
onSelectFolder,
onSelect,
onStartDirect,
onCreateGroup,
onClearChat,
onDeleteChat,
}: Props): ReactElement => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<PublicUser[]>([]);
const [groupTitle, setGroupTitle] = useState('');
const [groupMembers, setGroupMembers] = useState('');
const [error, setError] = useState<string | null>(null);
const [dialog, setDialog] = useState<SidebarDialog | null>(null);
const chatMenu = useContextMenu<Conversation>();
const folderMenu = useContextMenu<ChatFolder>();
const runSearch = async (value: string): Promise<void> => {
setQuery(value);
@ -48,113 +188,391 @@ export const ConversationSidebar = ({
};
const start = async (username: string): Promise<void> => {
setError(null);
try {
await onStartDirect(username);
setQuery('');
setResults([]);
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : 'Could not start chat');
}
await onStartDirect(username);
setQuery('');
setResults([]);
};
const submitGroup = async (event: SyntheticEvent): Promise<void> => {
event.preventDefault();
const title = groupTitle.trim();
if (title === '') {
return;
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);
}
const members = groupMembers
.split(',')
.map((name) => name.trim())
.filter((name) => name.length > 0);
setError(null);
try {
await onCreateGroup(title, members);
setGroupTitle('');
setGroupMembers('');
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : 'Could not create group');
if (pinA !== null || pinB !== null) {
return pinA !== null ? -1 : 1;
}
};
return b.lastMessageAt.localeCompare(a.lastMessageAt);
});
return (
<aside className="sidebar">
<input
className="search-input"
placeholder="Search users to chat…"
value={query}
onChange={(event) => {
void runSearch(event.target.value);
}}
/>
{results.length > 0 ? (
<ul className="search-results">
{results.map((user) => (
<li key={user.id}>
<button
type="button"
onClick={() => {
void start(user.username);
}}
>
{user.displayName} <span className="muted">@{user.username}</span>
</button>
</li>
<>
<div className="rail-search">
<SearchIcon size={18} className="rail-search-icon" />
<input
className="rail-search-input"
placeholder="Search people…"
value={query}
onChange={(event) => {
void runSearch(event.target.value);
}}
/>
{searching ? (
<button
type="button"
className="rail-search-clear"
aria-label="Clear search"
onClick={() => {
setQuery('');
setResults([]);
}}
>
<CloseIcon size={16} />
</button>
) : null}
</div>
{!searching ? (
<div className="folder-tabs" role="tablist">
<button
type="button"
role="tab"
className="folder-tab"
aria-selected={activeFolderId === null}
onClick={() => {
onSelectFolder(null);
}}
>
All
</button>
{folders.folders.map((folder) => (
<button
key={folder.id}
type="button"
role="tab"
className="folder-tab"
aria-selected={activeFolderId === folder.id}
onClick={() => {
onSelectFolder(folder.id);
}}
onContextMenu={(event) => {
folderMenu.openAt(event, folder);
}}
>
{folder.title}
</button>
))}
</ul>
<button
type="button"
className="folder-tab folder-add"
aria-label="New folder"
title="New folder"
onClick={() => {
setDialog({ kind: 'create-folder' });
}}
>
<PlusIcon size={15} />
</button>
</div>
) : null}
<h3 className="sidebar-heading">Conversations</h3>
<ul className="room-list">
{conversations.map((conversation) => {
const online =
conversation.peer !== null && onlineMap[conversation.peer.id] === true;
return (
<li key={conversation.id}>
<button
type="button"
aria-pressed={conversation.id === currentId}
<div className="rail-scroll">
{searching ? (
<div className="rail-section">
<p className="rail-section-title">People</p>
{results.length === 0 ? (
<p className="rail-empty">No matches</p>
) : (
results.map((user) => (
<button
key={user.id}
type="button"
className="conv-row"
onClick={() => {
void start(user.username);
}}
>
{user.avatarUrl !== null ? (
<img src={user.avatarUrl} alt="" className="avatar conv-avatar" />
) : (
<span className="avatar conv-avatar avatar-placeholder">
{user.displayName.charAt(0)}
</span>
)}
<span className="conv-main">
<span className="conv-top">
<span className="conv-name">{user.displayName}</span>
</span>
<span className="conv-sub">
<span className="conv-preview">@{user.username}</span>
</span>
</span>
</button>
))
)}
</div>
) : (
<div className="conv-list">
{sorted.length === 0 ? (
<p className="rail-empty">
{activeFolder === null ? 'No conversations yet' : 'This folder is empty'}
</p>
) : (
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 (
<button
key={conversation.id}
type="button"
className="conv-row"
aria-pressed={active}
onClick={() => {
onSelect(conversation);
}}
onContextMenu={(event) => {
chatMenu.openAt(event, conversation);
}}
>
<span className="conv-avatar-wrap">
<Avatar conversation={conversation} />
{online ? <span className="online-dot" aria-label="online" /> : null}
</span>
<span className="conv-main">
<span className="conv-top">
<span className="conv-name">{title(conversation)}</span>
<span className="conv-time">{formatTime(conversation.lastMessageAt)}</span>
</span>
<span className="conv-sub">
<span className="conv-preview">{subtitle(conversation)}</span>
<span className="conv-badges">
{pinned ? <PinIcon size={14} className="pin-mark" /> : null}
{conversation.unreadCount > 0 ? (
<span className="unread-badge">{conversation.unreadCount}</span>
) : null}
</span>
</span>
</span>
</button>
);
})
)}
</div>
)}
</div>
{chatMenu.state !== null ? (
<ContextMenu x={chatMenu.state.x} y={chatMenu.state.y} onClose={chatMenu.close}>
<MenuItem
icon={
folders.isPinned(chatMenu.state.payload.id, activeFolderId) ? (
<PinOffIcon size={17} />
) : (
<PinIcon size={17} />
)
}
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 ? <MenuSeparator /> : null}
{folders.folders.map((folder) => {
const target = chatMenu.state?.payload;
const member = target !== undefined && folder.chatIds.includes(target.id);
return (
<MenuItem
key={folder.id}
icon={member ? <CheckIcon size={17} /> : <FolderIcon size={17} />}
label={member ? `Remove from ${folder.title}` : `Add to ${folder.title}`}
onClick={() => {
onSelect(conversation);
chatMenu.close();
if (target !== undefined) {
void folders.toggleChat(folder.id, target.id);
}
}}
>
{online ? <span className="online-dot" aria-label="online" /> : null}
{label(conversation)}
{conversation.unreadCount > 0 ? (
<span className="unread-badge">{conversation.unreadCount}</span>
) : null}
</button>
</li>
);
})}
</ul>
/>
);
})}
<MenuSeparator />
<MenuItem
icon={<EraserIcon size={17} />}
label="Clear history"
onClick={() => {
const target = chatMenu.state?.payload;
chatMenu.close();
if (target !== undefined) {
setDialog({ kind: 'clear-chat', conversation: target });
}
}}
/>
<MenuItem
icon={<TrashIcon size={17} />}
label="Delete chat"
danger
onClick={() => {
const target = chatMenu.state?.payload;
chatMenu.close();
if (target !== undefined) {
setDialog({ kind: 'delete-chat', conversation: target });
}
}}
/>
</ContextMenu>
) : null}
<form
className="sidebar-form group-form"
onSubmit={(event) => {
void submitGroup(event);
}}
>
<h3 className="sidebar-heading">New group</h3>
<input
placeholder="group title"
value={groupTitle}
onChange={(event) => {
setGroupTitle(event.target.value);
{folderMenu.state !== null ? (
<ContextMenu x={folderMenu.state.x} y={folderMenu.state.y} onClose={folderMenu.close}>
<MenuItem
icon={<EditIcon size={17} />}
label="Rename folder"
onClick={() => {
const target = folderMenu.state?.payload;
folderMenu.close();
if (target !== undefined) {
setDialog({ kind: 'rename-folder', folder: target });
}
}}
/>
<MenuItem
icon={<TrashIcon size={17} />}
label="Delete folder"
danger
onClick={() => {
const target = folderMenu.state?.payload;
folderMenu.close();
if (target !== undefined) {
setDialog({ kind: 'delete-folder', folder: target });
}
}}
/>
</ContextMenu>
) : null}
{dialog?.kind === 'create-folder' ? (
<NameDialog
heading="New folder"
initialValue=""
submitLabel="Create"
onSubmit={(value) => {
setDialog(null);
void folders.create(value);
}}
onClose={() => {
setDialog(null);
}}
/>
<input
placeholder="members (comma-separated usernames)"
value={groupMembers}
onChange={(event) => {
setGroupMembers(event.target.value);
) : null}
{dialog?.kind === 'rename-folder' ? (
<NameDialog
heading="Rename folder"
initialValue={dialog.folder.title}
submitLabel="Save"
onSubmit={(value) => {
const folderId = dialog.folder.id;
setDialog(null);
void folders.rename(folderId, value);
}}
onClose={() => {
setDialog(null);
}}
/>
<button type="submit">Create group</button>
</form>
) : null}
{error !== null ? <p className="auth-error">{error}</p> : null}
</aside>
{dialog?.kind === 'delete-folder' ? (
<ConfirmDialog
title={`Delete folder “${dialog.folder.title}”?`}
body={<p className="muted">Chats in it are not deleted.</p>}
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' ? (
<ConfirmDialog
title={`Clear history with ${title(dialog.conversation)}?`}
body={<p className="muted">Messages are removed for you only.</p>}
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' ? (
<ConfirmDialog
title={`Delete chat with ${title(dialog.conversation)}?`}
body={<p className="muted">The chat is removed for you only and returns on new activity.</p>}
actions={[
{
key: 'delete',
label: 'Delete chat',
danger: true,
onSelect: () => {
const conversationId = dialog.conversation.id;
setDialog(null);
void onDeleteChat(conversationId);
},
},
]}
onClose={() => {
setDialog(null);
}}
/>
) : null}
</>
);
};

View file

@ -0,0 +1,2 @@
export { useFolders } from './model';
export type { UseFolders } from './model';

View file

@ -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<void>;
rename: (folderId: string, title: string) => Promise<void>;
removeFolder: (folderId: string) => Promise<void>;
/** Add/remove a chat from a folder (toggle). */
toggleChat: (folderId: string, conversationId: string) => Promise<void>;
/** Pin/unpin a chat within a scope (folderId null = "All chats"). */
togglePin: (conversationId: string, folderId: string | null) => Promise<void>;
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<FoldersState>(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<void> => {
setState(await createFolder(apiConfig, { title }));
}, []);
const rename = useCallback(async (folderId: string, title: string): Promise<void> => {
setState(await updateFolder(apiConfig, folderId, { title }));
}, []);
const removeFolder = useCallback(async (folderId: string): Promise<void> => {
setState(await deleteFolder(apiConfig, folderId));
}, []);
const toggleChat = useCallback(
async (folderId: string, conversationId: string): Promise<void> => {
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<void> => {
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,
};
};

View file

@ -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';

View file

@ -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<void>;
sendMedia: (file: File, caption: string) => Promise<void>;
sendMedia: (file: File, caption: string, kindOverride?: MediaKind) => Promise<void>;
edit: (messageId: string, content: string) => Promise<void>;
/** Delete for everyone (sender, or group owner as moderation). */
remove: (messageId: string) => Promise<void>;
/** Delete for me only. */
hide: (messageId: string) => Promise<void>;
toggleReaction: (message: Message, emoji: string) => Promise<void>;
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<void> => {
@ -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<void> => {
async (file: File, caption: string, kindOverride?: MediaKind): Promise<void> => {
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<void> => {
// 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<void> => {
const mine = message.reactions.some((r) => r.emoji === emoji && r.mine);
@ -314,6 +355,7 @@ export const useConversationMessages = (
sendMedia,
edit,
remove,
hide,
toggleReaction,
notifyTyping,
};

View file

@ -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<RecordKind | null>(null);
const [elapsedMs, setElapsedMs] = useState(0);
const [previewStream, setPreviewStream] = useState<MediaStream | null>(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);

View file

@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react';
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { KeyboardEvent, ReactElement, SyntheticEvent } from 'react';
import type { Conversation, Message, PublicUser } from '@altricade/core';
import { getMediaUrl } from '@altricade/core/api';
import { apiConfig } from '../../../shared/api';
import {
PaperclipIcon,
MicIcon,
@ -10,10 +8,34 @@ import {
StopIcon,
SendIcon,
CloseIcon,
FileIcon,
CheckIcon,
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 } from './MediaMessage';
import { MediaViewerProvider } from './MediaViewer';
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<string, number>();
const formatElapsed = (ms: number): string => {
const total = Math.floor(ms / 1000);
@ -22,73 +44,113 @@ const formatElapsed = (ms: number): string => {
return `${String(minutes)}:${seconds < 10 ? '0' : ''}${String(seconds)}`;
};
const clock = (iso: string): string =>
new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const dayKey = (iso: string): string => new Date(iso).toDateString();
const dayLabel = (iso: string): string => {
const then = new Date(iso);
const now = new Date();
if (then.toDateString() === now.toDateString()) {
return 'Today';
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (then.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
}
return then.toLocaleDateString([], { day: 'numeric', month: 'long' });
};
// Two messages belong to the same visual group when they share a sender and
// day and land within the grouping window (order-agnostic).
const inSameGroup = (base: Message, other: Message | undefined): boolean => {
if (other === undefined) {
return false;
}
return (
other.senderId === base.senderId &&
dayKey(other.createdAt) === dayKey(base.createdAt) &&
Math.abs(new Date(base.createdAt).getTime() - new Date(other.createdAt).getTime()) <
GROUP_WINDOW_MS
);
};
interface Props {
conversation: Conversation;
me: PublicUser;
onlineMap: Record<string, boolean>;
/** 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 QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉'];
const headerTitle = (conversation: Conversation): string => {
if (conversation.type === 'group') {
return conversation.title ?? 'Group';
}
return conversation.peer === null ? 'Direct' : `@${conversation.peer.username}`;
if (conversation.peer === null) {
return 'Direct';
}
return conversation.peer.displayName;
};
const MediaView = ({
conversationId,
message,
const ChatHeader = ({
conversation,
online,
typing,
onOpenProfile,
onBack,
}: {
conversationId: string;
message: Message;
}): ReactElement | null => {
const [url, setUrl] = useState<string | null>(null);
const media = message.media;
useEffect(() => {
if (media === null) {
return undefined;
}
let cancelled = false;
getMediaUrl(apiConfig, conversationId, message.id)
.then((resolved) => {
if (!cancelled) {
setUrl(resolved);
}
})
.catch(() => {
/* media may be unavailable */
});
return () => {
cancelled = true;
};
}, [conversationId, message.id, media]);
if (media === null) {
return null;
}
if (url === null) {
return <span className="muted">loading media</span>;
}
if (media.kind === 'image') {
return <img src={url} alt={media.name} className="media-img" />;
}
if (media.kind === 'video') {
return <video src={url} controls className="media-video" />;
}
if (media.kind === 'voice') {
return <audio src={url} controls />;
}
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 openable = peer !== null && onOpenProfile !== undefined;
return (
<a href={url} download={media.name} className="media-file">
<FileIcon size={16} />
<span>{media.name}</span>
</a>
<header className="chat-header">
{onBack !== undefined ? (
<button type="button" className="icon-btn chat-back" aria-label="Back" onClick={onBack}>
<ChevronLeftIcon />
</button>
) : null}
<button
type="button"
className="chat-header-main"
disabled={!openable}
onClick={() => {
if (peer !== null) {
onOpenProfile?.(peer);
}
}}
>
{peer !== null && peer.avatarUrl !== null ? (
<img src={peer.avatarUrl} alt="" className="avatar chat-header-avatar" />
) : (
<span className="avatar chat-header-avatar avatar-placeholder">
{isGroup ? <UsersIcon size={20} /> : headerTitle(conversation).charAt(0)}
</span>
)}
<span className="chat-header-text">
<span className="chat-header-title">{headerTitle(conversation)}</span>
<span className={typing ? 'chat-header-sub typing-sub' : 'chat-header-sub'}>
{!isGroup && online && !typing ? <span className="online-inline" /> : null}
{subtitle}
</span>
</span>
</button>
</header>
);
};
export const ChatView = ({ conversation, me }: Props): ReactElement => {
export const ChatView = ({ conversation, me, onlineMap, onOpenProfile, onBack }: Props): ReactElement => {
const {
messages,
loading,
@ -98,14 +160,28 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
sendMedia,
edit,
remove,
hide,
toggleReaction,
notifyTyping,
} = useConversationMessages(conversation, me);
const [text, setText] = useState('');
const recorder = useRecorder((file) => {
void sendMedia(file, '');
const [editing, setEditing] = useState<Message | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Message | null>(null);
const menu = useContextMenu<Message>();
const recorder = useRecorder((file, kind) => {
void sendMedia(file, '', kind === 'video' ? 'video_note' : 'voice');
});
const previewRef = useRef<HTMLVideoElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(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(() => {
@ -115,16 +191,126 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
}
}, [recorder.previewStream]);
// 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 && 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 || !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<void> => {
event.preventDefault();
const trimmed = text.trim();
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);
};
const onComposerKey = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submit(event);
}
if (event.key === 'Escape' && editing !== null) {
cancelEdit();
}
};
const onAttach = (event: SyntheticEvent<HTMLInputElement>): void => {
const input = event.currentTarget;
const file = input.files?.[0];
@ -134,167 +320,369 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => {
input.value = '';
};
const onEdit = (message: Message): void => {
const next = window.prompt('Edit message', message.content);
if (next !== null && next.trim() !== '') {
void edit(message.id, next.trim());
const 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 readMark = (message: Message): string => {
const tick = (message: Message): ReactElement | null => {
if (conversation.type !== 'direct' || message.senderId !== me.id || message.deletedAt !== null) {
return '';
return null;
}
return peerReadSeq >= message.seq ? '✓✓' : '✓';
const read = peerReadSeq >= message.seq;
return (
<span className={read ? 'tick tick-read' : 'tick'}>
{read ? <DoubleCheckIcon size={15} /> : <CheckIcon size={15} />}
</span>
);
};
return (
<section className="chat">
<h2 className="chat-title">{headerTitle(conversation)}</h2>
{loading ? <p className="chat-loading">Loading</p> : null}
<ul className="message-list">
{messages.map((message) => (
<li key={message.id} className={message.senderId === me.id ? 'mine' : ''}>
<div className="msg-row">
<span className="msg-author">@{message.sender.username}</span>
{message.deletedAt === null && message.media !== null ? (
<MediaView conversationId={conversation.id} message={message} />
) : null}
{message.content.length > 0 ? (
<span className="msg-body">
{message.deletedAt !== null ? (
<em className="muted">message deleted</em>
) : (
message.content
)}
</span>
) : null}
{message.deletedAt !== null && message.media === null && message.content.length === 0 ? (
<em className="muted">message deleted</em>
) : null}
{message.editedAt !== null && message.deletedAt === null ? (
<span className="muted"> (edited)</span>
) : null}
<span className="read-mark">{readMark(message)}</span>
</div>
{message.deletedAt === null ? (
<div className="msg-actions">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
className="react-btn"
onClick={() => {
void toggleReaction(message, emoji);
}}
>
{emoji}
</button>
))}
{message.senderId === me.id ? (
<>
<button type="button" className="link-btn" onClick={() => { onEdit(message); }}>
edit
</button>
<button
type="button"
className="link-btn"
onClick={() => {
void remove(message.id);
}}
>
delete
</button>
</>
) : null}
</div>
) : null}
{message.reactions.length > 0 ? (
<div className="reactions">
{message.reactions.map((r) => (
<button
key={r.emoji}
type="button"
className={r.mine ? 'reaction mine-reaction' : 'reaction'}
onClick={() => {
void toggleReaction(message, r.emoji);
}}
>
{r.emoji} {r.count}
</button>
))}
</div>
) : null}
</li>
))}
</ul>
{typingUserIds.length > 0 ? <p className="typing muted">typing</p> : null}
{recorder.recording !== null ? (
<div className="composer recording-bar">
{recorder.recording === 'video' ? (
<video ref={previewRef} className="record-preview" autoPlay muted playsInline />
) : null}
<span className="record-dot" aria-hidden="true" />
<span className="record-label">
{recorder.recording === 'video' ? 'Recording video' : 'Recording voice'} ·{' '}
{formatElapsed(recorder.elapsedMs)}
</span>
<button type="button" className="icon-btn" title="Cancel" onClick={recorder.cancel}>
<CloseIcon />
</button>
<button
type="button"
className="icon-btn send"
title="Stop and send"
onClick={recorder.finish}
>
<StopIcon />
</button>
<MediaViewerProvider>
<section className="chat-pane">
<ChatHeader
conversation={conversation}
online={peerOnline}
typing={typingUserIds.length > 0}
onOpenProfile={onOpenProfile}
onBack={onBack}
/>
<div className="message-scroll" ref={scrollRef} onScroll={onScroll}>
{loading ? <p className="chat-loading muted">Loading</p> : null}
<div className="message-list" ref={listRef}>
{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 bubbleClass = [
'bubble',
mine ? 'bubble-mine' : 'bubble-theirs',
groupStart ? 'is-start' : '',
groupEnd ? 'is-end' : '',
bareMedia ? 'bubble-bare' : '',
hasMedia ? 'has-media' : '',
]
.filter((token) => token !== '')
.join(' ');
return (
<Fragment key={message.id}>
{newDay ? (
<div className="day-sep">
<span>{dayLabel(message.createdAt)}</span>
</div>
) : null}
<div className={lineClass}>
{showAvatar ? (
groupEnd ? (
<button
type="button"
className="msg-avatar-btn"
aria-label={`Open ${message.sender.displayName}'s profile`}
onClick={() => {
onOpenProfile?.(message.sender);
}}
>
{message.sender.avatarUrl !== null ? (
<img src={message.sender.avatarUrl} alt="" className="avatar msg-avatar" />
) : (
<span className="avatar msg-avatar avatar-placeholder">
{message.sender.displayName.charAt(0)}
</span>
)}
</button>
) : (
<span className="msg-avatar-spacer" />
)
) : null}
<div className="bubble-wrap">
{showAvatar && groupStart && !deleted ? (
<span className="msg-sender">{message.sender.displayName}</span>
) : null}
<div
className={bubbleClass}
onContextMenu={
deleted
? undefined
: (event) => {
menu.openAt(event, message);
}
}
>
{deleted ? (
<span className="bubble-deleted">Message deleted</span>
) : (
<>
{media !== null ? (
<MediaMessage
conversationId={conversation.id}
message={message}
mine={mine}
/>
) : null}
{message.content.length > 0 ? (
<span className="bubble-text">{message.content}</span>
) : null}
<span className="bubble-meta">
{message.editedAt !== null ? (
<span className="bubble-edited">edited</span>
) : null}
<span className="bubble-time">{clock(message.createdAt)}</span>
{tick(message)}
</span>
</>
)}
</div>
{message.reactions.length > 0 ? (
<div className="reactions">
{message.reactions.map((reaction) => (
<button
key={reaction.emoji}
type="button"
className={reaction.mine ? 'reaction is-mine' : 'reaction'}
onClick={() => {
void toggleReaction(message, reaction.emoji);
}}
>
<span className="reaction-emoji">{reaction.emoji}</span>
<span className="reaction-count">{reaction.count}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</Fragment>
);
})}
</div>
</div>
) : (
<form
className="composer"
onSubmit={(event) => {
void submit(event);
}}
>
<label className="icon-btn" title="Attach file">
<PaperclipIcon />
<input type="file" hidden onChange={onAttach} />
</label>
{showJump ? (
<button
type="button"
className="icon-btn"
title="Record voice message"
onClick={() => {
recorder.start('voice');
}}
className="jump-latest"
aria-label="Scroll to newest message"
onClick={jumpToLatest}
>
<MicIcon />
<ChevronDownIcon size={22} />
</button>
<button
type="button"
className="icon-btn"
title="Record video message"
onClick={() => {
recorder.start('video');
}}
>
<VideoIcon />
</button>
<input
value={text}
placeholder="Write a message…"
onChange={(event) => {
setText(event.target.value);
notifyTyping();
) : null}
{recorder.recording !== null ? (
<div className="composer recording-bar">
{recorder.recording === 'video' ? (
<video ref={previewRef} className="record-preview" autoPlay muted playsInline />
) : null}
<span className="record-dot" aria-hidden="true" />
<span className="record-label">
{recorder.recording === 'video' ? 'Recording video' : 'Recording voice'} ·{' '}
{formatElapsed(recorder.elapsedMs)}
</span>
<button type="button" className="icon-btn" title="Cancel" onClick={recorder.cancel}>
<CloseIcon />
</button>
<button
type="button"
className="icon-btn accent"
title="Stop and send"
onClick={recorder.finish}
>
<StopIcon />
</button>
</div>
) : (
<div className="composer-area">
{editing !== null ? (
<div className="edit-banner">
<EditIcon size={18} className="edit-banner-icon" />
<div className="edit-banner-text">
<span className="edit-banner-title">Edit message</span>
<span className="edit-banner-preview">{editing.content}</span>
</div>
<button
type="button"
className="icon-btn"
aria-label="Cancel edit"
onClick={cancelEdit}
>
<CloseIcon />
</button>
</div>
) : null}
<form className="composer" onSubmit={(event) => void submit(event)}>
<label className="icon-btn" title="Attach file">
<PaperclipIcon />
<input type="file" hidden onChange={onAttach} />
</label>
<textarea
ref={composerRef}
className="composer-input"
rows={1}
value={text}
placeholder={editing !== null ? 'Edit message…' : 'Message…'}
onChange={(event) => {
setText(event.target.value);
if (editing === null) {
notifyTyping();
}
}}
onKeyDown={onComposerKey}
/>
{editing === null ? (
<>
<button
type="button"
className="icon-btn"
title="Record voice message"
onClick={() => {
recorder.start('voice');
}}
>
<MicIcon />
</button>
<button
type="button"
className="icon-btn"
title="Record video message"
onClick={() => {
recorder.start('video');
}}
>
<VideoIcon />
</button>
</>
) : null}
<button
type="submit"
className="icon-btn accent composer-send"
title={editing !== null ? 'Save' : 'Send'}
disabled={text.trim() === ''}
>
{editing !== null ? <CheckIcon /> : <SendIcon />}
</button>
</form>
</div>
)}
{menu.state !== null ? (
<ContextMenu x={menu.state.x} y={menu.state.y} onClose={menu.close}>
<div className="ctx-reactions">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
className="ctx-react"
aria-label={`React ${emoji}`}
onClick={() => {
const target = menu.state?.payload;
menu.close();
if (target !== undefined) {
void toggleReaction(target, emoji);
}
}}
>
{emoji}
</button>
))}
</div>
<MenuSeparator />
{menu.state.payload.content.length > 0 ? (
<MenuItem
icon={<CopyIcon size={17} />}
label="Copy text"
onClick={() => {
const target = menu.state?.payload;
menu.close();
if (target !== undefined) {
void navigator.clipboard.writeText(target.content);
}
}}
/>
) : null}
{menu.state.payload.senderId === me.id ? (
<MenuItem
icon={<EditIcon size={17} />}
label="Edit"
onClick={() => {
const target = menu.state?.payload;
menu.close();
if (target !== undefined) {
startEdit(target);
}
}}
/>
) : null}
<MenuItem
icon={<TrashIcon size={17} />}
label="Delete"
danger
onClick={() => {
const target = menu.state?.payload;
menu.close();
if (target !== undefined) {
setDeleteTarget(target);
}
}}
/>
</ContextMenu>
) : null}
{deleteTarget !== null ? (
<ConfirmDialog
title="Delete message"
body={<p className="muted">This cannot be undone.</p>}
actions={deleteActions(deleteTarget)}
onClose={() => {
setDeleteTarget(null);
}}
/>
<button type="submit" className="icon-btn send" title="Send">
<SendIcon />
</button>
</form>
)}
</section>
) : null}
</section>
</MediaViewerProvider>
);
};

View file

@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import type { Message } from '@altricade/core';
import { getMediaUrl } from '@altricade/core/api';
import { apiConfig } from '../../../shared/api';
import { FileIcon, DownloadIcon, PlayIcon } from '../../../shared/ui';
import { VoiceMessage } from './VoiceMessage';
import { VideoMessage } from './VideoMessage';
import { useMediaViewer } from './MediaViewer';
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${String(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
interface Props {
conversationId: string;
message: Message;
mine: boolean;
}
export const MediaMessage = ({ conversationId, message, mine }: Props): ReactElement | null => {
const [url, setUrl] = useState<string | null>(null);
const viewer = useMediaViewer();
const media = message.media;
useEffect(() => {
if (media === null) {
return undefined;
}
let cancelled = false;
getMediaUrl(apiConfig, conversationId, message.id)
.then((resolved) => {
if (!cancelled) {
setUrl(resolved);
}
})
.catch(() => {
/* media may be unavailable */
});
return () => {
cancelled = true;
};
}, [conversationId, message.id, media]);
if (media === null) {
return null;
}
if (url === null) {
return <span className="media-loading">Loading</span>;
}
if (media.kind === 'voice') {
return (
<VoiceMessage url={url} seed={message.id} durationSec={media.durationSec} mine={mine} />
);
}
if (media.kind === 'video_note') {
return <VideoMessage url={url} durationSec={media.durationSec} />;
}
if (media.kind === 'video') {
return (
<button
type="button"
className="media-video-frame"
onClick={() => {
viewer.open({ type: 'video', url, name: media.name });
}}
>
<video src={url} className="media-video" preload="metadata" muted />
<span className="media-play-overlay">
<PlayIcon size={24} />
</span>
</button>
);
}
if (media.kind === 'image') {
return (
<button
type="button"
className="media-image-btn"
onClick={() => {
viewer.open({ type: 'image', url, name: media.name });
}}
>
<img src={url} alt={media.name} className="media-img" />
</button>
);
}
return (
<a
href={url}
download={media.name}
className={mine ? 'media-file media-file-mine' : 'media-file'}
>
<span className="media-file-icon">
<FileIcon size={20} />
</span>
<span className="media-file-meta">
<span className="media-file-name">{media.name}</span>
<span className="media-file-size">{formatSize(media.size)}</span>
</span>
<DownloadIcon size={18} className="media-file-dl" />
</a>
);
};

View file

@ -0,0 +1,173 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { CloseIcon, DownloadIcon } from '../../../shared/ui';
export interface ViewerItem {
type: 'image' | 'video';
url: string;
name: string;
}
interface ViewerContextValue {
open: (item: ViewerItem) => void;
}
const ViewerContext = createContext<ViewerContextValue | null>(null);
export const useMediaViewer = (): ViewerContextValue => {
const context = useContext(ViewerContext);
if (context === null) {
throw new Error('useMediaViewer must be used within a MediaViewerProvider');
}
return context;
};
interface Point {
x: number;
y: number;
}
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const Overlay = ({ item, onClose }: { item: ViewerItem; onClose: () => void }): ReactElement => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
const dragging = useRef<{ startX: number; startY: number; baseX: number; baseY: number } | null>(
null,
);
const reset = useCallback((): void => {
setScale(1);
setOffset({ x: 0, y: 0 });
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
};
}, [onClose]);
const onWheel = (event: React.WheelEvent): void => {
if (item.type !== 'image') {
return;
}
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale - event.deltaY * 0.0015));
setScale(nextScale);
if (nextScale === 1) {
setOffset({ x: 0, y: 0 });
}
};
const onPointerDown = (event: React.PointerEvent): void => {
if (scale <= 1) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
dragging.current = {
startX: event.clientX,
startY: event.clientY,
baseX: offset.x,
baseY: offset.y,
};
};
const onPointerMove = (event: React.PointerEvent): void => {
const state = dragging.current;
if (state === null) {
return;
}
setOffset({
x: state.baseX + (event.clientX - state.startX),
y: state.baseY + (event.clientY - state.startY),
});
};
const onPointerUp = (): void => {
dragging.current = null;
};
return (
<div className="viewer" role="dialog" aria-modal="true" onClick={onClose}>
<div className="viewer-toolbar" onClick={(event) => {
event.stopPropagation();
}}>
<span className="viewer-name">{item.name}</span>
<div className="viewer-actions">
<a
className="icon-btn viewer-btn"
href={item.url}
download={item.name}
aria-label="Download"
onClick={(event) => {
event.stopPropagation();
}}
>
<DownloadIcon />
</a>
<button type="button" className="icon-btn viewer-btn" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
</div>
</div>
<div className="viewer-stage" onClick={(event) => {
event.stopPropagation();
}}>
{item.type === 'image' ? (
<img
src={item.url}
alt={item.name}
className="viewer-img"
draggable={false}
style={{
transform: `translate(${String(offset.x)}px, ${String(offset.y)}px) scale(${String(scale)})`,
cursor: scale > 1 ? 'grab' : 'zoom-in',
}}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={reset}
onClick={() => {
if (scale === 1) {
setScale(2.2);
} else {
reset();
}
}}
/>
) : (
<video src={item.url} className="viewer-video" controls autoPlay />
)}
</div>
</div>
);
};
export const MediaViewerProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [item, setItem] = useState<ViewerItem | null>(null);
const open = useCallback((next: ViewerItem): void => {
setItem(next);
}, []);
const close = useCallback((): void => {
setItem(null);
}, []);
const value = useMemo<ViewerContextValue>(() => ({ open }), [open]);
return (
<ViewerContext.Provider value={value}>
{children}
{item !== null ? createPortal(<Overlay item={item} onClose={close} />, document.body) : null}
</ViewerContext.Provider>
);
};

View file

@ -0,0 +1,70 @@
import { useRef, useState } from 'react';
import type { ReactElement } from 'react';
import { PlayIcon } from '../../../shared/ui';
const formatDuration = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds <= 0) {
return '';
}
const whole = Math.floor(seconds);
const minutes = Math.floor(whole / 60);
const rest = whole % 60;
return `${String(minutes)}:${rest < 10 ? '0' : ''}${String(rest)}`;
};
interface Props {
url: string;
durationSec: number | undefined;
}
// Circular "video message" — tap to play with sound, tap again to pause.
export const VideoMessage = ({ url, durationSec }: Props): ReactElement => {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [remaining, setRemaining] = useState(durationSec ?? 0);
const toggle = (): void => {
const video = videoRef.current;
if (video === null) {
return;
}
if (video.paused) {
void video.play();
} else {
video.pause();
}
};
return (
<button type="button" className={playing ? 'round-video is-playing' : 'round-video'} onClick={toggle}>
<video
ref={videoRef}
src={url}
className="round-video-el"
playsInline
onPlay={() => {
setPlaying(true);
}}
onPause={() => {
setPlaying(false);
}}
onTimeUpdate={(event) => {
const video = event.currentTarget;
setRemaining(Math.max(0, video.duration - video.currentTime));
}}
onEnded={() => {
setPlaying(false);
setRemaining(durationSec ?? 0);
}}
/>
{!playing ? (
<span className="round-video-overlay">
<PlayIcon size={26} />
</span>
) : null}
{formatDuration(remaining) !== '' ? (
<span className="round-video-time">{formatDuration(remaining)}</span>
) : null}
</button>
);
};

View file

@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from 'react';
import type { ReactElement } from 'react';
import { PlayIcon, PauseIcon } from '../../../shared/ui';
const BARS = 44;
const formatDuration = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) {
return '0:00';
}
const whole = Math.floor(seconds);
const minutes = Math.floor(whole / 60);
const rest = whole % 60;
return `${String(minutes)}:${rest < 10 ? '0' : ''}${String(rest)}`;
};
// Real amplitude peaks from the decoded audio, normalized to 0..1.
const computePeaks = (buffer: AudioBuffer, bars: number): number[] => {
const channel = buffer.getChannelData(0);
const blockSize = Math.max(1, Math.floor(channel.length / bars));
const peaks: number[] = [];
for (let index = 0; index < bars; index += 1) {
const start = index * blockSize;
let sum = 0;
for (let offset = 0; offset < blockSize; offset += 1) {
const value = channel[start + offset] ?? 0;
sum += value * value;
}
peaks.push(Math.sqrt(sum / blockSize));
}
const max = Math.max(...peaks, 0.0001);
return peaks.map((peak) => peak / max);
};
// Deterministic stand-in waveform when decoding is unavailable (e.g. Safari/opus).
const pseudoPeaks = (seed: string, bars: number): number[] => {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}
const peaks: number[] = [];
for (let index = 0; index < bars; index += 1) {
hash = (hash * 1103515245 + 12345) & 0x7fffffff;
peaks.push(0.25 + (hash % 1000) / 1000 * 0.7);
}
return peaks;
};
interface Props {
url: string;
seed: string;
durationSec: number | undefined;
mine: boolean;
}
export const VoiceMessage = ({ url, seed, durationSec, mine }: Props): ReactElement => {
const audioRef = useRef<HTMLAudioElement>(null);
const [peaks, setPeaks] = useState<number[]>(() => pseudoPeaks(seed, BARS));
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(durationSec ?? 0);
// Decode the clip once to draw a true waveform; fall back silently.
useEffect(() => {
let cancelled = false;
const AudioCtor = window.AudioContext;
fetch(url)
.then((response) => response.arrayBuffer())
.then((raw) => {
const ctx = new AudioCtor();
return ctx.decodeAudioData(raw).then((buffer) => {
void ctx.close();
return buffer;
});
})
.then((buffer) => {
if (!cancelled) {
setPeaks(computePeaks(buffer, BARS));
setDuration((prev) => (prev > 0 ? prev : buffer.duration));
}
})
.catch(() => {
/* keep the pseudo waveform */
});
return () => {
cancelled = true;
};
}, [url]);
const toggle = (): void => {
const audio = audioRef.current;
if (audio === null) {
return;
}
if (audio.paused) {
void audio.play();
} else {
audio.pause();
}
};
const seek = (event: React.MouseEvent<HTMLDivElement>): void => {
const audio = audioRef.current;
if (audio === null || duration <= 0) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
audio.currentTime = ratio * duration;
setCurrent(audio.currentTime);
};
const progress = duration > 0 ? current / duration : 0;
const elapsed = playing || current > 0 ? current : duration;
return (
<div className={mine ? 'voice voice-mine' : 'voice'}>
<audio
ref={audioRef}
src={url}
preload="metadata"
onPlay={() => {
setPlaying(true);
}}
onPause={() => {
setPlaying(false);
}}
onTimeUpdate={(event) => {
setCurrent(event.currentTarget.currentTime);
}}
onLoadedMetadata={(event) => {
const value = event.currentTarget.duration;
if (Number.isFinite(value) && value > 0) {
setDuration((prev) => (prev > 0 ? prev : value));
}
}}
onEnded={() => {
setPlaying(false);
setCurrent(0);
}}
/>
<button
type="button"
className="voice-play"
aria-label={playing ? 'Pause' : 'Play'}
onClick={toggle}
>
{playing ? <PauseIcon size={18} /> : <PlayIcon size={18} />}
</button>
<div
className="voice-wave"
role="presentation"
onClick={seek}
>
{peaks.map((peak, index) => {
const played = (index + 0.5) / BARS <= progress;
return (
<span
key={index}
className={played ? 'wave-bar is-played' : 'wave-bar'}
style={{ height: `${String(Math.round(Math.max(0.14, peak) * 100))}%` }}
/>
);
})}
</div>
<span className="voice-time">{formatDuration(elapsed)}</span>
</div>
);
};

View file

@ -0,0 +1 @@
export { ProfilePanel } from './ui/ProfilePanel';

View file

@ -0,0 +1,349 @@
import { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import type { Conversation, Message, Presence, PublicUser, MediaTab } from '@altricade/core';
import { getPresence, listConversationMedia, getMediaUrl } from '@altricade/core/api';
import { apiConfig } from '../../../shared/api';
import {
CloseIcon,
ChevronLeftIcon,
FileIcon,
DownloadIcon,
PlayIcon,
SendIcon,
} from '../../../shared/ui';
import { VoiceMessage, MediaViewerProvider, useMediaViewer } from '../../messaging';
const PAGE_SIZE = 30;
interface Props {
user: PublicUser;
/** The DM with this user, when one exists — source of the shared media. */
conversation: Conversation | null;
online: boolean;
onClose: () => void;
/** Open (or start) the DM with this user. */
onMessage: (username: string) => void;
}
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${String(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const lastSeenLabel = (presence: Presence | null, online: boolean): string => {
if (online) {
return 'online';
}
const iso = presence?.lastSeenAt ?? null;
if (iso === null) {
return 'last seen a long time ago';
}
const then = new Date(iso);
const now = new Date();
const time = then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (then.toDateString() === now.toDateString()) {
return `last seen today at ${time}`;
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (then.toDateString() === yesterday.toDateString()) {
return `last seen yesterday at ${time}`;
}
return `last seen ${then.toLocaleDateString([], { day: 'numeric', month: 'short' })}`;
};
// Resolves the presigned URL once, then renders per kind.
const useMediaUrl = (conversationId: string, messageId: string): string | null => {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getMediaUrl(apiConfig, conversationId, messageId)
.then((resolved) => {
if (!cancelled) {
setUrl(resolved);
}
})
.catch(() => {
/* media may be unavailable */
});
return () => {
cancelled = true;
};
}, [conversationId, messageId]);
return url;
};
const MediaCell = ({
conversationId,
message,
}: {
conversationId: string;
message: Message;
}): ReactElement => {
const url = useMediaUrl(conversationId, message.id);
const viewer = useMediaViewer();
const media = message.media;
if (media === null) {
return <span className="media-cell media-cell-empty" />;
}
if (url === null) {
return <span className="media-cell media-cell-empty" aria-hidden="true" />;
}
const isImage = media.kind === 'image';
return (
<button
type="button"
className="media-cell"
aria-label={media.name}
onClick={() => {
viewer.open({ type: isImage ? 'image' : 'video', url, name: media.name });
}}
>
{isImage ? (
<img src={url} alt="" loading="lazy" />
) : (
<>
<video src={url} preload="metadata" muted />
<span className="media-cell-play">
<PlayIcon size={18} />
</span>
</>
)}
</button>
);
};
const FileRow = ({
conversationId,
message,
}: {
conversationId: string;
message: Message;
}): ReactElement | null => {
const url = useMediaUrl(conversationId, message.id);
const media = message.media;
if (media === null) {
return null;
}
return (
<a
className="profile-file"
href={url ?? undefined}
download={media.name}
aria-disabled={url === null}
>
<span className="media-file-icon">
<FileIcon size={20} />
</span>
<span className="media-file-meta">
<span className="media-file-name">{media.name}</span>
<span className="media-file-size">{formatSize(media.size)}</span>
</span>
<DownloadIcon size={18} className="media-file-dl" />
</a>
);
};
const VoiceRow = ({
conversationId,
message,
}: {
conversationId: string;
message: Message;
}): ReactElement | null => {
const url = useMediaUrl(conversationId, message.id);
const media = message.media;
if (media === null) {
return null;
}
if (url === null) {
return <span className="media-loading">Loading</span>;
}
return (
<div className="profile-voice">
<VoiceMessage url={url} seed={message.id} durationSec={media.durationSec} mine={false} />
<span className="profile-voice-date muted">
{new Date(message.createdAt).toLocaleDateString([], { day: 'numeric', month: 'short' })}
</span>
</div>
);
};
const TABS: readonly { key: MediaTab; label: string }[] = [
{ key: 'media', label: 'Media' },
{ key: 'files', label: 'Files' },
{ key: 'voice', label: 'Voice' },
];
const SharedMedia = ({ conversation }: { conversation: Conversation }): ReactElement => {
const [tab, setTab] = useState<MediaTab>('media');
const [items, setItems] = useState<Message[]>([]);
const [exhausted, setExhausted] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setItems([]);
setExhausted(false);
setLoading(true);
void listConversationMedia(apiConfig, conversation.id, tab)
.then((batch) => {
if (!cancelled) {
setItems(batch);
setExhausted(batch.length < PAGE_SIZE);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [conversation.id, tab]);
const loadMore = async (): Promise<void> => {
const last = items[items.length - 1];
if (last === undefined) {
return;
}
const batch = await listConversationMedia(apiConfig, conversation.id, tab, last.seq);
setItems((prev) => [...prev, ...batch]);
setExhausted(batch.length < PAGE_SIZE);
};
return (
<div className="profile-shared">
<div className="profile-tabs" role="tablist">
{TABS.map((entry) => (
<button
key={entry.key}
type="button"
role="tab"
className="profile-tab"
aria-selected={tab === entry.key}
onClick={() => {
setTab(entry.key);
}}
>
{entry.label}
</button>
))}
</div>
{loading ? (
<p className="rail-empty">Loading</p>
) : items.length === 0 ? (
<p className="rail-empty">Nothing shared yet</p>
) : tab === 'media' ? (
<div className="media-grid">
{items.map((message) => (
<MediaCell key={message.id} conversationId={conversation.id} message={message} />
))}
</div>
) : (
<div className="profile-list">
{items.map((message) =>
tab === 'files' ? (
<FileRow key={message.id} conversationId={conversation.id} message={message} />
) : (
<VoiceRow key={message.id} conversationId={conversation.id} message={message} />
),
)}
</div>
)}
{!loading && !exhausted && items.length > 0 ? (
<button type="button" className="profile-more" onClick={() => void loadMore()}>
Load more
</button>
) : null}
</div>
);
};
export const ProfilePanel = ({
user,
conversation,
online,
onClose,
onMessage,
}: Props): ReactElement => {
const [presence, setPresence] = useState<Presence | null>(null);
useEffect(() => {
let cancelled = false;
setPresence(null);
void getPresence(apiConfig, [user.id]).then((list) => {
if (!cancelled) {
setPresence(list[0] ?? null);
}
});
return () => {
cancelled = true;
};
}, [user.id]);
return (
<MediaViewerProvider>
<aside className="profile-panel" aria-label={`${user.displayName} profile`}>
<div className="profile-header">
<button
type="button"
className="icon-btn profile-back"
aria-label="Back"
onClick={onClose}
>
<ChevronLeftIcon />
</button>
<span className="profile-header-title">User info</span>
<button
type="button"
className="icon-btn profile-close"
aria-label="Close profile"
onClick={onClose}
>
<CloseIcon />
</button>
</div>
<div className="profile-scroll">
<div className="profile-hero">
{user.avatarUrl !== null ? (
<img src={user.avatarUrl} alt="" className="profile-avatar" />
) : (
<span className="profile-avatar avatar-placeholder">
{user.displayName.charAt(0)}
</span>
)}
<h2 className="profile-name">{user.displayName}</h2>
<p className={online ? 'profile-presence is-online' : 'profile-presence'}>
{lastSeenLabel(presence, online)}
</p>
<button
type="button"
className="btn-primary btn-sm profile-message-btn"
onClick={() => {
onMessage(user.username);
}}
>
<SendIcon size={16} />
Message
</button>
</div>
<div className="profile-info">
<div className="profile-info-row">
<span className="profile-info-value">@{user.username}</span>
<span className="profile-info-label">Username</span>
</div>
</div>
{conversation !== null ? <SharedMedia conversation={conversation} /> : null}
</div>
</aside>
</MediaViewerProvider>
);
};

View file

@ -0,0 +1 @@
export { SettingsPanel } from './ui/SettingsPanel';

View file

@ -0,0 +1,161 @@
import { useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react';
import { updateMe, getAvatarUploadUrl, uploadToUrl, setAvatar, ApiError } from '@altricade/core/api';
import { useSession } from '../../../entities/session';
import { apiConfig } from '../../../shared/api';
import { useTheme } from '../../../shared/theme';
import type { ThemePreference } from '../../../shared/theme';
import {
SunIcon,
MoonIcon,
MonitorIcon,
LogOutIcon,
EditIcon,
CheckIcon,
} from '../../../shared/ui';
const THEME_OPTIONS: readonly { value: ThemePreference; label: string; icon: ReactElement }[] = [
{ value: 'light', label: 'Light', icon: <SunIcon size={17} /> },
{ value: 'dark', label: 'Dark', icon: <MoonIcon size={17} /> },
{ value: 'system', label: 'System', icon: <MonitorIcon size={17} /> },
];
interface Props {
onLogout: () => void;
}
// Telegram-style settings page: account (avatar, name), appearance, log out.
export const SettingsPanel = ({ onLogout }: Props): ReactElement | null => {
const { user, updateUser } = useSession();
const { preference, setPreference } = useTheme();
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState('');
const [error, setError] = useState<string | null>(null);
if (user === null) {
return null;
}
const onAvatar = (event: SyntheticEvent<HTMLInputElement>): 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 }));
})();
};
const saveName = async (): Promise<void> => {
const trimmed = nameDraft.trim();
setEditingName(false);
if (trimmed === '' || trimmed === user.displayName) {
return;
}
setError(null);
try {
updateUser(await updateMe(apiConfig, { displayName: trimmed }));
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : 'Could not update name');
}
};
return (
<div className="settings">
<div className="settings-hero">
<label className="settings-avatar-wrap" title="Change avatar">
{user.avatarUrl !== null ? (
<img src={user.avatarUrl} alt="" className="settings-avatar" />
) : (
<span className="settings-avatar avatar-placeholder">
{user.displayName.charAt(0)}
</span>
)}
<span className="settings-avatar-edit">
<EditIcon size={15} />
</span>
<input type="file" accept="image/*" hidden onChange={onAvatar} />
</label>
{editingName ? (
<form
className="settings-name-form"
onSubmit={(event) => {
event.preventDefault();
void saveName();
}}
>
<input
className="field settings-name-input"
autoFocus
maxLength={64}
value={nameDraft}
onChange={(event) => {
setNameDraft(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditingName(false);
}
}}
/>
<button type="submit" className="icon-btn accent" aria-label="Save name">
<CheckIcon size={17} />
</button>
</form>
) : (
<button
type="button"
className="settings-name"
title="Edit display name"
onClick={() => {
setNameDraft(user.displayName);
setEditingName(true);
}}
>
{user.displayName}
<EditIcon size={15} className="settings-name-edit" />
</button>
)}
<p className="muted settings-handle">@{user.username}</p>
{error !== null ? <p className="form-error">{error}</p> : null}
</div>
<div className="settings-section">
<p className="rail-section-title">Appearance</p>
<div className="settings-row">
<span className="settings-row-label">Theme</span>
<div className="theme-switch" role="group" aria-label="Theme">
{THEME_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className="theme-option"
title={option.label}
aria-label={option.label}
aria-pressed={preference === option.value}
onClick={() => {
setPreference(option.value);
}}
>
{option.icon}
</button>
))}
</div>
</div>
</div>
<div className="settings-section">
<button type="button" className="settings-logout" onClick={onLogout}>
<LogOutIcon size={18} />
Log out
</button>
</div>
</div>
);
};

View file

@ -1,33 +1,87 @@
// Design tokens — the single source of truth for colors. No hard-coded colors
// live in feature/entity/widget code; everything reads these via CSS variables.
// live in feature/entity/widget code; everything reads these via CSS variables
// (`--color-<token>`), written onto <html> by the ThemeProvider.
//
// Non-color design primitives (spacing, radius, type scale, shadows, motion)
// live as static custom properties in app/index.css, keyed on [data-theme]
// where they must differ between light and dark.
export type ThemeName = 'light' | 'dark';
export interface ThemeColors {
/** App base background. */
background: string;
/** Sidebar / header / composer chrome. */
surfacePanel: string;
/** Incoming bubbles, chips, inputs. */
surface: string;
/** Hovered rows / controls. */
surfaceHover: string;
/** The chat scrollback backdrop, one step off the base. */
chatBackdrop: string;
text: string;
textMuted: string;
textFaint: string;
accent: string;
accentHover: string;
/** Tinted accent wash — selected conversation row, own reaction pill. */
accentSoft: string;
/** Foreground on an accent fill. */
onAccent: string;
/** Muted foreground on an accent fill (timestamps on own bubbles). */
onAccentMuted: string;
/** Own (outgoing) message bubble fill. */
bubbleOut: string;
/** Incoming message bubble fill (elevated off the chat backdrop). */
bubbleIn: string;
border: string;
borderStrong: string;
online: string;
danger: string;
[token: string]: string;
}
export const themes: Record<ThemeName, ThemeColors> = {
light: {
background: '#ffffff',
surface: '#f4f5f7',
text: '#0b0c0f',
textMuted: '#5b6472',
accent: '#2f6fed',
border: '#e2e5ea',
surfacePanel: '#f7f8fa',
surface: '#eef0f4',
surfaceHover: '#f0f2f6',
chatBackdrop: '#f4f5f8',
text: '#0c0d10',
textMuted: '#606a7b',
textFaint: '#9aa3b2',
accent: '#4c6fff',
accentHover: '#3a5cf5',
accentSoft: '#eaeeff',
onAccent: '#ffffff',
onAccentMuted: 'rgba(255, 255, 255, 0.72)',
bubbleOut: '#4c6fff',
bubbleIn: '#ffffff',
border: '#e6e8ee',
borderStrong: '#d4d8e0',
online: '#22c55e',
danger: '#ef4444',
},
dark: {
background: '#0b0c0f',
surface: '#15171c',
text: '#f4f5f7',
textMuted: '#9aa3b2',
accent: '#5b8bff',
border: '#242833',
background: '#0e0f13',
surfacePanel: '#15171d',
surface: '#1e222b',
surfaceHover: '#242833',
chatBackdrop: '#0b0c10',
text: '#f3f4f7',
textMuted: '#98a1b2',
textFaint: '#5f6675',
accent: '#5b7cff',
accentHover: '#6f8bff',
accentSoft: '#1b2540',
onAccent: '#ffffff',
onAccentMuted: 'rgba(255, 255, 255, 0.72)',
bubbleOut: '#3b5cf5',
bubbleIn: '#22262f',
border: '#262a33',
borderStrong: '#333844',
online: '#22c55e',
danger: '#f87171',
},
};

View file

@ -0,0 +1,74 @@
import { useEffect } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { createPortal } from 'react-dom';
// Telegram-style small centered confirm dialog with a vertical action list —
// used for message deletion (for me / for everyone), clear history, etc.
export interface DialogAction {
key: string;
label: string;
danger?: boolean;
onSelect: () => void;
}
interface Props {
title: string;
body?: ReactNode;
actions: DialogAction[];
cancelLabel?: string;
onClose: () => void;
}
export const ConfirmDialog = ({
title,
body,
actions,
cancelLabel = 'Cancel',
onClose,
}: Props): ReactElement => {
useEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
};
}, [onClose]);
return createPortal(
<div className="dialog-backdrop" onClick={onClose} role="presentation">
<div
className="dialog"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(event) => {
event.stopPropagation();
}}
>
<h3 className="dialog-title">{title}</h3>
{body !== undefined ? <div className="dialog-body">{body}</div> : null}
<div className="dialog-actions">
{actions.map((action) => (
<button
key={action.key}
type="button"
className={action.danger === true ? 'dialog-btn dialog-danger' : 'dialog-btn'}
onClick={action.onSelect}
>
{action.label}
</button>
))}
<button type="button" className="dialog-btn dialog-cancel" onClick={onClose}>
{cancelLabel}
</button>
</div>
</div>
</div>,
document.body,
);
};

View file

@ -0,0 +1,119 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent, ReactElement, ReactNode } from 'react';
import { createPortal } from 'react-dom';
// Generic right-click context menu. Owner keeps `{position, payload}` state via
// useContextMenu; the component portals to <body>, clamps to the viewport and
// closes on click-away / Escape / resize.
export interface ContextMenuState<T> {
x: number;
y: number;
payload: T;
}
export interface UseContextMenu<T> {
state: ContextMenuState<T> | null;
openAt: (event: ReactMouseEvent, payload: T) => void;
close: () => void;
}
export const useContextMenu = <T,>(): UseContextMenu<T> => {
const [state, setState] = useState<ContextMenuState<T> | null>(null);
const openAt = useCallback((event: ReactMouseEvent, payload: T): void => {
event.preventDefault();
event.stopPropagation();
setState({ x: event.clientX, y: event.clientY, payload });
}, []);
const close = useCallback((): void => {
setState(null);
}, []);
return { state, openAt, close };
};
interface ContextMenuProps {
x: number;
y: number;
onClose: () => void;
children: ReactNode;
}
export const ContextMenu = ({ x, y, onClose, children }: ContextMenuProps): ReactElement => {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: x, top: y });
// Clamp into the viewport once rendered (menu size is content-driven).
useLayoutEffect(() => {
const node = menuRef.current;
if (node === null) {
return;
}
const rect = node.getBoundingClientRect();
const margin = 8;
const left = Math.min(x, window.innerWidth - rect.width - margin);
const top = Math.min(y, window.innerHeight - rect.height - margin);
setPosition({ left: Math.max(margin, left), top: Math.max(margin, top) });
}, [x, y]);
useLayoutEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', onKey);
window.addEventListener('resize', onClose);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('resize', onClose);
};
}, [onClose]);
return createPortal(
<div
className="ctx-backdrop"
onClick={onClose}
onContextMenu={(event) => {
event.preventDefault();
onClose();
}}
>
<div
ref={menuRef}
className="ctx-menu"
role="menu"
style={{ left: position.left, top: position.top }}
onClick={(event) => {
event.stopPropagation();
}}
>
{children}
</div>
</div>,
document.body,
);
};
interface MenuItemProps {
icon?: ReactNode;
label: string;
danger?: boolean;
onClick: () => void;
}
export const MenuItem = ({ icon, label, danger = false, onClick }: MenuItemProps): ReactElement => (
<button
type="button"
role="menuitem"
className={danger ? 'ctx-item ctx-danger' : 'ctx-item'}
onClick={onClick}
>
{icon !== undefined ? <span className="ctx-item-icon">{icon}</span> : null}
<span className="ctx-item-label">{label}</span>
</button>
);
export const MenuSeparator = (): ReactElement => <div className="ctx-sep" role="separator" />;

View file

@ -1,4 +1,4 @@
import type { ReactElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
// Inline stroke icons so glyphs render identically on every OS/browser (no
// emoji font variance). `currentColor` lets callers theme them via CSS.
@ -7,14 +7,19 @@ interface IconProps {
className?: string;
}
const base = (size: number, className: string | undefined, children: ReactElement): ReactElement => (
const base = (
size: number,
className: string | undefined,
children: ReactNode,
strokeWidth = 2,
): ReactElement => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
@ -24,6 +29,20 @@ const base = (size: number, className: string | undefined, children: ReactElemen
</svg>
);
// Solid-fill variant helper (play triangle, filled dots, etc.).
const solid = (size: number, className: string | undefined, children: ReactNode): ReactElement => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
{children}
</svg>
);
export const PaperclipIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
@ -54,17 +73,10 @@ export const VideoIcon = ({ size = 20, className }: IconProps): ReactElement =>
);
export const StopIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(size, className, <rect x="6" y="6" width="12" height="12" rx="2" />);
solid(size, className, <rect x="6" y="6" width="12" height="12" rx="2" />);
export const SendIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<line x1="22" y1="2" x2="11" y2="13" />
<path d="M22 2l-7 20-4-9-9-4 20-7z" />
</>,
);
solid(size, className, <path d="M3.4 20.4l17.45-8.3a1 1 0 0 0 0-1.8L3.4 2A.7.7 0 0 0 2.4 2.7L4.5 11 2.4 21.3a.7.7 0 0 0 1 .1z" />);
export const CloseIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
@ -85,3 +97,241 @@ export const FileIcon = ({ size = 20, className }: IconProps): ReactElement =>
<polyline points="14 2 14 8 20 8" />
</>,
);
export const SearchIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</>,
);
export const PlayIcon = ({ size = 20, className }: IconProps): ReactElement =>
solid(size, className, <path d="M7 4.5v15a1 1 0 0 0 1.53.85l12-7.5a1 1 0 0 0 0-1.7l-12-7.5A1 1 0 0 0 7 4.5z" />);
export const PauseIcon = ({ size = 20, className }: IconProps): ReactElement =>
solid(
size,
className,
<>
<rect x="6" y="4.5" width="4" height="15" rx="1.2" />
<rect x="14" y="4.5" width="4" height="15" rx="1.2" />
</>,
);
export const DownloadIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</>,
);
export const ImageIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</>,
);
export const CheckIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(size, className, <polyline points="20 6 9 17 4 12" />, 2.4);
export const DoubleCheckIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M2 12.5l4.5 4.5L16 7" />
<path d="M11 16.5l1 1L22 7" />
</>,
2.4,
);
export const PlusIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</>,
);
export const ChevronLeftIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(size, className, <polyline points="15 18 9 12 15 6" />);
export const SunIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</>,
);
export const MoonIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(size, className, <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />);
export const MonitorIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</>,
);
export const LogOutIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</>,
);
export const TrashIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</>,
);
export const EditIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</>,
);
export const SmileIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</>,
);
export const UsersIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</>,
);
export const PinIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M12 17v5" />
<path d="M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z" />
</>,
);
export const PinOffIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M12 17v5" />
<path d="M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z" />
<line x1="3" y1="3" x2="21" y2="21" />
</>,
);
export const FolderIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
);
export const CopyIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>,
);
export const EraserIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M20 20H7L3 16a1.9 1.9 0 0 1 0-2.7L13.3 3a1.9 1.9 0 0 1 2.7 0l5 5a1.9 1.9 0 0 1 0 2.7L13 19" />
<line x1="9" y1="8" x2="16" y2="15" />
</>,
);
export const SettingsIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</>,
);
export const ChatsIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />,
);
export const UserIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</>,
);
export const ChevronDownIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(size, className, <polyline points="6 9 12 15 18 9" />);
export const ReplyIcon = ({ size = 20, className }: IconProps): ReactElement =>
base(
size,
className,
<>
<polyline points="9 17 4 12 9 7" />
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
</>,
);

View file

@ -6,4 +6,35 @@ export {
SendIcon,
CloseIcon,
FileIcon,
SearchIcon,
PlayIcon,
PauseIcon,
DownloadIcon,
ImageIcon,
CheckIcon,
DoubleCheckIcon,
PlusIcon,
ChevronLeftIcon,
SunIcon,
MoonIcon,
MonitorIcon,
LogOutIcon,
TrashIcon,
EditIcon,
SmileIcon,
UsersIcon,
ReplyIcon,
PinIcon,
PinOffIcon,
FolderIcon,
CopyIcon,
EraserIcon,
SettingsIcon,
ChatsIcon,
UserIcon,
ChevronDownIcon,
} from './icons';
export { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from './ContextMenu';
export type { ContextMenuState, UseContextMenu } from './ContextMenu';
export { ConfirmDialog } from './ConfirmDialog';
export type { DialogAction } from './ConfirmDialog';