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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-11 01:53:34 +05:00

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