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
This commit is contained in:
parent
55825662d7
commit
cf432bd803
44 changed files with 4019 additions and 428 deletions
|
|
@ -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');
|
||||||
|
};
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
} from './modules/conversations';
|
} from './modules/conversations';
|
||||||
import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages';
|
import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages';
|
||||||
import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts';
|
import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts';
|
||||||
|
import { createFoldersRepository, createFoldersService, foldersRoutes } from './modules/folders';
|
||||||
import { createPresenceService, presenceRoutes } from './modules/presence';
|
import { createPresenceService, presenceRoutes } from './modules/presence';
|
||||||
import { createMediaService, mediaRoutes } from './modules/media';
|
import { createMediaService, mediaRoutes } from './modules/media';
|
||||||
import {
|
import {
|
||||||
|
|
@ -155,6 +156,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
messages: messagesRepository,
|
messages: messagesRepository,
|
||||||
conversations: conversationsRepository,
|
conversations: conversationsRepository,
|
||||||
deliver,
|
deliver,
|
||||||
|
publish,
|
||||||
mediaDownloadUrl: (objectKey) => mediaService.downloadUrl(objectKey),
|
mediaDownloadUrl: (objectKey) => mediaService.downloadUrl(objectKey),
|
||||||
notify: (message) => {
|
notify: (message) => {
|
||||||
void notificationQueue
|
void notificationQueue
|
||||||
|
|
@ -173,6 +175,14 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
'contactsService',
|
'contactsService',
|
||||||
createContactsService({ contacts: contactsRepository, users: usersRepository }),
|
createContactsService({ contacts: contactsRepository, users: usersRepository }),
|
||||||
);
|
);
|
||||||
|
app.decorate(
|
||||||
|
'foldersService',
|
||||||
|
createFoldersService({
|
||||||
|
folders: createFoldersRepository(app.db),
|
||||||
|
conversations: conversationsRepository,
|
||||||
|
publish,
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.decorate(
|
app.decorate(
|
||||||
'presenceService',
|
'presenceService',
|
||||||
createPresenceService({
|
createPresenceService({
|
||||||
|
|
@ -191,6 +201,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
await app.register(conversationsRoutes);
|
await app.register(conversationsRoutes);
|
||||||
await app.register(messagesRoutes);
|
await app.register(messagesRoutes);
|
||||||
await app.register(contactsRoutes);
|
await app.register(contactsRoutes);
|
||||||
|
await app.register(foldersRoutes);
|
||||||
await app.register(presenceRoutes);
|
await app.register(presenceRoutes);
|
||||||
await app.register(mediaRoutes);
|
await app.register(mediaRoutes);
|
||||||
await app.register(notificationsRoutes);
|
await app.register(notificationsRoutes);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ export interface ConversationMembersTable {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
role: Generated<string>;
|
role: Generated<string>;
|
||||||
joined_at: Generated<Date>;
|
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 {
|
export interface MessagesTable {
|
||||||
|
|
@ -114,6 +118,35 @@ export interface ConversationMutesTable {
|
||||||
created_at: Generated<Date>;
|
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 {
|
export interface Database {
|
||||||
users: UsersTable;
|
users: UsersTable;
|
||||||
refresh_tokens: RefreshTokensTable;
|
refresh_tokens: RefreshTokensTable;
|
||||||
|
|
@ -126,4 +159,8 @@ export interface Database {
|
||||||
device_tokens: DeviceTokensTable;
|
device_tokens: DeviceTokensTable;
|
||||||
notification_settings: NotificationSettingsTable;
|
notification_settings: NotificationSettingsTable;
|
||||||
conversation_mutes: ConversationMutesTable;
|
conversation_mutes: ConversationMutesTable;
|
||||||
|
message_hidden: MessageHiddenTable;
|
||||||
|
chat_folders: ChatFoldersTable;
|
||||||
|
chat_folder_items: ChatFolderItemsTable;
|
||||||
|
chat_pins: ChatPinsTable;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { sql } from 'kysely';
|
||||||
import type { Kysely, Selectable } from 'kysely';
|
import type { Kysely, Selectable } from 'kysely';
|
||||||
import type { Database, ConversationsTable } from '../../db/schema';
|
import type { Database, ConversationsTable } from '../../db/schema';
|
||||||
|
|
||||||
|
|
@ -38,9 +39,46 @@ export interface ConversationsRepository {
|
||||||
listMembers(conversationId: string): Promise<MemberWithUser[]>;
|
listMembers(conversationId: string): Promise<MemberWithUser[]>;
|
||||||
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
||||||
touchLastMessage(conversationId: string): Promise<void>;
|
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 => {
|
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) =>
|
const membersWithUser = (conversationId: string) =>
|
||||||
db
|
db
|
||||||
.selectFrom('conversation_members')
|
.selectFrom('conversation_members')
|
||||||
|
|
@ -130,6 +168,13 @@ export const createConversationsRepository = (db: Kysely<Database>): Conversatio
|
||||||
'conversations.id',
|
'conversations.id',
|
||||||
)
|
)
|
||||||
.where('conversation_members.user_id', '=', userId)
|
.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')
|
.selectAll('conversations')
|
||||||
.orderBy('conversations.last_message_at', 'desc')
|
.orderBy('conversations.last_message_at', 'desc')
|
||||||
.execute(),
|
.execute(),
|
||||||
|
|
@ -247,5 +292,18 @@ export const createConversationsRepository = (db: Kysely<Database>): Conversatio
|
||||||
.where('id', '=', conversationId)
|
.where('id', '=', conversationId)
|
||||||
.execute();
|
.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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 }>(
|
app.post<{ Params: { id: string }; Body: ReadBody }>(
|
||||||
'/conversations/:id/read',
|
'/conversations/:id/read',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type {
|
||||||
ConversationMember,
|
ConversationMember,
|
||||||
ConversationNewEvent,
|
ConversationNewEvent,
|
||||||
ConversationMembershipEvent,
|
ConversationMembershipEvent,
|
||||||
|
ConversationClearedEvent,
|
||||||
|
ConversationHiddenEvent,
|
||||||
ReadReceiptEvent,
|
ReadReceiptEvent,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import { userChannel, EventType } from '@altricade/core';
|
import { userChannel, EventType } from '@altricade/core';
|
||||||
|
|
@ -34,6 +36,8 @@ export interface ConversationsService {
|
||||||
isMember(conversationId: string, userId: string): Promise<boolean>;
|
isMember(conversationId: string, userId: string): Promise<boolean>;
|
||||||
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
|
||||||
markRead(conversationId: string, userId: string, seq: number): Promise<void>;
|
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 = (
|
export const createConversationsService = (
|
||||||
|
|
@ -180,6 +184,28 @@ export const createConversationsService = (
|
||||||
const event: ReadReceiptEvent = { type: EventType.ReadReceipt, conversationId, userId, seq };
|
const event: ReadReceiptEvent = { type: EventType.ReadReceipt, conversationId, userId, seq };
|
||||||
await deliver(conversationId, event);
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
137
packages/backend/src/modules/folders/folders.repository.ts
Normal file
137
packages/backend/src/modules/folders/folders.repository.ts
Normal 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();
|
||||||
|
},
|
||||||
|
});
|
||||||
119
packages/backend/src/modules/folders/folders.routes.ts
Normal file
119
packages/backend/src/modules/folders/folders.routes.ts
Normal 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();
|
||||||
|
};
|
||||||
106
packages/backend/src/modules/folders/folders.service.ts
Normal file
106
packages/backend/src/modules/folders/folders.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/backend/src/modules/folders/index.ts
Normal file
5
packages/backend/src/modules/folders/index.ts
Normal 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';
|
||||||
|
|
@ -32,7 +32,7 @@ const DOWNLOAD_EXPIRY = 3600;
|
||||||
|
|
||||||
const kindMatches = (kind: MediaKind, mime: string): boolean => {
|
const kindMatches = (kind: MediaKind, mime: string): boolean => {
|
||||||
if (kind === 'image') return mime.startsWith('image/');
|
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/');
|
if (kind === 'voice') return mime.startsWith('audio/');
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,18 @@ export interface MessagesRepository {
|
||||||
clientMsgId: string,
|
clientMsgId: string,
|
||||||
): Promise<string | undefined>;
|
): Promise<string | undefined>;
|
||||||
getWithSenderById(id: string): Promise<MessageWithSenderRow | 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(
|
listHistory(
|
||||||
conversationId: string,
|
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,
|
beforeSeq: number | null,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<MessageWithSenderRow[]>;
|
): Promise<MessageWithSenderRow[]>;
|
||||||
|
|
@ -53,6 +63,10 @@ export interface MessagesRepository {
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<{ id: string } | undefined>;
|
): Promise<{ id: string } | undefined>;
|
||||||
softDelete(messageId: string, senderId: 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>;
|
addReaction(messageId: string, userId: string, emoji: string): Promise<void>;
|
||||||
removeReaction(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[]>>;
|
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',
|
'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 {
|
return {
|
||||||
insert: (input) =>
|
insert: (input) =>
|
||||||
db
|
db
|
||||||
|
|
@ -115,8 +151,20 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
|
|
||||||
getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(),
|
getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(),
|
||||||
|
|
||||||
listHistory: (conversationId, beforeSeq, limit) => {
|
listHistory: (conversationId, userId, beforeSeq, limit) => {
|
||||||
let query = withSender().where('messages.conversation_id', '=', conversationId);
|
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) {
|
if (beforeSeq !== null) {
|
||||||
query = query.where('messages.seq', '<', beforeSeq);
|
query = query.where('messages.seq', '<', beforeSeq);
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +200,23 @@ export const createMessagesRepository = (db: Kysely<Database>): MessagesReposito
|
||||||
.returning('id')
|
.returning('id')
|
||||||
.executeTakeFirst(),
|
.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) => {
|
addReaction: async (messageId, userId, emoji) => {
|
||||||
await db
|
await db
|
||||||
.insertInto('reactions')
|
.insertInto('reactions')
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import {
|
||||||
messageSchema,
|
messageSchema,
|
||||||
messageListSchema,
|
messageListSchema,
|
||||||
mediaUrlSchema,
|
mediaUrlSchema,
|
||||||
|
mediaListQuerySchema,
|
||||||
errorSchema,
|
errorSchema,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import type { SendMessageBody, EditMessageBody, ReactionBody } from '@altricade/core';
|
import type { SendMessageBody, EditMessageBody, ReactionBody, MediaTab } from '@altricade/core';
|
||||||
|
|
||||||
const bearerAuth = [{ bearerAuth: [] }];
|
const bearerAuth = [{ bearerAuth: [] }];
|
||||||
const idParamsSchema = {
|
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 }>(
|
app.post<{ Params: { id: string; messageId: string }; Body: ReactionBody }>(
|
||||||
'/conversations/:id/messages/:messageId/reactions',
|
'/conversations/:id/messages/:messageId/reactions',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { EventType } from '@altricade/core';
|
import { EventType, userChannel } from '@altricade/core';
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
MessageNewEvent,
|
MessageNewEvent,
|
||||||
MessageEditEvent,
|
MessageEditEvent,
|
||||||
MessageDeleteEvent,
|
MessageDeleteEvent,
|
||||||
|
MessageHiddenEvent,
|
||||||
ReactionEvent,
|
ReactionEvent,
|
||||||
SendMessageBody,
|
SendMessageBody,
|
||||||
|
MediaTab,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import { HttpError } from '../../shared/http-error';
|
import { HttpError } from '../../shared/http-error';
|
||||||
|
import type { Publisher } from '../../shared/publisher';
|
||||||
import type { ConversationsRepository } from '../conversations';
|
import type { ConversationsRepository } from '../conversations';
|
||||||
import type { Deliver } from '../conversations';
|
import type { Deliver } from '../conversations';
|
||||||
import type { MessagesRepository } from './messages.repository';
|
import type { MessagesRepository } from './messages.repository';
|
||||||
|
|
@ -17,11 +20,20 @@ export interface MessagesServiceDeps {
|
||||||
messages: MessagesRepository;
|
messages: MessagesRepository;
|
||||||
conversations: ConversationsRepository;
|
conversations: ConversationsRepository;
|
||||||
deliver: Deliver;
|
deliver: Deliver;
|
||||||
|
/** Personal-channel publisher for per-user view-state events. */
|
||||||
|
publish: Publisher;
|
||||||
mediaDownloadUrl: (objectKey: string) => Promise<string>;
|
mediaDownloadUrl: (objectKey: string) => Promise<string>;
|
||||||
/** Fire-and-forget push-notification hook, called for each newly-created message. */
|
/** Fire-and-forget push-notification hook, called for each newly-created message. */
|
||||||
notify: (message: Message) => void;
|
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 {
|
export interface SentMessage {
|
||||||
message: Message;
|
message: Message;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
|
|
@ -42,6 +54,14 @@ export interface MessagesService {
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<Message>;
|
): Promise<Message>;
|
||||||
remove(conversationId: string, messageId: string, userId: string): Promise<void>;
|
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(
|
addReaction(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
|
@ -58,7 +78,7 @@ export interface MessagesService {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createMessagesService = (deps: MessagesServiceDeps): 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> => {
|
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
|
||||||
if (!(await conversations.isMember(conversationId, userId))) {
|
if (!(await conversations.isMember(conversationId, userId))) {
|
||||||
|
|
@ -120,7 +140,7 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
|
|
||||||
history: async (conversationId, userId, beforeSeq, limit) => {
|
history: async (conversationId, userId, beforeSeq, limit) => {
|
||||||
await assertMember(conversationId, userId);
|
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(
|
const reactions = await messages.reactionsFor(
|
||||||
rows.map((row) => row.id),
|
rows.map((row) => row.id),
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -128,6 +148,12 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
return rows.map((row) => toMessage(row, reactions.get(row.id) ?? []));
|
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) => {
|
edit: async (conversationId, messageId, userId, content) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
await assertMessageIn(conversationId, messageId);
|
await assertMessageIn(conversationId, messageId);
|
||||||
|
|
@ -144,14 +170,37 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic
|
||||||
remove: async (conversationId, messageId, userId) => {
|
remove: async (conversationId, messageId, userId) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
await assertMessageIn(conversationId, messageId);
|
await assertMessageIn(conversationId, messageId);
|
||||||
const deleted = await messages.softDelete(messageId, userId);
|
let deleted = await messages.softDelete(messageId, userId);
|
||||||
if (deleted === undefined) {
|
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 };
|
const event: MessageDeleteEvent = { type: EventType.MessageDelete, conversationId, messageId };
|
||||||
await deliver(conversationId, event);
|
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) => {
|
addReaction: async (conversationId, messageId, userId, emoji) => {
|
||||||
await assertMember(conversationId, userId);
|
await assertMember(conversationId, userId);
|
||||||
await assertMessageIn(conversationId, messageId);
|
await assertMessageIn(conversationId, messageId);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ const mediaPlaceholder = (kind: MediaKind): string => {
|
||||||
return 'Photo';
|
return 'Photo';
|
||||||
case 'video':
|
case 'video':
|
||||||
return 'Video';
|
return 'Video';
|
||||||
|
case 'video_note':
|
||||||
|
return 'Video message';
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return 'Voice message';
|
return 'Voice message';
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -61,3 +61,13 @@ export const markRead = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await requestJson(config, 'POST', `/conversations/${id}/read`, { seq });
|
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`);
|
||||||
|
};
|
||||||
|
|
|
||||||
35
packages/core/src/api/folders.ts
Normal file
35
packages/core/src/api/folders.ts
Normal 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));
|
||||||
|
|
@ -22,6 +22,8 @@ export {
|
||||||
listMembers,
|
listMembers,
|
||||||
addMember,
|
addMember,
|
||||||
removeMember,
|
removeMember,
|
||||||
|
clearConversation,
|
||||||
|
hideConversation,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
export { listContacts, addContact, removeContact } from './contacts';
|
export { listContacts, addContact, removeContact } from './contacts';
|
||||||
export {
|
export {
|
||||||
|
|
@ -29,9 +31,12 @@ export {
|
||||||
getHistory,
|
getHistory,
|
||||||
editMessage,
|
editMessage,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
|
hideMessage,
|
||||||
|
listConversationMedia,
|
||||||
addReaction,
|
addReaction,
|
||||||
removeReaction,
|
removeReaction,
|
||||||
} from './messages';
|
} from './messages';
|
||||||
|
export { getFolders, createFolder, updateFolder, deleteFolder, setPin } from './folders';
|
||||||
export type { HistoryOptions } from './messages';
|
export type { HistoryOptions } from './messages';
|
||||||
export { markRead } from './conversations';
|
export { markRead } from './conversations';
|
||||||
export { getPresence, heartbeat } from './presence';
|
export { getPresence, heartbeat } from './presence';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { messageSchema, messageListSchema } from '../schemas/index';
|
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 type { Message } from '../types/index';
|
||||||
import { compileValidator, parse, requestJson } from './http';
|
import { compileValidator, parse, requestJson } from './http';
|
||||||
import type { ApiClientConfig } from './http';
|
import type { ApiClientConfig } from './http';
|
||||||
|
|
@ -48,6 +48,7 @@ export const editMessage = async (
|
||||||
await requestJson(config, 'PATCH', `/conversations/${conversationId}/messages/${messageId}`, body),
|
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 (
|
export const deleteMessage = async (
|
||||||
config: ApiClientConfig,
|
config: ApiClientConfig,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|
@ -56,6 +57,32 @@ export const deleteMessage = async (
|
||||||
await requestJson(config, 'DELETE', `/conversations/${conversationId}/messages/${messageId}`);
|
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 (
|
export const addReaction = async (
|
||||||
config: ApiClientConfig,
|
config: ApiClientConfig,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
import type { Message } from '../types/message';
|
import type { Message } from '../types/message';
|
||||||
import type { Conversation } from '../types/conversation';
|
import type { Conversation } from '../types/conversation';
|
||||||
|
import type { FoldersState } from '../types/folders';
|
||||||
|
|
||||||
export const EventType = {
|
export const EventType = {
|
||||||
// Bucket A
|
// Bucket A
|
||||||
|
|
@ -24,6 +25,12 @@ export const EventType = {
|
||||||
ConversationNew: 'conversation.new',
|
ConversationNew: 'conversation.new',
|
||||||
ConversationMembership: 'conversation.membership',
|
ConversationMembership: 'conversation.membership',
|
||||||
ProfileUpdate: 'profile.update',
|
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
|
// Bucket B
|
||||||
TypingStart: 'typing.start',
|
TypingStart: 'typing.start',
|
||||||
|
|
@ -100,3 +107,29 @@ export interface ProfileUpdateEvent {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string | null;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
85
packages/core/src/schemas/folders.ts
Normal file
85
packages/core/src/schemas/folders.ts
Normal 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'];
|
||||||
|
|
@ -26,6 +26,16 @@ export {
|
||||||
mediaUrlSchema,
|
mediaUrlSchema,
|
||||||
} from './media';
|
} from './media';
|
||||||
export type { MediaRef, MediaKind, UploadUrlBody, AvatarUploadBody, SetAvatarBody } 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 {
|
export {
|
||||||
registerDeviceBodySchema,
|
registerDeviceBodySchema,
|
||||||
updateSettingsBodySchema,
|
updateSettingsBodySchema,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import type { FromSchema } from 'json-schema-to-ts';
|
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).
|
// Metadata for an attached media object (no object key — that stays server-side).
|
||||||
export const mediaRefSchema = {
|
export const mediaRefSchema = {
|
||||||
|
|
|
||||||
24
packages/core/src/types/folders.ts
Normal file
24
packages/core/src/types/folders.ts
Normal 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[];
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export type { Conversation, ConversationType, ConversationMember } from './conve
|
||||||
export type { Contact } from './contact';
|
export type { Contact } from './contact';
|
||||||
export type { Message, ReactionSummary, MediaRef } from './message';
|
export type { Message, ReactionSummary, MediaRef } from './message';
|
||||||
export type { Presence } from './presence';
|
export type { Presence } from './presence';
|
||||||
|
export type { ChatFolder, ChatPin, FoldersState } from './folders';
|
||||||
export type {
|
export type {
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
Device,
|
Device,
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,35 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { ReactElement, SyntheticEvent } from 'react';
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
import type { Conversation, Message, MediaRef, PublicUser, User } from '@altricade/core';
|
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 { SessionProvider, useSession } from '../entities/session';
|
||||||
import { AuthForm } from '../features/auth';
|
import { AuthForm } from '../features/auth';
|
||||||
import { RealtimeProvider } from '../features/realtime';
|
import { RealtimeProvider } from '../features/realtime';
|
||||||
import { useConversations, ConversationSidebar } from '../features/conversations';
|
import { useConversations, ConversationSidebar } from '../features/conversations';
|
||||||
|
import { useFolders } from '../features/folders';
|
||||||
import { ContactsPanel } from '../features/contacts';
|
import { ContactsPanel } from '../features/contacts';
|
||||||
import { ChatView } from '../features/messaging';
|
import { ChatView } from '../features/messaging';
|
||||||
|
import { ProfilePanel } from '../features/profile';
|
||||||
|
import { SettingsPanel } from '../features/settings';
|
||||||
import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications';
|
import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications';
|
||||||
import { apiConfig } from '../shared/api';
|
import { apiConfig } from '../shared/api';
|
||||||
import { useTheme } from '../shared/theme';
|
|
||||||
import type { ThemePreference } from '../shared/theme';
|
|
||||||
import {
|
import {
|
||||||
SunIcon,
|
|
||||||
MoonIcon,
|
|
||||||
MonitorIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
LogOutIcon,
|
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
UserIcon,
|
||||||
|
ChatsIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
} from '../shared/ui';
|
} from '../shared/ui';
|
||||||
|
|
||||||
const PREFERENCES: readonly { value: ThemePreference; label: string; icon: ReactElement }[] = [
|
type RailView = 'chats' | 'contacts' | 'compose' | 'settings';
|
||||||
{ value: 'light', label: 'Light', icon: <SunIcon size={17} /> },
|
|
||||||
{ value: 'dark', label: 'Dark', icon: <MoonIcon size={17} /> },
|
const RAIL_TITLES: Record<Exclude<RailView, 'chats'>, string> = {
|
||||||
{ value: 'system', label: 'System', icon: <MonitorIcon size={17} /> },
|
contacts: 'Contacts',
|
||||||
];
|
compose: 'New group',
|
||||||
|
settings: 'Settings',
|
||||||
|
};
|
||||||
|
|
||||||
const mediaLabel = (media: MediaRef | null): string => {
|
const mediaLabel = (media: MediaRef | null): string => {
|
||||||
if (media === null) {
|
if (media === null) {
|
||||||
|
|
@ -37,6 +40,8 @@ const mediaLabel = (media: MediaRef | null): string => {
|
||||||
return 'Photo';
|
return 'Photo';
|
||||||
case 'video':
|
case 'video':
|
||||||
return 'Video';
|
return 'Video';
|
||||||
|
case 'video_note':
|
||||||
|
return 'Video message';
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return 'Voice message';
|
return 'Voice message';
|
||||||
default:
|
default:
|
||||||
|
|
@ -44,29 +49,6 @@ const mediaLabel = (media: MediaRef | null): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThemeSwitch = (): ReactElement => {
|
|
||||||
const { preference, setPreference } = useTheme();
|
|
||||||
return (
|
|
||||||
<div className="theme-switch" role="group" aria-label="Theme">
|
|
||||||
{PREFERENCES.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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupComposer = ({
|
const GroupComposer = ({
|
||||||
onCreate,
|
onCreate,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -133,11 +115,13 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
};
|
};
|
||||||
const { updateUser } = useSession();
|
|
||||||
const { notify, setOpener } = useNotifications();
|
const { notify, setOpener } = useNotifications();
|
||||||
const [current, setCurrent] = useState<Conversation | null>(null);
|
const [current, setCurrent] = useState<Conversation | null>(null);
|
||||||
const [composing, setComposing] = useState(false);
|
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 convRef = useRef<Conversation[]>([]);
|
||||||
|
const folders = useFolders(user.id);
|
||||||
|
|
||||||
// Toast (or OS notification when hidden) for messages arriving in other chats.
|
// Toast (or OS notification when hidden) for messages arriving in other chats.
|
||||||
const onIncoming = useCallback(
|
const onIncoming = useCallback(
|
||||||
|
|
@ -154,18 +138,39 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
[notify],
|
[notify],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { conversations, onlineMap, startDirect, createGroupChat } = useConversations(
|
const { conversations, onlineMap, startDirect, createGroupChat, clearChat, deleteChat } =
|
||||||
user.id,
|
useConversations(user.id, current?.id ?? null, onIncoming);
|
||||||
current?.id ?? null,
|
|
||||||
onIncoming,
|
|
||||||
);
|
|
||||||
convRef.current = conversations;
|
convRef.current = conversations;
|
||||||
|
|
||||||
const openConversation = useCallback((conversation: Conversation): void => {
|
const openConversation = useCallback((conversation: Conversation): void => {
|
||||||
setCurrent(conversation);
|
setCurrent(conversation);
|
||||||
setComposing(false);
|
setRailView('chats');
|
||||||
|
setProfileUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The DM shared with the profiled user, when one exists (drives shared media).
|
||||||
|
const profileConversation =
|
||||||
|
profileUser === null
|
||||||
|
? null
|
||||||
|
: (conversations.find(
|
||||||
|
(item) => item.type === 'direct' && item.peer !== null && item.peer.id === profileUser.id,
|
||||||
|
) ?? null);
|
||||||
|
|
||||||
|
// If the open chat disappears from the list (deleted for me, possibly on
|
||||||
|
// another device), close it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (current !== null && !conversations.some((c) => c.id === current.id)) {
|
||||||
|
setCurrent(null);
|
||||||
|
}
|
||||||
|
}, [conversations, current]);
|
||||||
|
|
||||||
|
// If the active folder was deleted (any device), fall back to All.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeFolderId !== null && !folders.folders.some((f) => f.id === activeFolderId)) {
|
||||||
|
setActiveFolderId(null);
|
||||||
|
}
|
||||||
|
}, [folders.folders, activeFolderId]);
|
||||||
|
|
||||||
// Let notification taps (in-app toast or SW message) open the right chat.
|
// Let notification taps (in-app toast or SW message) open the right chat.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpener((conversationId) => {
|
setOpener((conversationId) => {
|
||||||
|
|
@ -190,21 +195,6 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
}
|
}
|
||||||
}, [conversations]);
|
}, [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.
|
// Keep-alive heartbeat so our own last-seen stays fresh while connected.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
|
|
@ -215,6 +205,8 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const totalUnread = conversations.reduce((sum, item) => sum + item.unreadCount, 0);
|
||||||
|
|
||||||
const startDirectAndOpen = async (username: string): Promise<void> => {
|
const startDirectAndOpen = async (username: string): Promise<void> => {
|
||||||
openConversation(await startDirect(username));
|
openConversation(await startDirect(username));
|
||||||
};
|
};
|
||||||
|
|
@ -223,70 +215,174 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
openConversation(await createGroupChat(title, members));
|
openConversation(await createGroupChat(title, members));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shellClass = [
|
||||||
|
'app-shell',
|
||||||
|
current !== null ? 'has-chat' : '',
|
||||||
|
profileUser !== null ? 'has-profile' : '',
|
||||||
|
]
|
||||||
|
.filter((token) => token !== '')
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className={shellClass}>
|
||||||
<aside className="rail">
|
<aside className="rail">
|
||||||
<div className="rail-header">
|
<div className="rail-header">
|
||||||
|
{railView === 'chats' ? (
|
||||||
<span className="rail-brand">
|
<span className="rail-brand">
|
||||||
<span className="rail-mark" aria-hidden="true">
|
<span className="rail-mark" aria-hidden="true">
|
||||||
A
|
A
|
||||||
</span>
|
</span>
|
||||||
<span className="rail-title">Altricade</span>
|
<span className="rail-title">Altricade</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="rail-header-actions">
|
) : (
|
||||||
<ThemeSwitch />
|
<span className="rail-brand">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={composing ? 'icon-btn accent' : 'icon-btn'}
|
className="icon-btn"
|
||||||
aria-label={composing ? 'Close new chat' : 'New chat'}
|
aria-label="Back to chats"
|
||||||
aria-pressed={composing}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setComposing((value) => !value);
|
setRailView('chats');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{composing ? <CloseIcon /> : <PlusIcon />}
|
<ChevronLeftIcon />
|
||||||
</button>
|
</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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={() => {
|
||||||
|
setRailView('chats');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{composing ? (
|
{railView === 'chats' ? (
|
||||||
<div className="rail-scroll compose">
|
|
||||||
<GroupComposer onCreate={createGroupAndOpen} />
|
|
||||||
<ContactsPanel onStartDirect={startDirectAndOpen} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ConversationSidebar
|
<ConversationSidebar
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
currentId={current?.id ?? null}
|
currentId={current?.id ?? null}
|
||||||
onlineMap={onlineMap}
|
onlineMap={onlineMap}
|
||||||
|
folders={folders}
|
||||||
|
activeFolderId={activeFolderId}
|
||||||
|
onSelectFolder={setActiveFolderId}
|
||||||
onSelect={openConversation}
|
onSelect={openConversation}
|
||||||
onStartDirect={startDirectAndOpen}
|
onStartDirect={startDirectAndOpen}
|
||||||
|
onClearChat={clearChat}
|
||||||
|
onDeleteChat={deleteChat}
|
||||||
/>
|
/>
|
||||||
|
) : railView === 'contacts' ? (
|
||||||
|
<div className="rail-scroll compose">
|
||||||
|
<ContactsPanel onStartDirect={startDirectAndOpen} />
|
||||||
|
</div>
|
||||||
|
) : railView === 'compose' ? (
|
||||||
|
<div className="rail-scroll compose">
|
||||||
|
<GroupComposer onCreate={createGroupAndOpen} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rail-scroll">
|
||||||
|
<SettingsPanel onLogout={onLogout} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rail-footer">
|
{/* Mobile bottom navigation (Telegram-style): Contacts | Chats | Settings */}
|
||||||
<label className="rail-account" title="Change avatar">
|
<nav className="rail-nav" aria-label="Main">
|
||||||
{user.avatarUrl !== null ? (
|
<button
|
||||||
<img src={user.avatarUrl} alt="avatar" className="avatar rail-account-avatar" />
|
type="button"
|
||||||
) : (
|
className="rail-nav-btn"
|
||||||
<span className="avatar rail-account-avatar avatar-placeholder">
|
aria-pressed={railView === 'contacts'}
|
||||||
{user.displayName.charAt(0)}
|
aria-label="Contacts"
|
||||||
</span>
|
onClick={() => {
|
||||||
)}
|
setRailView('contacts');
|
||||||
<input type="file" accept="image/*" hidden onChange={onAvatar} />
|
}}
|
||||||
<span className="rail-account-names">
|
>
|
||||||
<span className="rail-account-name">{user.displayName}</span>
|
<UserIcon size={24} />
|
||||||
<span className="muted rail-account-handle">@{user.username}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" className="icon-btn" aria-label="Log out" onClick={onLogout}>
|
|
||||||
<LogOutIcon />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<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>
|
</aside>
|
||||||
|
|
||||||
{current !== null ? (
|
{current !== null ? (
|
||||||
<ChatView conversation={current} me={me} onlineMap={onlineMap} />
|
<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">
|
<section className="chat-pane chat-empty">
|
||||||
<div className="chat-empty-inner">
|
<div className="chat-empty-inner">
|
||||||
|
|
@ -298,6 +394,20 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{profileUser !== null ? (
|
||||||
|
<ProfilePanel
|
||||||
|
user={profileUser}
|
||||||
|
conversation={profileConversation}
|
||||||
|
online={onlineMap[profileUser.id] === true}
|
||||||
|
onClose={() => {
|
||||||
|
setProfileUser(null);
|
||||||
|
}}
|
||||||
|
onMessage={(username) => {
|
||||||
|
void startDirectAndOpen(username);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1783,3 +1783,899 @@ a {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Context menus + dialogs (Telegram-style)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ctx-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-menu {
|
||||||
|
position: fixed;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--color-surfacePanel) 92%, transparent);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: ctx-in 0.12s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ctx-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item:hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-textMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-item-label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-danger .ctx-item-icon {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-danger:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 6px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick-reactions row on top of the message menu */
|
||||||
|
.ctx-reactions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-react {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: none;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctx-react:hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
transform: scale(1.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm dialog */
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1300;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(8, 9, 13, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: viewer-in var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
width: min(340px, calc(100vw - 2.5rem));
|
||||||
|
padding: 1.25rem 1.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surfacePanel);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn {
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-cancel {
|
||||||
|
color: var(--color-textMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Composer edit banner (Telegram-style)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.composer-area {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-surfacePanel);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-area .composer {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.45rem clamp(0.6rem, 3vw, 1.5rem) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-banner-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-banner-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
border-left: 2px solid var(--color-accent);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-banner-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-banner-preview {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-textMuted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-banner .icon-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat header is now clickable (opens profile) + optional back button */
|
||||||
|
.chat-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
margin-left: -0.4rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-main:not(:disabled):hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-back {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-avatar-btn {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
line-height: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Folder tabs (rail)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.folder-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 0.85rem 0.4rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-tab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-textMuted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-tab:hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-tab[aria-selected='true'] {
|
||||||
|
background: var(--color-accentSoft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-badges {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-mark {
|
||||||
|
color: var(--color-textFaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Profile panel (Telegram-style right sidebar)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.app-shell.has-profile {
|
||||||
|
grid-template-columns: var(--rail-width) 1fr 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--color-surfacePanel);
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 0.6rem 0 1.1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 1.5rem 1rem 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
object-fit: cover;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-presence {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-textMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-presence.is-online {
|
||||||
|
color: var(--color-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-message-btn {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
margin: 0 1rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-value {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-textFaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared media */
|
||||||
|
.profile-shared {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab {
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-textMuted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab[aria-selected='true'] {
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-bottom-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cell {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cell img,
|
||||||
|
.media-cell video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cell-play {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-cell-empty {
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-file:hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-voice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-voice .voice {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-voice-date {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-more {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-more:hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Responsive — single-pane navigation under 900px (list ↔ chat ↔ profile)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-shell,
|
||||||
|
.app-shell.has-profile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One pane at a time: the rail by default, the chat once one is open. */
|
||||||
|
.rail {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell.has-chat .rail {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell:not(.has-chat) .chat-pane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-back {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile becomes a full-screen layer. */
|
||||||
|
.profile-panel {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 900;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roomier touch targets, tighter chrome. */
|
||||||
|
.message-scroll {
|
||||||
|
padding: 0.75rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-wrap {
|
||||||
|
max-width: 86%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-img {
|
||||||
|
max-width: min(280px, 74vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-video {
|
||||||
|
max-width: min(300px, 78vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-video {
|
||||||
|
width: 176px;
|
||||||
|
height: 176px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-header {
|
||||||
|
padding: 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Viewer toolbar clear of notches. */
|
||||||
|
.viewer-toolbar {
|
||||||
|
padding-top: max(0.9rem, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.auth-card {
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth {
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Jump-to-newest button
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.jump-latest {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 84px;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-surfacePanel);
|
||||||
|
color: var(--color-textMuted);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
color var(--dur-fast) var(--ease),
|
||||||
|
transform var(--dur-fast) var(--ease-out);
|
||||||
|
animation: ctx-in 0.15s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-latest:hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
color: var(--color-text);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Settings page
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.5rem 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 1.25rem 0 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
object-fit: cover;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar-edit {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-onAccent);
|
||||||
|
border: 2px solid var(--color-surfacePanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-name:hover {
|
||||||
|
background: var(--color-surfaceHover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-name-edit {
|
||||||
|
color: var(--color-textFaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-name-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: min(260px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-name-input {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-handle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row-label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-logout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-logout:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Mobile bottom navigation (Telegram-style) + profile back button
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.rail-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-back {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.rail-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surfacePanel);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-textFaint);
|
||||||
|
transition: color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-nav-btn[aria-pressed='true'] {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-nav-icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header shortcuts are redundant next to the bottom nav. */
|
||||||
|
.rail-desktop-action {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile page: back arrow left (Telegram-style), no X. */
|
||||||
|
.profile-back {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0 1.1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-title {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-latest {
|
||||||
|
bottom: 76px;
|
||||||
|
right: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { Conversation, Message } from '@altricade/core';
|
import type { Conversation, Message } from '@altricade/core';
|
||||||
import { conversationChannel, userChannel, EventType } 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 { apiConfig } from '../../shared/api';
|
||||||
import { useRealtime } from '../realtime';
|
import { useRealtime } from '../realtime';
|
||||||
|
|
||||||
|
|
@ -11,6 +17,10 @@ export interface UseConversations {
|
||||||
onlineMap: Record<string, boolean>;
|
onlineMap: Record<string, boolean>;
|
||||||
startDirect: (username: string) => Promise<Conversation>;
|
startDirect: (username: string) => Promise<Conversation>;
|
||||||
createGroupChat: (title: string, members: 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 } =>
|
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 } =>
|
const isMessageNew = (data: unknown): data is { type: 'message.new'; message: Message } =>
|
||||||
hasType(data) && data.type === EventType.MessageNew && 'message' in data;
|
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 upsert = (list: Conversation[], conversation: Conversation): Conversation[] => {
|
||||||
const rest = list.filter((item) => item.id !== conversation.id);
|
const rest = list.filter((item) => item.id !== conversation.id);
|
||||||
return [conversation, ...rest];
|
return [conversation, ...rest];
|
||||||
|
|
@ -113,6 +133,15 @@ export const useConversations = (
|
||||||
setConversations((prev) => upsert(prev, conversation));
|
setConversations((prev) => upsert(prev, conversation));
|
||||||
} else if (isMessageNew(event.data)) {
|
} else if (isMessageNew(event.data)) {
|
||||||
bumpUnread(event.data.message);
|
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]);
|
}, [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 };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,48 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement, SyntheticEvent } from 'react';
|
||||||
import type { Conversation, PublicUser } from '@altricade/core';
|
import type { ChatFolder, Conversation, PublicUser } from '@altricade/core';
|
||||||
import { searchUsers } from '@altricade/core/api';
|
import { searchUsers } from '@altricade/core/api';
|
||||||
import { apiConfig } from '../../../shared/api';
|
import { apiConfig } from '../../../shared/api';
|
||||||
import { SearchIcon, UsersIcon, CloseIcon } from '../../../shared/ui';
|
import {
|
||||||
|
SearchIcon,
|
||||||
|
UsersIcon,
|
||||||
|
CloseIcon,
|
||||||
|
PlusIcon,
|
||||||
|
PinIcon,
|
||||||
|
PinOffIcon,
|
||||||
|
FolderIcon,
|
||||||
|
CheckIcon,
|
||||||
|
EraserIcon,
|
||||||
|
TrashIcon,
|
||||||
|
EditIcon,
|
||||||
|
ContextMenu,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
useContextMenu,
|
||||||
|
ConfirmDialog,
|
||||||
|
} from '../../../shared/ui';
|
||||||
|
import type { UseFolders } from '../../folders';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
currentId: string | null;
|
currentId: string | null;
|
||||||
onlineMap: Record<string, boolean>;
|
onlineMap: Record<string, boolean>;
|
||||||
|
folders: UseFolders;
|
||||||
|
activeFolderId: string | null;
|
||||||
|
onSelectFolder: (folderId: string | null) => void;
|
||||||
onSelect: (conversation: Conversation) => void;
|
onSelect: (conversation: Conversation) => void;
|
||||||
onStartDirect: (username: string) => Promise<void>;
|
onStartDirect: (username: string) => Promise<void>;
|
||||||
|
onClearChat: (conversationId: string) => Promise<void>;
|
||||||
|
onDeleteChat: (conversationId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 => {
|
const title = (conversation: Conversation): string => {
|
||||||
if (conversation.type === 'group') {
|
if (conversation.type === 'group') {
|
||||||
return conversation.title ?? 'Group';
|
return conversation.title ?? 'Group';
|
||||||
|
|
@ -62,15 +92,87 @@ const Avatar = ({ conversation }: { conversation: Conversation }): ReactElement
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Small centered dialog with a single text input (folder create/rename).
|
||||||
|
const NameDialog = ({
|
||||||
|
heading,
|
||||||
|
initialValue,
|
||||||
|
submitLabel,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
heading: string;
|
||||||
|
initialValue: string;
|
||||||
|
submitLabel: string;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactElement => {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const submit = (event: SyntheticEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed !== '') {
|
||||||
|
onSubmit(trimmed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<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 = ({
|
export const ConversationSidebar = ({
|
||||||
conversations,
|
conversations,
|
||||||
currentId,
|
currentId,
|
||||||
onlineMap,
|
onlineMap,
|
||||||
|
folders,
|
||||||
|
activeFolderId,
|
||||||
|
onSelectFolder,
|
||||||
onSelect,
|
onSelect,
|
||||||
onStartDirect,
|
onStartDirect,
|
||||||
|
onClearChat,
|
||||||
|
onDeleteChat,
|
||||||
}: Props): ReactElement => {
|
}: Props): ReactElement => {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<PublicUser[]>([]);
|
const [results, setResults] = useState<PublicUser[]>([]);
|
||||||
|
const [dialog, setDialog] = useState<SidebarDialog | null>(null);
|
||||||
|
const chatMenu = useContextMenu<Conversation>();
|
||||||
|
const folderMenu = useContextMenu<ChatFolder>();
|
||||||
|
|
||||||
const runSearch = async (value: string): Promise<void> => {
|
const runSearch = async (value: string): Promise<void> => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
|
|
@ -92,6 +194,27 @@ export const ConversationSidebar = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const searching = query.trim().length > 0;
|
const searching = query.trim().length > 0;
|
||||||
|
const activeFolder =
|
||||||
|
activeFolderId === null
|
||||||
|
? null
|
||||||
|
: (folders.folders.find((folder) => folder.id === activeFolderId) ?? null);
|
||||||
|
|
||||||
|
// Folder scope filter, then pinned-first (per-scope) ordering.
|
||||||
|
const scoped =
|
||||||
|
activeFolder === null
|
||||||
|
? conversations
|
||||||
|
: conversations.filter((conversation) => activeFolder.chatIds.includes(conversation.id));
|
||||||
|
const sorted = [...scoped].sort((a, b) => {
|
||||||
|
const pinA = folders.pinnedAt(a.id, activeFolderId);
|
||||||
|
const pinB = folders.pinnedAt(b.id, activeFolderId);
|
||||||
|
if (pinA !== null && pinB !== null) {
|
||||||
|
return pinB.localeCompare(pinA);
|
||||||
|
}
|
||||||
|
if (pinA !== null || pinB !== null) {
|
||||||
|
return pinA !== null ? -1 : 1;
|
||||||
|
}
|
||||||
|
return b.lastMessageAt.localeCompare(a.lastMessageAt);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -120,6 +243,50 @@ export const ConversationSidebar = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
|
))}
|
||||||
|
<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}
|
||||||
|
|
||||||
<div className="rail-scroll">
|
<div className="rail-scroll">
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<div className="rail-section">
|
<div className="rail-section">
|
||||||
|
|
@ -157,13 +324,16 @@ export const ConversationSidebar = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="conv-list">
|
<div className="conv-list">
|
||||||
{conversations.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<p className="rail-empty">No conversations yet</p>
|
<p className="rail-empty">
|
||||||
|
{activeFolder === null ? 'No conversations yet' : 'This folder is empty'}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
conversations.map((conversation) => {
|
sorted.map((conversation) => {
|
||||||
const online =
|
const online =
|
||||||
conversation.peer !== null && onlineMap[conversation.peer.id] === true;
|
conversation.peer !== null && onlineMap[conversation.peer.id] === true;
|
||||||
const active = conversation.id === currentId;
|
const active = conversation.id === currentId;
|
||||||
|
const pinned = folders.isPinned(conversation.id, activeFolderId);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={conversation.id}
|
key={conversation.id}
|
||||||
|
|
@ -173,6 +343,9 @@ export const ConversationSidebar = ({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(conversation);
|
onSelect(conversation);
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
chatMenu.openAt(event, conversation);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="conv-avatar-wrap">
|
<span className="conv-avatar-wrap">
|
||||||
<Avatar conversation={conversation} />
|
<Avatar conversation={conversation} />
|
||||||
|
|
@ -185,11 +358,14 @@ export const ConversationSidebar = ({
|
||||||
</span>
|
</span>
|
||||||
<span className="conv-sub">
|
<span className="conv-sub">
|
||||||
<span className="conv-preview">{subtitle(conversation)}</span>
|
<span className="conv-preview">{subtitle(conversation)}</span>
|
||||||
|
<span className="conv-badges">
|
||||||
|
{pinned ? <PinIcon size={14} className="pin-mark" /> : null}
|
||||||
{conversation.unreadCount > 0 ? (
|
{conversation.unreadCount > 0 ? (
|
||||||
<span className="unread-badge">{conversation.unreadCount}</span>
|
<span className="unread-badge">{conversation.unreadCount}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -197,6 +373,206 @@ export const ConversationSidebar = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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={() => {
|
||||||
|
chatMenu.close();
|
||||||
|
if (target !== undefined) {
|
||||||
|
void folders.toggleChat(folder.id, target.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<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}
|
||||||
|
|
||||||
|
{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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
2
packages/web/src/features/folders/index.ts
Normal file
2
packages/web/src/features/folders/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { useFolders } from './model';
|
||||||
|
export type { UseFolders } from './model';
|
||||||
121
packages/web/src/features/folders/model.ts
Normal file
121
packages/web/src/features/folders/model.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
export { useConversationMessages } from './model';
|
export { useConversationMessages } from './model';
|
||||||
export type { UseConversationMessages } from './model';
|
export type { UseConversationMessages } from './model';
|
||||||
export { ChatView } from './ui/ChatView';
|
export { ChatView } from './ui/ChatView';
|
||||||
|
export { VoiceMessage } from './ui/VoiceMessage';
|
||||||
|
export { MediaViewerProvider, useMediaViewer } from './ui/MediaViewer';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import type {
|
||||||
MessageNewEvent,
|
MessageNewEvent,
|
||||||
MessageEditEvent,
|
MessageEditEvent,
|
||||||
MessageDeleteEvent,
|
MessageDeleteEvent,
|
||||||
|
MessageHiddenEvent,
|
||||||
|
ConversationClearedEvent,
|
||||||
ReactionEvent,
|
ReactionEvent,
|
||||||
ReadReceiptEvent,
|
ReadReceiptEvent,
|
||||||
TypingEvent,
|
TypingEvent,
|
||||||
|
|
@ -26,6 +28,7 @@ import {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
editMessage as apiEdit,
|
editMessage as apiEdit,
|
||||||
deleteMessage as apiDelete,
|
deleteMessage as apiDelete,
|
||||||
|
hideMessage as apiHide,
|
||||||
addReaction,
|
addReaction,
|
||||||
removeReaction,
|
removeReaction,
|
||||||
markRead,
|
markRead,
|
||||||
|
|
@ -43,9 +46,12 @@ export interface UseConversationMessages {
|
||||||
/** For a direct conversation: the peer's last-read seq (drives ✓✓). */
|
/** For a direct conversation: the peer's last-read seq (drives ✓✓). */
|
||||||
peerReadSeq: number;
|
peerReadSeq: number;
|
||||||
send: (content: string) => Promise<void>;
|
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>;
|
edit: (messageId: string, content: string) => Promise<void>;
|
||||||
|
/** Delete for everyone (sender, or group owner as moderation). */
|
||||||
remove: (messageId: string) => Promise<void>;
|
remove: (messageId: string) => Promise<void>;
|
||||||
|
/** Delete for me only. */
|
||||||
|
hide: (messageId: string) => Promise<void>;
|
||||||
toggleReaction: (message: Message, emoji: string) => Promise<void>;
|
toggleReaction: (message: Message, emoji: string) => Promise<void>;
|
||||||
notifyTyping: () => void;
|
notifyTyping: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +73,10 @@ const isDeleteEvent = (d: unknown): d is MessageDeleteEvent =>
|
||||||
const isReactionEvent = (d: unknown): d is ReactionEvent =>
|
const isReactionEvent = (d: unknown): d is ReactionEvent =>
|
||||||
hasType(d) && (d.type === EventType.ReactionAdd || d.type === EventType.ReactionRemove);
|
hasType(d) && (d.type === EventType.ReactionAdd || d.type === EventType.ReactionRemove);
|
||||||
const isReadEvent = (d: unknown): d is ReadReceiptEvent => hasType(d) && d.type === EventType.ReadReceipt;
|
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 =>
|
const isTypingEvent = (d: unknown): d is TypingEvent =>
|
||||||
hasType(d) && (d.type === EventType.TypingStart || d.type === EventType.TypingStop);
|
hasType(d) && (d.type === EventType.TypingStart || d.type === EventType.TypingStop);
|
||||||
|
|
||||||
|
|
@ -104,8 +114,10 @@ export const useConversationMessages = (
|
||||||
const lastTypingSent = useRef(0);
|
const lastTypingSent = useRef(0);
|
||||||
|
|
||||||
const conversationId = conversation.id;
|
const conversationId = conversation.id;
|
||||||
const channel =
|
// Groups deliver on the conversation channel; DMs on the personal channel.
|
||||||
conversation.type === 'group' ? conversationChannel(conversationId) : userChannel(me.id);
|
// 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -161,6 +173,22 @@ export const useConversationMessages = (
|
||||||
}
|
}
|
||||||
return;
|
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 (isTypingEvent(data)) {
|
||||||
if (data.conversationId !== conversationId || data.userId === me.id) {
|
if (data.conversationId !== conversationId || data.userId === me.id) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -184,8 +212,11 @@ export const useConversationMessages = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Durable events arrive on the message channel; typing on the ephemeral channel.
|
// Durable events arrive on the personal channel (DMs + per-user view state)
|
||||||
const unsubMessages = subscribe(channel, handler);
|
// 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 unsubEphemeral = subscribe(ephemeralChannel(conversationId), handler);
|
||||||
|
|
||||||
const load = async (): Promise<void> => {
|
const load = async (): Promise<void> => {
|
||||||
|
|
@ -204,14 +235,15 @@ export const useConversationMessages = (
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubMessages();
|
unsubUser();
|
||||||
|
unsubGroup?.();
|
||||||
unsubEphemeral();
|
unsubEphemeral();
|
||||||
for (const timer of timers.values()) {
|
for (const timer of timers.values()) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
timers.clear();
|
timers.clear();
|
||||||
};
|
};
|
||||||
}, [conversationId, channel, subscribe, me.id]);
|
}, [conversationId, groupChannel, subscribe, me.id]);
|
||||||
|
|
||||||
// Mark the conversation read up to the newest confirmed message.
|
// Mark the conversation read up to the newest confirmed message.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -250,9 +282,9 @@ export const useConversationMessages = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendMedia = useCallback(
|
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 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 });
|
const { uploadUrl, objectKey } = await getUploadUrl(apiConfig, { kind, mime, size: file.size });
|
||||||
await uploadToUrl(uploadUrl, file, mime);
|
await uploadToUrl(uploadUrl, file, mime);
|
||||||
const media: MediaRef = { kind, mime, size: file.size, name: file.name };
|
const media: MediaRef = { kind, mime, size: file.size, name: file.name };
|
||||||
|
|
@ -283,6 +315,15 @@ export const useConversationMessages = (
|
||||||
[conversationId],
|
[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(
|
const toggleReaction = useCallback(
|
||||||
async (message: Message, emoji: string): Promise<void> => {
|
async (message: Message, emoji: string): Promise<void> => {
|
||||||
const mine = message.reactions.some((r) => r.emoji === emoji && r.mine);
|
const mine = message.reactions.some((r) => r.emoji === emoji && r.mine);
|
||||||
|
|
@ -314,6 +355,7 @@ export const useConversationMessages = (
|
||||||
sendMedia,
|
sendMedia,
|
||||||
edit,
|
edit,
|
||||||
remove,
|
remove,
|
||||||
|
hide,
|
||||||
toggleReaction,
|
toggleReaction,
|
||||||
notifyTyping,
|
notifyTyping,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ export interface Recorder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-app voice/video message capture via MediaRecorder. Produces a webm File and
|
// 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.
|
// hands it to `onComplete` with the record kind, which uploads + sends it as a
|
||||||
export const useRecorder = (onComplete: (file: File) => void): Recorder => {
|
// 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 [recording, setRecording] = useState<RecordKind | null>(null);
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null);
|
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 isVideo = kindRef.current === 'video';
|
||||||
const type = isVideo ? 'video/webm' : 'audio/webm';
|
const type = isVideo ? 'video/webm' : 'audio/webm';
|
||||||
const name = isVideo ? 'video-message.webm' : 'voice-message.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;
|
recorderRef.current = null;
|
||||||
setRecording(null);
|
setRecording(null);
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,30 @@ import {
|
||||||
DoubleCheckIcon,
|
DoubleCheckIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
CopyIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ContextMenu,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
useContextMenu,
|
||||||
|
ConfirmDialog,
|
||||||
} from '../../../shared/ui';
|
} from '../../../shared/ui';
|
||||||
|
import type { DialogAction } from '../../../shared/ui';
|
||||||
import { useConversationMessages } from '../model';
|
import { useConversationMessages } from '../model';
|
||||||
import { useRecorder } from '../recorder';
|
import { useRecorder } from '../recorder';
|
||||||
import { MediaMessage, isRoundVideo } from './MediaMessage';
|
import { MediaMessage } from './MediaMessage';
|
||||||
import { MediaViewerProvider } from './MediaViewer';
|
import { MediaViewerProvider } from './MediaViewer';
|
||||||
|
|
||||||
const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉'];
|
const QUICK_REACTIONS = ['👍', '❤️', '😂', '🎉', '🔥', '👎'];
|
||||||
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
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 formatElapsed = (ms: number): string => {
|
||||||
const total = Math.floor(ms / 1000);
|
const total = Math.floor(ms / 1000);
|
||||||
|
|
@ -66,6 +81,10 @@ interface Props {
|
||||||
conversation: Conversation;
|
conversation: Conversation;
|
||||||
me: PublicUser;
|
me: PublicUser;
|
||||||
onlineMap: Record<string, boolean>;
|
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 headerTitle = (conversation: Conversation): string => {
|
const headerTitle = (conversation: Conversation): string => {
|
||||||
|
|
@ -82,22 +101,36 @@ const ChatHeader = ({
|
||||||
conversation,
|
conversation,
|
||||||
online,
|
online,
|
||||||
typing,
|
typing,
|
||||||
|
onOpenProfile,
|
||||||
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
conversation: Conversation;
|
conversation: Conversation;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
typing: boolean;
|
typing: boolean;
|
||||||
|
onOpenProfile?: ((user: PublicUser) => void) | undefined;
|
||||||
|
onBack?: (() => void) | undefined;
|
||||||
}): ReactElement => {
|
}): ReactElement => {
|
||||||
const peer = conversation.peer;
|
const peer = conversation.peer;
|
||||||
const isGroup = conversation.type === 'group';
|
const isGroup = conversation.type === 'group';
|
||||||
const subtitle = typing
|
const subtitle = typing ? 'typing…' : isGroup ? 'Group' : online ? 'online' : 'offline';
|
||||||
? 'typing…'
|
const openable = peer !== null && onOpenProfile !== undefined;
|
||||||
: isGroup
|
|
||||||
? 'Group'
|
|
||||||
: online
|
|
||||||
? 'online'
|
|
||||||
: 'offline';
|
|
||||||
return (
|
return (
|
||||||
<header className="chat-header">
|
<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 ? (
|
{peer !== null && peer.avatarUrl !== null ? (
|
||||||
<img src={peer.avatarUrl} alt="" className="avatar chat-header-avatar" />
|
<img src={peer.avatarUrl} alt="" className="avatar chat-header-avatar" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -105,18 +138,19 @@ const ChatHeader = ({
|
||||||
{isGroup ? <UsersIcon size={20} /> : headerTitle(conversation).charAt(0)}
|
{isGroup ? <UsersIcon size={20} /> : headerTitle(conversation).charAt(0)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="chat-header-text">
|
<span className="chat-header-text">
|
||||||
<span className="chat-header-title">{headerTitle(conversation)}</span>
|
<span className="chat-header-title">{headerTitle(conversation)}</span>
|
||||||
<span className={typing ? 'chat-header-sub typing-sub' : 'chat-header-sub'}>
|
<span className={typing ? 'chat-header-sub typing-sub' : 'chat-header-sub'}>
|
||||||
{!isGroup && online && !typing ? <span className="online-inline" /> : null}
|
{!isGroup && online && !typing ? <span className="online-inline" /> : null}
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement => {
|
export const ChatView = ({ conversation, me, onlineMap, onOpenProfile, onBack }: Props): ReactElement => {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -126,20 +160,28 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
sendMedia,
|
sendMedia,
|
||||||
edit,
|
edit,
|
||||||
remove,
|
remove,
|
||||||
|
hide,
|
||||||
toggleReaction,
|
toggleReaction,
|
||||||
notifyTyping,
|
notifyTyping,
|
||||||
} = useConversationMessages(conversation, me);
|
} = useConversationMessages(conversation, me);
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editing, setEditing] = useState<Message | null>(null);
|
||||||
const [draft, setDraft] = useState('');
|
const [deleteTarget, setDeleteTarget] = useState<Message | null>(null);
|
||||||
const recorder = useRecorder((file) => {
|
const menu = useContextMenu<Message>();
|
||||||
void sendMedia(file, '');
|
const recorder = useRecorder((file, kind) => {
|
||||||
|
void sendMedia(file, '', kind === 'video' ? 'video_note' : 'voice');
|
||||||
});
|
});
|
||||||
const previewRef = useRef<HTMLVideoElement>(null);
|
const previewRef = useRef<HTMLVideoElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const atBottomRef = useRef(true);
|
const atBottomRef = useRef(true);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
const [showJump, setShowJump] = useState(false);
|
||||||
const isGroup = conversation.type === 'group';
|
const isGroup = conversation.type === 'group';
|
||||||
const peerOnline = conversation.peer !== null && onlineMap[conversation.peer.id] === true;
|
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.
|
// Mirror the live camera stream into the in-composer preview while recording.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -149,25 +191,95 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
}
|
}
|
||||||
}, [recorder.previewStream]);
|
}, [recorder.previewStream]);
|
||||||
|
|
||||||
// Jump to the newest message when opening a conversation.
|
// Reset per-conversation view state; the scroll position is restored below
|
||||||
|
// once history has loaded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
restoredRef.current = false;
|
||||||
atBottomRef.current = true;
|
atBottomRef.current = true;
|
||||||
|
setShowJump(false);
|
||||||
|
setEditing(null);
|
||||||
|
setText('');
|
||||||
|
setDeleteTarget(null);
|
||||||
}, [conversation.id]);
|
}, [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
|
// 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.
|
// is already near the bottom, so scrolling up through history isn't yanked.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const node = scrollRef.current;
|
const node = scrollRef.current;
|
||||||
if (node !== null && atBottomRef.current) {
|
if (node !== null && restoredRef.current && atBottomRef.current) {
|
||||||
node.scrollTop = node.scrollHeight;
|
node.scrollTop = node.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [messages.length, typingUserIds.length]);
|
}, [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 onScroll = (): void => {
|
||||||
const node = scrollRef.current;
|
const node = scrollRef.current;
|
||||||
if (node !== null) {
|
if (node === null || !restoredRef.current) {
|
||||||
atBottomRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80;
|
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> => {
|
const submit = async (event: SyntheticEvent): Promise<void> => {
|
||||||
|
|
@ -176,6 +288,15 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
if (trimmed === '') {
|
if (trimmed === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (editing !== null) {
|
||||||
|
const target = editing;
|
||||||
|
setEditing(null);
|
||||||
|
setText('');
|
||||||
|
if (trimmed !== target.content) {
|
||||||
|
await edit(target.id, trimmed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setText('');
|
setText('');
|
||||||
await send(trimmed);
|
await send(trimmed);
|
||||||
};
|
};
|
||||||
|
|
@ -185,6 +306,9 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void submit(event);
|
void submit(event);
|
||||||
}
|
}
|
||||||
|
if (event.key === 'Escape' && editing !== null) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAttach = (event: SyntheticEvent<HTMLInputElement>): void => {
|
const onAttach = (event: SyntheticEvent<HTMLInputElement>): void => {
|
||||||
|
|
@ -196,17 +320,29 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
input.value = '';
|
input.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (message: Message): void => {
|
const deleteActions = (message: Message): DialogAction[] => {
|
||||||
setEditingId(message.id);
|
const actions: DialogAction[] = [];
|
||||||
setDraft(message.content);
|
if (message.senderId === me.id || canModerate) {
|
||||||
};
|
actions.push({
|
||||||
|
key: 'everyone',
|
||||||
const commitEdit = async (messageId: string): Promise<void> => {
|
label: 'Delete for everyone',
|
||||||
const next = draft.trim();
|
danger: true,
|
||||||
setEditingId(null);
|
onSelect: () => {
|
||||||
if (next !== '') {
|
setDeleteTarget(null);
|
||||||
await edit(messageId, next);
|
void remove(message.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
actions.push({
|
||||||
|
key: 'me',
|
||||||
|
label: 'Delete for me',
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
void hide(message.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return actions;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tick = (message: Message): ReactElement | null => {
|
const tick = (message: Message): ReactElement | null => {
|
||||||
|
|
@ -228,25 +364,26 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
online={peerOnline}
|
online={peerOnline}
|
||||||
typing={typingUserIds.length > 0}
|
typing={typingUserIds.length > 0}
|
||||||
|
onOpenProfile={onOpenProfile}
|
||||||
|
onBack={onBack}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="message-scroll" ref={scrollRef} onScroll={onScroll}>
|
<div className="message-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||||
{loading ? <p className="chat-loading muted">Loading…</p> : null}
|
{loading ? <p className="chat-loading muted">Loading…</p> : null}
|
||||||
<div className="message-list">
|
<div className="message-list" ref={listRef}>
|
||||||
{messages.map((message, index) => {
|
{messages.map((message, index) => {
|
||||||
const prev = index > 0 ? messages[index - 1] : undefined;
|
const prev = index > 0 ? messages[index - 1] : undefined;
|
||||||
const next = index < messages.length - 1 ? messages[index + 1] : undefined;
|
const next = index < messages.length - 1 ? messages[index + 1] : undefined;
|
||||||
const mine = message.senderId === me.id;
|
const mine = message.senderId === me.id;
|
||||||
const newDay = prev === undefined || dayKey(prev.createdAt) !== dayKey(message.createdAt);
|
const newDay =
|
||||||
|
prev === undefined || dayKey(prev.createdAt) !== dayKey(message.createdAt);
|
||||||
const groupStart = newDay || !inSameGroup(message, prev);
|
const groupStart = newDay || !inSameGroup(message, prev);
|
||||||
const groupEnd = !inSameGroup(message, next);
|
const groupEnd = !inSameGroup(message, next);
|
||||||
const showAvatar = !mine && isGroup;
|
const showAvatar = !mine && isGroup;
|
||||||
const deleted = message.deletedAt !== null;
|
const deleted = message.deletedAt !== null;
|
||||||
const editing = editingId === message.id;
|
|
||||||
const media = message.media;
|
const media = message.media;
|
||||||
// Round video notes render without bubble chrome (like Telegram).
|
// Round video notes render without bubble chrome (like Telegram).
|
||||||
const bareMedia =
|
const bareMedia = media !== null && !deleted && media.kind === 'video_note';
|
||||||
media !== null && !deleted && media.kind === 'video' && isRoundVideo(media.name);
|
|
||||||
const hasMedia = media !== null && !deleted && !bareMedia;
|
const hasMedia = media !== null && !deleted && !bareMedia;
|
||||||
|
|
||||||
const lineClass = [
|
const lineClass = [
|
||||||
|
|
@ -279,13 +416,22 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
<div className={lineClass}>
|
<div className={lineClass}>
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
groupEnd ? (
|
groupEnd ? (
|
||||||
message.sender.avatarUrl !== null ? (
|
<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" />
|
<img src={message.sender.avatarUrl} alt="" className="avatar msg-avatar" />
|
||||||
) : (
|
) : (
|
||||||
<span className="avatar msg-avatar avatar-placeholder">
|
<span className="avatar msg-avatar avatar-placeholder">
|
||||||
{message.sender.displayName.charAt(0)}
|
{message.sender.displayName.charAt(0)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)}
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="msg-avatar-spacer" />
|
<span className="msg-avatar-spacer" />
|
||||||
)
|
)
|
||||||
|
|
@ -296,54 +442,21 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
<span className="msg-sender">{message.sender.displayName}</span>
|
<span className="msg-sender">{message.sender.displayName}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={bubbleClass}>
|
<div
|
||||||
{editing ? (
|
className={bubbleClass}
|
||||||
<div className="bubble-edit">
|
onContextMenu={
|
||||||
<textarea
|
deleted
|
||||||
className="edit-input"
|
? undefined
|
||||||
value={draft}
|
: (event) => {
|
||||||
autoFocus
|
menu.openAt(event, message);
|
||||||
onChange={(event) => {
|
|
||||||
setDraft(event.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
void commitEdit(message.id);
|
|
||||||
}
|
}
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setEditingId(null);
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="edit-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-btn edit-cancel"
|
|
||||||
aria-label="Cancel"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingId(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CloseIcon size={17} />
|
{deleted ? (
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-btn accent edit-save"
|
|
||||||
aria-label="Save"
|
|
||||||
onClick={() => {
|
|
||||||
void commitEdit(message.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckIcon size={17} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : deleted ? (
|
|
||||||
<span className="bubble-deleted">Message deleted</span>
|
<span className="bubble-deleted">Message deleted</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{message.media !== null ? (
|
{media !== null ? (
|
||||||
<MediaMessage
|
<MediaMessage
|
||||||
conversationId={conversation.id}
|
conversationId={conversation.id}
|
||||||
message={message}
|
message={message}
|
||||||
|
|
@ -362,49 +475,6 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!deleted && !editing ? (
|
|
||||||
<div className="msg-tools">
|
|
||||||
{QUICK_REACTIONS.map((emoji) => (
|
|
||||||
<button
|
|
||||||
key={emoji}
|
|
||||||
type="button"
|
|
||||||
className="tool-react"
|
|
||||||
aria-label={`React ${emoji}`}
|
|
||||||
onClick={() => {
|
|
||||||
void toggleReaction(message, emoji);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{mine ? (
|
|
||||||
<>
|
|
||||||
<span className="tool-divider" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tool-btn"
|
|
||||||
aria-label="Edit"
|
|
||||||
onClick={() => {
|
|
||||||
startEdit(message);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tool-btn tool-danger"
|
|
||||||
aria-label="Delete"
|
|
||||||
onClick={() => {
|
|
||||||
void remove(message.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashIcon size={16} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message.reactions.length > 0 ? (
|
{message.reactions.length > 0 ? (
|
||||||
|
|
@ -432,6 +502,17 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showJump ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="jump-latest"
|
||||||
|
aria-label="Scroll to newest message"
|
||||||
|
onClick={jumpToLatest}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon size={22} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{recorder.recording !== null ? (
|
{recorder.recording !== null ? (
|
||||||
<div className="composer recording-bar">
|
<div className="composer recording-bar">
|
||||||
{recorder.recording === 'video' ? (
|
{recorder.recording === 'video' ? (
|
||||||
|
|
@ -455,22 +536,45 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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)}>
|
<form className="composer" onSubmit={(event) => void submit(event)}>
|
||||||
<label className="icon-btn" title="Attach file">
|
<label className="icon-btn" title="Attach file">
|
||||||
<PaperclipIcon />
|
<PaperclipIcon />
|
||||||
<input type="file" hidden onChange={onAttach} />
|
<input type="file" hidden onChange={onAttach} />
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={composerRef}
|
||||||
className="composer-input"
|
className="composer-input"
|
||||||
rows={1}
|
rows={1}
|
||||||
value={text}
|
value={text}
|
||||||
placeholder="Message…"
|
placeholder={editing !== null ? 'Edit message…' : 'Message…'}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setText(event.target.value);
|
setText(event.target.value);
|
||||||
|
if (editing === null) {
|
||||||
notifyTyping();
|
notifyTyping();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={onComposerKey}
|
onKeyDown={onComposerKey}
|
||||||
/>
|
/>
|
||||||
|
{editing === null ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
|
|
@ -491,16 +595,93 @@ export const ChatView = ({ conversation, me, onlineMap }: Props): ReactElement =
|
||||||
>
|
>
|
||||||
<VideoIcon />
|
<VideoIcon />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="icon-btn accent composer-send"
|
className="icon-btn accent composer-send"
|
||||||
title="Send"
|
title={editing !== null ? 'Save' : 'Send'}
|
||||||
disabled={text.trim() === ''}
|
disabled={text.trim() === ''}
|
||||||
>
|
>
|
||||||
<SendIcon />
|
{editing !== null ? <CheckIcon /> : <SendIcon />}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</MediaViewerProvider>
|
</MediaViewerProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,6 @@ const formatSize = (bytes: number): string => {
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Recorded video messages carry this fixed name (see features/messaging/recorder.ts)
|
|
||||||
// and render as a circular "video note"; any other video is a regular file player.
|
|
||||||
export const isRoundVideo = (name: string): boolean => name.startsWith('video-message');
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
|
|
@ -61,10 +57,11 @@ export const MediaMessage = ({ conversationId, message, mine }: Props): ReactEle
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.kind === 'video') {
|
if (media.kind === 'video_note') {
|
||||||
if (isRoundVideo(media.name)) {
|
|
||||||
return <VideoMessage url={url} durationSec={media.durationSec} />;
|
return <VideoMessage url={url} durationSec={media.durationSec} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.kind === 'video') {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
1
packages/web/src/features/profile/index.ts
Normal file
1
packages/web/src/features/profile/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { ProfilePanel } from './ui/ProfilePanel';
|
||||||
349
packages/web/src/features/profile/ui/ProfilePanel.tsx
Normal file
349
packages/web/src/features/profile/ui/ProfilePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
packages/web/src/features/settings/index.ts
Normal file
1
packages/web/src/features/settings/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { SettingsPanel } from './ui/SettingsPanel';
|
||||||
161
packages/web/src/features/settings/ui/SettingsPanel.tsx
Normal file
161
packages/web/src/features/settings/ui/SettingsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
74
packages/web/src/shared/ui/ConfirmDialog.tsx
Normal file
74
packages/web/src/shared/ui/ConfirmDialog.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
};
|
||||||
119
packages/web/src/shared/ui/ContextMenu.tsx
Normal file
119
packages/web/src/shared/ui/ContextMenu.tsx
Normal 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" />;
|
||||||
|
|
@ -248,6 +248,84 @@ export const UsersIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 =>
|
export const ReplyIcon = ({ size = 20, className }: IconProps): ReactElement =>
|
||||||
base(
|
base(
|
||||||
size,
|
size,
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,17 @@ export {
|
||||||
SmileIcon,
|
SmileIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
|
PinIcon,
|
||||||
|
PinOffIcon,
|
||||||
|
FolderIcon,
|
||||||
|
CopyIcon,
|
||||||
|
EraserIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
ChatsIcon,
|
||||||
|
UserIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
export { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from './ContextMenu';
|
||||||
|
export type { ContextMenuState, UseContextMenu } from './ContextMenu';
|
||||||
|
export { ConfirmDialog } from './ConfirmDialog';
|
||||||
|
export type { DialogAction } from './ConfirmDialog';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue