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
86 lines
3.6 KiB
JavaScript
86 lines
3.6 KiB
JavaScript
// 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');
|
|
};
|