Messenger/packages/backend/src/modules/messages/messages.service.ts
Заид Омар Медхат | 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

249 lines
8.8 KiB
TypeScript

import { EventType, userChannel } from '@altricade/core';
import type {
Message,
MessageNewEvent,
MessageEditEvent,
MessageDeleteEvent,
MessageHiddenEvent,
ReactionEvent,
SendMessageBody,
MediaTab,
} from '@altricade/core';
import { HttpError } from '../../shared/http-error';
import type { Publisher } from '../../shared/publisher';
import type { ConversationsRepository } from '../conversations';
import type { Deliver } from '../conversations';
import type { MessagesRepository } from './messages.repository';
import { toMessage } from './messages.mapper';
export interface MessagesServiceDeps {
messages: MessagesRepository;
conversations: ConversationsRepository;
deliver: Deliver;
/** Personal-channel publisher for per-user view-state events. */
publish: Publisher;
mediaDownloadUrl: (objectKey: string) => Promise<string>;
/** Fire-and-forget push-notification hook, called for each newly-created message. */
notify: (message: Message) => void;
}
// Telegram-style shared-media tabs → media kinds.
const TAB_KINDS: Record<MediaTab, string[]> = {
media: ['image', 'video', 'video_note'],
files: ['file'],
voice: ['voice'],
};
export interface SentMessage {
message: Message;
created: boolean;
}
export interface MessagesService {
send(conversationId: string, senderId: string, input: SendMessageBody): Promise<SentMessage>;
history(
conversationId: string,
userId: string,
beforeSeq: number | null,
limit: number,
): Promise<Message[]>;
edit(
conversationId: string,
messageId: string,
userId: string,
content: string,
): Promise<Message>;
remove(conversationId: string, messageId: string, userId: string): Promise<void>;
hide(conversationId: string, messageId: string, userId: string): Promise<void>;
media(
conversationId: string,
userId: string,
tab: MediaTab,
beforeSeq: number | null,
limit: number,
): Promise<Message[]>;
addReaction(
conversationId: string,
messageId: string,
userId: string,
emoji: string,
): Promise<void>;
removeReaction(
conversationId: string,
messageId: string,
userId: string,
emoji: string,
): Promise<void>;
mediaUrl(conversationId: string, messageId: string, userId: string): Promise<string>;
}
export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => {
const { messages, conversations, deliver, publish, mediaDownloadUrl, notify } = deps;
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
if (!(await conversations.isMember(conversationId, userId))) {
throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation');
}
};
const assertMessageIn = async (conversationId: string, messageId: string): Promise<void> => {
if ((await messages.getConversationId(messageId)) !== conversationId) {
throw new HttpError(404, 'not_found', 'Message not found');
}
};
const loadMessage = async (messageId: string, userId: string): Promise<Message> => {
const row = await messages.getWithSenderById(messageId);
if (row === undefined) {
throw new HttpError(404, 'not_found', 'Message not found');
}
const reactions = await messages.reactionsFor([messageId], userId);
return toMessage(row, reactions.get(messageId) ?? []);
};
return {
send: async (conversationId, senderId, input) => {
await assertMember(conversationId, senderId);
const inserted = await messages.insert({
conversationId,
senderId,
clientMsgId: input.clientMsgId,
content: input.content ?? '',
contentType: input.contentType ?? 'text',
encryption: input.encryption ?? null,
mediaKey: input.mediaKey ?? null,
media: input.media ?? null,
});
const id =
inserted?.id ??
(await messages.findIdByDedupe(conversationId, senderId, input.clientMsgId));
if (id === undefined) {
throw new HttpError(500, 'internal_error', 'Message could not be persisted');
}
const row = await messages.getWithSenderById(id);
if (row === undefined) {
throw new HttpError(500, 'internal_error', 'Message not found after insert');
}
const message = toMessage(row, []);
if (inserted !== undefined) {
await conversations.touchLastMessage(conversationId);
const event: MessageNewEvent = { type: EventType.MessageNew, message };
await deliver(conversationId, event);
// Enqueue a push job (offline recipients only; online ones get it live).
notify(message);
}
return { message, created: inserted !== undefined };
},
history: async (conversationId, userId, beforeSeq, limit) => {
await assertMember(conversationId, userId);
const rows = await messages.listHistory(conversationId, userId, beforeSeq, limit);
const reactions = await messages.reactionsFor(
rows.map((row) => row.id),
userId,
);
return rows.map((row) => toMessage(row, reactions.get(row.id) ?? []));
},
media: async (conversationId, userId, tab, beforeSeq, limit) => {
await assertMember(conversationId, userId);
const rows = await messages.listMedia(conversationId, userId, TAB_KINDS[tab], beforeSeq, limit);
return rows.map((row) => toMessage(row, []));
},
edit: async (conversationId, messageId, userId, content) => {
await assertMember(conversationId, userId);
await assertMessageIn(conversationId, messageId);
const edited = await messages.editContent(messageId, userId, content);
if (edited === undefined) {
throw new HttpError(403, 'not_editable', 'You can only edit your own messages');
}
const message = await loadMessage(messageId, userId);
const event: MessageEditEvent = { type: EventType.MessageEdit, message };
await deliver(conversationId, event);
return message;
},
remove: async (conversationId, messageId, userId) => {
await assertMember(conversationId, userId);
await assertMessageIn(conversationId, messageId);
let deleted = await messages.softDelete(messageId, userId);
if (deleted === undefined) {
// Not the sender: the owner of a group may moderate any message in it.
const conversation = await conversations.findById(conversationId);
const role = await conversations.getRole(conversationId, userId);
if (conversation?.type !== 'group' || role !== 'owner') {
throw new HttpError(
403,
'not_deletable',
'You can only delete your own messages (group owners can delete any)',
);
}
deleted = await messages.softDeleteAny(messageId);
}
if (deleted === undefined) {
// Already deleted — idempotent success, nothing to broadcast.
return;
}
const event: MessageDeleteEvent = { type: EventType.MessageDelete, conversationId, messageId };
await deliver(conversationId, event);
},
hide: async (conversationId, messageId, userId) => {
await assertMember(conversationId, userId);
await assertMessageIn(conversationId, messageId);
await messages.hide(messageId, userId);
// Personal channel only — other participants are unaffected by design.
const event: MessageHiddenEvent = { type: EventType.MessageHidden, conversationId, messageId };
await publish(userChannel(userId), event);
},
addReaction: async (conversationId, messageId, userId, emoji) => {
await assertMember(conversationId, userId);
await assertMessageIn(conversationId, messageId);
await messages.addReaction(messageId, userId, emoji);
const event: ReactionEvent = {
type: EventType.ReactionAdd,
conversationId,
messageId,
emoji,
userId,
};
await deliver(conversationId, event);
},
removeReaction: async (conversationId, messageId, userId, emoji) => {
await assertMember(conversationId, userId);
await assertMessageIn(conversationId, messageId);
await messages.removeReaction(messageId, userId, emoji);
const event: ReactionEvent = {
type: EventType.ReactionRemove,
conversationId,
messageId,
emoji,
userId,
};
await deliver(conversationId, event);
},
mediaUrl: async (conversationId, messageId, userId) => {
await assertMember(conversationId, userId);
await assertMessageIn(conversationId, messageId);
const row = await messages.getWithSenderById(messageId);
const mediaKey = row?.media_key ?? null;
if (mediaKey === null) {
throw new HttpError(404, 'not_found', 'No media on this message');
}
return mediaDownloadUrl(mediaKey);
},
};
};
declare module 'fastify' {
interface FastifyInstance {
messagesService: MessagesService;
}
}