Messenger/packages/backend/src/modules/media/media.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

77 lines
2.4 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import type { Client } from 'minio';
import type { MediaKind } from '@altricade/core';
import { HttpError } from '../../shared/http-error';
export interface MediaServiceDeps {
// The presigning (browser-facing) MinIO client.
minio: Client;
mediaBucket: string;
avatarsBucket: string;
publicUrl: string;
}
export interface UploadTarget {
uploadUrl: string;
objectKey: string;
}
export interface AvatarTarget extends UploadTarget {
publicUrl: string;
}
export interface MediaService {
createUploadUrl(userId: string, kind: MediaKind, mime: string, size: number): Promise<UploadTarget>;
createAvatarUploadUrl(userId: string, mime: string): Promise<AvatarTarget>;
downloadUrl(objectKey: string): Promise<string>;
avatarPublicUrl(objectKey: string): string;
}
const UPLOAD_EXPIRY = 3600;
const DOWNLOAD_EXPIRY = 3600;
const kindMatches = (kind: MediaKind, mime: string): boolean => {
if (kind === 'image') return mime.startsWith('image/');
if (kind === 'video' || kind === 'video_note') return mime.startsWith('video/');
if (kind === 'voice') return mime.startsWith('audio/');
return true;
};
export const createMediaService = (deps: MediaServiceDeps): MediaService => ({
createUploadUrl: async (userId, kind, mime, _size) => {
if (!kindMatches(kind, mime)) {
throw new HttpError(400, 'invalid_media', `Content type ${mime} does not match kind ${kind}`);
}
const objectKey = `${userId}/${randomUUID()}`;
const uploadUrl = await deps.minio.presignedPutObject(deps.mediaBucket, objectKey, UPLOAD_EXPIRY);
return { uploadUrl, objectKey };
},
createAvatarUploadUrl: async (userId, mime) => {
if (!mime.startsWith('image/')) {
throw new HttpError(400, 'invalid_media', 'Avatar must be an image');
}
const objectKey = `${userId}/${randomUUID()}`;
const uploadUrl = await deps.minio.presignedPutObject(
deps.avatarsBucket,
objectKey,
UPLOAD_EXPIRY,
);
return {
uploadUrl,
objectKey,
publicUrl: `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`,
};
},
downloadUrl: (objectKey) =>
deps.minio.presignedGetObject(deps.mediaBucket, objectKey, DOWNLOAD_EXPIRY),
avatarPublicUrl: (objectKey) => `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`,
});
declare module 'fastify' {
interface FastifyInstance {
mediaService: MediaService;
}
}