initial
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
// Encrypted attachment upload / download.
|
||||
//
|
||||
// Per-message symmetric key + nonce encrypts the raw blob (XSalsa20-Poly1305
|
||||
// via secretbox). The encrypted blob is uploaded to Supabase Storage under
|
||||
// `{conversation_id}/{attachment_id}.bin`. The symmetric key + blob-nonce
|
||||
// travel inside the per-device message envelope as JSON, so the server never
|
||||
// sees the decryption material.
|
||||
|
||||
import { fromBase64, getCryptoBackend, toBase64 } from '../crypto/index.js';
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
|
||||
export const ATTACHMENT_BUCKET = 'chat-attachments';
|
||||
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// The per-attachment envelope material + public metadata. The base64 fields
|
||||
// live in the encrypted message payload; the storage path + mime live on
|
||||
// the public message_attachments row.
|
||||
export interface AttachmentHandle {
|
||||
id: string; // matches message_attachments.id + storage sub-path
|
||||
storagePath: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
// base64-encoded — only readable via per-device envelope decrypt.
|
||||
keyB64: string;
|
||||
nonceB64: string;
|
||||
}
|
||||
|
||||
export type CallEventStatus = 'ended' | 'missed' | 'declined';
|
||||
export type CallEventKind = 'audio' | 'video';
|
||||
|
||||
// Plaintext payload wire format: either text+attachments or a compact
|
||||
// call-event record. Pre-attachment messages without JSON auto-upgrade
|
||||
// via the fallback branch in parseMessagePayload.
|
||||
|
||||
export interface TextMessagePayload {
|
||||
v: 1;
|
||||
type?: 'text';
|
||||
text: string;
|
||||
attachments: AttachmentHandle[];
|
||||
}
|
||||
|
||||
export interface CallEventPayload {
|
||||
v: 1;
|
||||
type: 'call_event';
|
||||
status: CallEventStatus;
|
||||
mediaKind: CallEventKind;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
export type MessagePayload = TextMessagePayload | CallEventPayload;
|
||||
|
||||
export type ParsedMessagePayload =
|
||||
| { kind: 'text'; text: string; attachments: AttachmentHandle[] }
|
||||
| {
|
||||
kind: 'call_event';
|
||||
status: CallEventStatus;
|
||||
mediaKind: CallEventKind;
|
||||
durationSec: number;
|
||||
};
|
||||
|
||||
export function serializeMessagePayload(payload: MessagePayload): string {
|
||||
if (
|
||||
(!('type' in payload) || payload.type === 'text' || payload.type === undefined) &&
|
||||
'attachments' in payload &&
|
||||
payload.attachments.length === 0
|
||||
) {
|
||||
return payload.text;
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
||||
if (!raw) return { kind: 'text', text: '', attachments: [] };
|
||||
if (!raw.startsWith('{')) return { kind: 'text', text: raw, attachments: [] };
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<MessagePayload> & { type?: string };
|
||||
if (obj && obj.v === 1) {
|
||||
if (obj.type === 'call_event') {
|
||||
const p = obj as CallEventPayload;
|
||||
return {
|
||||
kind: 'call_event',
|
||||
status: p.status,
|
||||
mediaKind: p.mediaKind,
|
||||
durationSec: typeof p.durationSec === 'number' ? p.durationSec : 0,
|
||||
};
|
||||
}
|
||||
const t = obj as TextMessagePayload;
|
||||
return {
|
||||
kind: 'text',
|
||||
text: typeof t.text === 'string' ? t.text : '',
|
||||
attachments: Array.isArray(t.attachments) ? t.attachments : [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* fall through to plain text */
|
||||
}
|
||||
return { kind: 'text', text: raw, attachments: [] };
|
||||
}
|
||||
|
||||
function ensureUuid(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
throw new Error('crypto.randomUUID unavailable');
|
||||
}
|
||||
|
||||
export interface EncryptedAttachmentResult {
|
||||
handle: AttachmentHandle;
|
||||
key: Uint8Array; // raw bytes — caller is responsible for wiping
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
|
||||
// Encrypt + upload a blob. Does NOT insert the message_attachments row —
|
||||
// the caller combines this with a message insert so everything commits
|
||||
// atomically at the application layer.
|
||||
export async function encryptAndUploadAttachment(params: {
|
||||
client: AppSupabaseClient;
|
||||
conversationId: string;
|
||||
file: Blob;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}): Promise<EncryptedAttachmentResult> {
|
||||
const backend = getCryptoBackend();
|
||||
|
||||
const bytes = new Uint8Array(await params.file.arrayBuffer());
|
||||
const key = backend.randomBytes(backend.secretboxKeyLength);
|
||||
const nonce = backend.randomBytes(backend.secretboxNonceLength);
|
||||
const ciphertext = backend.secretbox(bytes, nonce, key);
|
||||
|
||||
const id = ensureUuid();
|
||||
const storagePath = params.conversationId + '/' + id + '.bin';
|
||||
|
||||
const { error } = await params.client.storage
|
||||
.from(ATTACHMENT_BUCKET)
|
||||
.upload(storagePath, ciphertext, {
|
||||
contentType: 'application/octet-stream',
|
||||
upsert: false,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const handle: AttachmentHandle = {
|
||||
id,
|
||||
storagePath,
|
||||
mimeType: params.mimeType,
|
||||
sizeBytes: params.sizeBytes,
|
||||
...(params.width !== undefined ? { width: params.width } : {}),
|
||||
...(params.height !== undefined ? { height: params.height } : {}),
|
||||
keyB64: await toBase64(key),
|
||||
nonceB64: await toBase64(nonce),
|
||||
};
|
||||
|
||||
return { handle, key, nonce };
|
||||
}
|
||||
|
||||
// Download + decrypt an attachment blob and return a Blob the caller can use
|
||||
// with URL.createObjectURL. Throws on network or authentication failures.
|
||||
export async function downloadAndDecryptAttachment(params: {
|
||||
client: AppSupabaseClient;
|
||||
handle: AttachmentHandle;
|
||||
}): Promise<Blob> {
|
||||
const backend = getCryptoBackend();
|
||||
|
||||
const { data, error } = await params.client.storage
|
||||
.from(ATTACHMENT_BUCKET)
|
||||
.download(params.handle.storagePath);
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('empty download');
|
||||
|
||||
const ciphertext = new Uint8Array(await data.arrayBuffer());
|
||||
const key = await fromBase64(params.handle.keyB64);
|
||||
const nonce = await fromBase64(params.handle.nonceB64);
|
||||
const plainBytes = backend.secretboxOpen(ciphertext, nonce, key);
|
||||
// Zero key/nonce buffers on the way out.
|
||||
for (let i = 0; i < key.length; i++) key[i] = 0;
|
||||
for (let i = 0; i < nonce.length; i++) nonce[i] = 0;
|
||||
// Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants.
|
||||
const copy = new Uint8Array(plainBytes.byteLength);
|
||||
copy.set(plainBytes);
|
||||
return new Blob([copy.buffer], { type: params.handle.mimeType });
|
||||
}
|
||||
|
||||
// Insert the public metadata row for an attachment. The ciphertext itself has
|
||||
// already been uploaded to storage under `handle.storagePath`.
|
||||
export async function insertAttachmentRow(
|
||||
client: AppSupabaseClient,
|
||||
messageId: string,
|
||||
handle: AttachmentHandle,
|
||||
blobNonceHex: string,
|
||||
): Promise<void> {
|
||||
const row: Record<string, unknown> = {
|
||||
id: handle.id,
|
||||
message_id: messageId,
|
||||
storage_path: handle.storagePath,
|
||||
nonce: blobNonceHex,
|
||||
mime_type: handle.mimeType,
|
||||
size_bytes: handle.sizeBytes,
|
||||
};
|
||||
if (handle.width !== undefined) row.width = handle.width;
|
||||
if (handle.height !== undefined) row.height = handle.height;
|
||||
const { error } = await client.from('message_attachments').insert(row as never);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ProfileBrief } from '../friends/index.js';
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
import type { ConversationSummary } from './types.js';
|
||||
|
||||
const PROFILE_BRIEF_COLS = 'user_id, username, display_name, avatar_url';
|
||||
|
||||
function mapBrief(row: {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url: string | null;
|
||||
}): ProfileBrief {
|
||||
return {
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
avatarUrl: row.avatar_url,
|
||||
};
|
||||
}
|
||||
|
||||
async function currentUserId(client: AppSupabaseClient): Promise<string> {
|
||||
const { data, error } = await client.auth.getUser();
|
||||
if (error) throw error;
|
||||
if (!data.user) throw new Error('not authenticated');
|
||||
return data.user.id;
|
||||
}
|
||||
|
||||
export async function listConversations(client: AppSupabaseClient): Promise<ConversationSummary[]> {
|
||||
const myId = await currentUserId(client);
|
||||
|
||||
// 1. Caller's memberships
|
||||
const { data: myMembers, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('conversation_id, role, accepted')
|
||||
.eq('user_id', myId);
|
||||
if (mErr) throw mErr;
|
||||
const myMembersList = myMembers ?? [];
|
||||
if (myMembersList.length === 0) return [];
|
||||
|
||||
const convIds = myMembersList.map((m) => m.conversation_id);
|
||||
|
||||
// 2. Conversations
|
||||
const { data: convs, error: cErr } = await client
|
||||
.from('conversations')
|
||||
.select('id, type, name, avatar_url, created_at')
|
||||
.in('id', convIds);
|
||||
if (cErr) throw cErr;
|
||||
|
||||
// 3. All members across these conversations
|
||||
const { data: allMembers, error: aErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('conversation_id, user_id, role, accepted')
|
||||
.in('conversation_id', convIds);
|
||||
if (aErr) throw aErr;
|
||||
|
||||
// 4. Profiles for ALL distinct member user ids (including self — the
|
||||
// member list in GroupInfoPanel needs our own display name too).
|
||||
const memberIdsAll = Array.from(new Set((allMembers ?? []).map((m) => m.user_id)));
|
||||
const profileMap = new Map<string, ProfileBrief>();
|
||||
if (memberIdsAll.length > 0) {
|
||||
const { data: profiles, error: pErr } = await client
|
||||
.from('profiles')
|
||||
.select(PROFILE_BRIEF_COLS)
|
||||
.in('user_id', memberIdsAll);
|
||||
if (pErr) throw pErr;
|
||||
for (const p of profiles ?? []) {
|
||||
const b = mapBrief(p);
|
||||
profileMap.set(b.userId, b);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Latest message per conversation
|
||||
const { data: latest, error: lErr } = await client
|
||||
.from('messages')
|
||||
.select('conversation_id, created_at')
|
||||
.in('conversation_id', convIds)
|
||||
.order('created_at', { ascending: false });
|
||||
if (lErr) throw lErr;
|
||||
const lastSeen = new Map<string, string>();
|
||||
for (const m of latest ?? []) {
|
||||
if (!lastSeen.has(m.conversation_id)) {
|
||||
lastSeen.set(m.conversation_id, m.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const myMap = new Map(myMembersList.map((m) => [m.conversation_id, m]));
|
||||
const memberMap = new Map<string, typeof allMembers>();
|
||||
for (const m of allMembers ?? []) {
|
||||
const list = memberMap.get(m.conversation_id) ?? [];
|
||||
list.push(m);
|
||||
memberMap.set(m.conversation_id, list);
|
||||
}
|
||||
|
||||
return (convs ?? []).map<ConversationSummary>((c) => {
|
||||
const mine = myMap.get(c.id);
|
||||
const members = (memberMap.get(c.id) ?? []).map((m) => ({
|
||||
userId: m.user_id,
|
||||
role: m.role,
|
||||
accepted: m.accepted,
|
||||
profile: profileMap.get(m.user_id) ?? null,
|
||||
}));
|
||||
const peer =
|
||||
c.type === 'dm'
|
||||
? (members.find((m) => m.userId !== myId)?.profile ?? null)
|
||||
: null;
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
name: c.name,
|
||||
avatarUrl: c.avatar_url,
|
||||
createdAt: c.created_at,
|
||||
peer,
|
||||
acceptedByMe: mine?.accepted ?? false,
|
||||
myRole: mine?.role ?? 'member',
|
||||
members,
|
||||
lastMessageAt: lastSeen.get(c.id) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
import type { MemberRole } from '../supabase/types.js';
|
||||
|
||||
async function currentUserId(client: AppSupabaseClient): Promise<string> {
|
||||
const { data, error } = await client.auth.getUser();
|
||||
if (error) throw error;
|
||||
if (!data.user) throw new Error('not authenticated');
|
||||
return data.user.id;
|
||||
}
|
||||
|
||||
export interface CreateGroupParams {
|
||||
client: AppSupabaseClient;
|
||||
name: string;
|
||||
memberUserIds: string[];
|
||||
}
|
||||
|
||||
// Client-side 3-step create: conversation → self as admin → peers as members.
|
||||
// On any peer-insert failure the conversation still survives (partial group is
|
||||
// still usable, admin can retry). On self-insert failure we delete the empty
|
||||
// conversation to avoid orphans.
|
||||
export async function createGroup(params: CreateGroupParams): Promise<string> {
|
||||
const myId = await currentUserId(params.client);
|
||||
const trimmed = params.name.trim();
|
||||
if (trimmed.length === 0) throw new Error('group name required');
|
||||
|
||||
// Generate the uuid client-side so we don't need a post-insert SELECT on
|
||||
// conversations — the SELECT policy requires membership, which only exists
|
||||
// AFTER we insert the creator's member row in step 2.
|
||||
const conversationId = crypto.randomUUID();
|
||||
|
||||
const { error: cErr } = await params.client
|
||||
.from('conversations')
|
||||
.insert({
|
||||
id: conversationId,
|
||||
type: 'group',
|
||||
name: trimmed,
|
||||
created_by: myId,
|
||||
} as never);
|
||||
if (cErr) throw cErr;
|
||||
|
||||
const { error: sErr } = await params.client.from('conversation_members').insert({
|
||||
conversation_id: conversationId,
|
||||
user_id: myId,
|
||||
role: 'admin',
|
||||
accepted: true,
|
||||
} as never);
|
||||
if (sErr) {
|
||||
await params.client.from('conversations').delete().eq('id', conversationId);
|
||||
throw sErr;
|
||||
}
|
||||
|
||||
const peerIds = Array.from(new Set(params.memberUserIds)).filter((id) => id !== myId);
|
||||
if (peerIds.length > 0) {
|
||||
const rows = peerIds.map((user_id) => ({
|
||||
conversation_id: conversationId,
|
||||
user_id,
|
||||
role: 'member' as MemberRole,
|
||||
accepted: true,
|
||||
}));
|
||||
const { error: mErr } = await params.client
|
||||
.from('conversation_members')
|
||||
.insert(rows as never);
|
||||
if (mErr) throw mErr;
|
||||
}
|
||||
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
// Add an accepted friend to a group. RLS allows the insert only when the
|
||||
// caller is admin/mod of the conversation AND is friends with `userId`.
|
||||
export async function addGroupMember(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const { error } = await client.from('conversation_members').insert({
|
||||
conversation_id: conversationId,
|
||||
user_id: userId,
|
||||
role: 'member',
|
||||
accepted: true,
|
||||
} as never);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function leaveGroup(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
): Promise<void> {
|
||||
const myId = await currentUserId(client);
|
||||
const { error } = await client
|
||||
.from('conversation_members')
|
||||
.delete()
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('user_id', myId);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
|
||||
export * from './attachments.js';
|
||||
export * from './conversations.js';
|
||||
export * from './groups.js';
|
||||
export * from './messages.js';
|
||||
export * from './types.js';
|
||||
|
||||
// ----- RPC wrappers ---------------------------------------------------------
|
||||
|
||||
export async function createDm(
|
||||
client: AppSupabaseClient,
|
||||
targetUserId: string,
|
||||
): Promise<string> {
|
||||
const { data, error } = await client.rpc('create_dm', { target_user_id: targetUserId });
|
||||
if (error) throw error;
|
||||
if (typeof data !== 'string') throw new Error('create_dm returned non-uuid');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function acceptDm(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
): Promise<void> {
|
||||
const { error } = await client.rpc('accept_dm', { conversation_id: conversationId });
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import {
|
||||
bytesToUtf8,
|
||||
decryptFrom,
|
||||
encryptFor,
|
||||
utf8ToBytes,
|
||||
} from '../crypto/index.js';
|
||||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
import type { ChatMessage, DecryptedMessage } from './types.js';
|
||||
|
||||
const MESSAGE_COLS =
|
||||
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at';
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string;
|
||||
sender_device_id: string | null;
|
||||
reply_to_id: string | null;
|
||||
edited_at: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function mapMessage(row: MessageRow): ChatMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
conversationId: row.conversation_id,
|
||||
senderId: row.sender_id,
|
||||
senderDeviceId: row.sender_device_id,
|
||||
replyToId: row.reply_to_id,
|
||||
editedAt: row.edited_at,
|
||||
deletedAt: row.deleted_at,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
interface DeviceKey {
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
// All device public keys for accepted members of a conversation.
|
||||
export async function listConversationDeviceKeys(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
): Promise<DeviceKey[]> {
|
||||
// 1. Members
|
||||
const { data: members, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr) throw mErr;
|
||||
|
||||
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||||
if (memberIds.length === 0) return [];
|
||||
|
||||
// 2. Devices for those members
|
||||
const { data: devices, error: dErr } = await client
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
.in('user_id', memberIds);
|
||||
if (dErr) throw dErr;
|
||||
|
||||
return (devices ?? []).map((d) => ({
|
||||
deviceId: d.id,
|
||||
userId: d.user_id,
|
||||
publicKey: pgHexToBytes(d.public_key),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface SendMessageParams {
|
||||
client: AppSupabaseClient;
|
||||
conversationId: string;
|
||||
plaintext: string;
|
||||
senderUserId: string;
|
||||
senderDeviceId: string;
|
||||
senderPrivateKey: Uint8Array;
|
||||
replyToId?: string;
|
||||
// Optional encrypted attachments — their handles are already materialised
|
||||
// via `encryptAndUploadAttachment`. The caller is responsible for creating
|
||||
// the corresponding message_attachments rows (see insertAttachmentRow) once
|
||||
// the returned message id is known.
|
||||
attachmentHandles?: import('./attachments.js').AttachmentHandle[];
|
||||
}
|
||||
|
||||
// Encrypts and inserts a message + per-device envelopes (one per recipient
|
||||
// device, including the sender's own devices so multi-device sender devices
|
||||
// can decrypt their own outbox).
|
||||
export async function sendEncryptedMessage(params: SendMessageParams): Promise<ChatMessage> {
|
||||
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
|
||||
if (deviceKeys.length === 0) {
|
||||
throw new Error('no recipient devices found');
|
||||
}
|
||||
|
||||
const attachments = params.attachmentHandles ?? [];
|
||||
const payloadString =
|
||||
attachments.length === 0
|
||||
? params.plaintext
|
||||
: JSON.stringify({ v: 1, text: params.plaintext, attachments });
|
||||
const plainBytes = utf8ToBytes(payloadString);
|
||||
|
||||
// Insert the message metadata first.
|
||||
const insertPayload: Record<string, unknown> = {
|
||||
conversation_id: params.conversationId,
|
||||
sender_id: params.senderUserId,
|
||||
sender_device_id: params.senderDeviceId,
|
||||
};
|
||||
if (params.replyToId) insertPayload.reply_to_id = params.replyToId;
|
||||
|
||||
const { data: messageRow, error: insertErr } = await params.client
|
||||
.from('messages')
|
||||
.insert(insertPayload as never)
|
||||
.select(MESSAGE_COLS)
|
||||
.single();
|
||||
if (insertErr) throw insertErr;
|
||||
const msg = mapMessage(messageRow as unknown as MessageRow);
|
||||
|
||||
// Encrypt one envelope per recipient device (including own devices).
|
||||
const envelopes: { message_id: string; recipient_device_id: string; ciphertext: string; nonce: string }[] = [];
|
||||
for (const dk of deviceKeys) {
|
||||
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
|
||||
envelopes.push({
|
||||
message_id: msg.id,
|
||||
recipient_device_id: dk.deviceId,
|
||||
ciphertext: bytesToPgHex(ciphertext),
|
||||
nonce: bytesToPgHex(nonce),
|
||||
});
|
||||
}
|
||||
|
||||
const { error: envErr } = await params.client
|
||||
.from('message_envelopes')
|
||||
.insert(envelopes as never);
|
||||
if (envErr) {
|
||||
// Best-effort cleanup if envelope insert failed.
|
||||
await params.client.from('messages').delete().eq('id', msg.id);
|
||||
throw envErr;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Fetch the last `limit` messages of a conversation in ascending order.
|
||||
export async function fetchConversationMessages(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
limit = 100,
|
||||
): Promise<ChatMessage[]> {
|
||||
const { data, error } = await client
|
||||
.from('messages')
|
||||
.select(MESSAGE_COLS)
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
if (error) throw error;
|
||||
const rows = (data ?? []) as unknown as MessageRow[];
|
||||
return rows.map(mapMessage).reverse();
|
||||
}
|
||||
|
||||
// Pull envelopes targeted at our own device for a batch of message ids.
|
||||
export async function fetchOwnEnvelopes(
|
||||
client: AppSupabaseClient,
|
||||
messageIds: string[],
|
||||
ownDeviceId: string,
|
||||
): Promise<Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>> {
|
||||
if (messageIds.length === 0) return new Map();
|
||||
const { data, error } = await client
|
||||
.from('message_envelopes')
|
||||
.select('message_id, ciphertext, nonce')
|
||||
.in('message_id', messageIds)
|
||||
.eq('recipient_device_id', ownDeviceId);
|
||||
if (error) throw error;
|
||||
const out = new Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>();
|
||||
for (const row of data ?? []) {
|
||||
out.set(row.message_id, {
|
||||
ciphertext: pgHexToBytes(row.ciphertext),
|
||||
nonce: pgHexToBytes(row.nonce),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Map sender device id -> public key (for verifying envelope authenticity).
|
||||
export async function fetchSenderDeviceKeys(
|
||||
client: AppSupabaseClient,
|
||||
deviceIds: string[],
|
||||
): Promise<Map<string, Uint8Array>> {
|
||||
if (deviceIds.length === 0) return new Map();
|
||||
const unique = Array.from(new Set(deviceIds));
|
||||
const { data, error } = await client
|
||||
.from('devices')
|
||||
.select('id, public_key')
|
||||
.in('id', unique);
|
||||
if (error) throw error;
|
||||
const out = new Map<string, Uint8Array>();
|
||||
for (const row of data ?? []) {
|
||||
out.set(row.id, pgHexToBytes(row.public_key));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit + delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EditMessageParams {
|
||||
client: AppSupabaseClient;
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
newPlaintext: string;
|
||||
senderPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
// Re-encrypts the message for every currently-registered device in the
|
||||
// conversation and rewrites the envelope rows. The server-side trigger
|
||||
// enforces the 24h window + sender-only rule.
|
||||
export async function editEncryptedMessage(params: EditMessageParams): Promise<void> {
|
||||
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
|
||||
if (deviceKeys.length === 0) throw new Error('no recipient devices found');
|
||||
|
||||
const plainBytes = utf8ToBytes(params.newPlaintext);
|
||||
const rows: {
|
||||
message_id: string;
|
||||
recipient_device_id: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
}[] = [];
|
||||
for (const dk of deviceKeys) {
|
||||
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
|
||||
rows.push({
|
||||
message_id: params.messageId,
|
||||
recipient_device_id: dk.deviceId,
|
||||
ciphertext: bytesToPgHex(ciphertext),
|
||||
nonce: bytesToPgHex(nonce),
|
||||
});
|
||||
}
|
||||
|
||||
// UPDATE the message row — trigger rechecks 24h window + sets edited_at.
|
||||
const { error: mErr } = await params.client
|
||||
.from('messages')
|
||||
.update({ edited_at: new Date().toISOString() } as never)
|
||||
.eq('id', params.messageId);
|
||||
if (mErr) throw mErr;
|
||||
|
||||
// Upsert envelopes (INSERT on conflict UPDATE).
|
||||
const { error: eErr } = await params.client
|
||||
.from('message_envelopes')
|
||||
.upsert(rows as never, { onConflict: 'message_id,recipient_device_id' });
|
||||
if (eErr) throw eErr;
|
||||
}
|
||||
|
||||
export async function softDeleteMessage(
|
||||
client: AppSupabaseClient,
|
||||
messageId: string,
|
||||
): Promise<void> {
|
||||
const { error } = await client
|
||||
.from('messages')
|
||||
.update({ deleted_at: new Date().toISOString() } as never)
|
||||
.eq('id', messageId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read receipts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Upsert read rows for each message. Idempotent thanks to the composite PK.
|
||||
export async function markMessagesRead(
|
||||
client: AppSupabaseClient,
|
||||
messageIds: string[],
|
||||
): Promise<void> {
|
||||
if (messageIds.length === 0) return;
|
||||
const { data: session, error: aErr } = await client.auth.getUser();
|
||||
if (aErr) throw aErr;
|
||||
if (!session.user) return;
|
||||
const myId = session.user.id;
|
||||
const rows = messageIds.map((id) => ({ message_id: id, user_id: myId }));
|
||||
const { error } = await client
|
||||
.from('message_reads')
|
||||
.upsert(rows as never, { onConflict: 'message_id,user_id', ignoreDuplicates: true });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Returns the set of message ids that `peerUserId` has read (among the given ids).
|
||||
// RLS hides rows when either side has read receipts off.
|
||||
export async function listPeerReadsForMessages(
|
||||
client: AppSupabaseClient,
|
||||
messageIds: string[],
|
||||
peerUserId: string,
|
||||
): Promise<Set<string>> {
|
||||
if (messageIds.length === 0) return new Set();
|
||||
const { data, error } = await client
|
||||
.from('message_reads')
|
||||
.select('message_id')
|
||||
.eq('user_id', peerUserId)
|
||||
.in('message_id', messageIds);
|
||||
if (error) throw error;
|
||||
return new Set((data ?? []).map((r) => r.message_id));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reactions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MessageReaction {
|
||||
messageId: string;
|
||||
userId: string;
|
||||
emoji: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function listReactionsForMessages(
|
||||
client: AppSupabaseClient,
|
||||
messageIds: string[],
|
||||
): Promise<MessageReaction[]> {
|
||||
if (messageIds.length === 0) return [];
|
||||
const { data, error } = await client
|
||||
.from('message_reactions')
|
||||
.select('message_id, user_id, emoji, created_at')
|
||||
.in('message_id', messageIds);
|
||||
if (error) throw error;
|
||||
return (data ?? []).map((r) => ({
|
||||
messageId: r.message_id,
|
||||
userId: r.user_id,
|
||||
emoji: r.emoji,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addReaction(
|
||||
client: AppSupabaseClient,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
): Promise<void> {
|
||||
const { data: session, error: aErr } = await client.auth.getUser();
|
||||
if (aErr) throw aErr;
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { error } = await client.from('message_reactions').insert({
|
||||
message_id: messageId,
|
||||
user_id: session.user.id,
|
||||
emoji,
|
||||
} as never);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function removeReaction(
|
||||
client: AppSupabaseClient,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
): Promise<void> {
|
||||
const { data: session, error: aErr } = await client.auth.getUser();
|
||||
if (aErr) throw aErr;
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { error } = await client
|
||||
.from('message_reactions')
|
||||
.delete()
|
||||
.eq('message_id', messageId)
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('emoji', emoji);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decrypt helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DecryptOptions {
|
||||
ownPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export async function decryptMessages(opts: {
|
||||
messages: ChatMessage[];
|
||||
envelopes: Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>;
|
||||
senderKeys: Map<string, Uint8Array>;
|
||||
ownPrivateKey: Uint8Array;
|
||||
}): Promise<DecryptedMessage[]> {
|
||||
const out: DecryptedMessage[] = [];
|
||||
for (const m of opts.messages) {
|
||||
const env = opts.envelopes.get(m.id);
|
||||
const senderKey = m.senderDeviceId ? opts.senderKeys.get(m.senderDeviceId) : undefined;
|
||||
let plaintext: string | null = null;
|
||||
if (env && senderKey) {
|
||||
try {
|
||||
const decoded = await decryptFrom(env.ciphertext, env.nonce, senderKey, opts.ownPrivateKey);
|
||||
plaintext = bytesToUtf8(decoded);
|
||||
} catch {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
out.push({ ...m, plaintext });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ProfileBrief } from '../friends/index.js';
|
||||
import type { ConversationType, MemberRole } from '../supabase/types.js';
|
||||
|
||||
export interface ConversationMember {
|
||||
userId: string;
|
||||
role: MemberRole;
|
||||
accepted: boolean;
|
||||
profile: ProfileBrief | null;
|
||||
}
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
type: ConversationType;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
createdAt: string;
|
||||
// For DMs: the OTHER member's profile. null for groups.
|
||||
peer: ProfileBrief | null;
|
||||
// Caller's accepted flag on their own membership row (DM-request flow).
|
||||
acceptedByMe: boolean;
|
||||
// Caller's role inside this conversation.
|
||||
myRole: MemberRole;
|
||||
members: ConversationMember[];
|
||||
// Latest message timestamp (server can't see content, only metadata).
|
||||
lastMessageAt: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderDeviceId: string | null;
|
||||
replyToId: string | null;
|
||||
editedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DecryptedMessage extends ChatMessage {
|
||||
// null when decryption failed (envelope missing for our device, key gone, etc.).
|
||||
plaintext: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user