This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@chat-app/db-types",
"version": "0.1.0",
"private": true,
"description": "Generated Supabase database types (output of `supabase gen types typescript`)",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -b",
"dev": "tsc -b --watch",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"test": "echo \"no tests\" && exit 0",
"clean": "rm -rf dist .turbo *.tsbuildinfo",
"gen": "supabase gen types typescript --local > src/index.ts"
}
}
+639
View File
@@ -0,0 +1,639 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
extensions?: Json
operationName?: string
query?: string
variables?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
admin_settings: {
Row: {
key: string
updated_at: string
value: Json
}
Insert: {
key: string
updated_at?: string
value: Json
}
Update: {
key?: string
updated_at?: string
value?: Json
}
Relationships: []
}
conversation_members: {
Row: {
accepted: boolean
conversation_id: string
joined_at: string
role: Database["public"]["Enums"]["member_role"]
user_id: string
}
Insert: {
accepted?: boolean
conversation_id: string
joined_at?: string
role?: Database["public"]["Enums"]["member_role"]
user_id: string
}
Update: {
accepted?: boolean
conversation_id?: string
joined_at?: string
role?: Database["public"]["Enums"]["member_role"]
user_id?: string
}
Relationships: [
{
foreignKeyName: "conversation_members_conversation_id_fkey"
columns: ["conversation_id"]
isOneToOne: false
referencedRelation: "conversations"
referencedColumns: ["id"]
},
]
}
conversations: {
Row: {
avatar_url: string | null
created_at: string
created_by: string | null
id: string
name: string | null
type: Database["public"]["Enums"]["conversation_type"]
}
Insert: {
avatar_url?: string | null
created_at?: string
created_by?: string | null
id?: string
name?: string | null
type: Database["public"]["Enums"]["conversation_type"]
}
Update: {
avatar_url?: string | null
created_at?: string
created_by?: string | null
id?: string
name?: string | null
type?: Database["public"]["Enums"]["conversation_type"]
}
Relationships: []
}
devices: {
Row: {
created_at: string
id: string
last_seen_at: string
name: string
platform: Database["public"]["Enums"]["device_platform"]
public_key: string
user_id: string
}
Insert: {
created_at?: string
id?: string
last_seen_at?: string
name: string
platform: Database["public"]["Enums"]["device_platform"]
public_key: string
user_id: string
}
Update: {
created_at?: string
id?: string
last_seen_at?: string
name?: string
platform?: Database["public"]["Enums"]["device_platform"]
public_key?: string
user_id?: string
}
Relationships: []
}
friendships: {
Row: {
accepted_at: string | null
created_at: string
requested_by: string
status: Database["public"]["Enums"]["friendship_status"]
user_hi: string
user_lo: string
}
Insert: {
accepted_at?: string | null
created_at?: string
requested_by: string
status?: Database["public"]["Enums"]["friendship_status"]
user_hi: string
user_lo: string
}
Update: {
accepted_at?: string | null
created_at?: string
requested_by?: string
status?: Database["public"]["Enums"]["friendship_status"]
user_hi?: string
user_lo?: string
}
Relationships: []
}
invites: {
Row: {
code: string
created_at: string
created_by: string | null
disabled: boolean
expires_at: string | null
uses_count: number
uses_limit: number | null
}
Insert: {
code: string
created_at?: string
created_by?: string | null
disabled?: boolean
expires_at?: string | null
uses_count?: number
uses_limit?: number | null
}
Update: {
code?: string
created_at?: string
created_by?: string | null
disabled?: boolean
expires_at?: string | null
uses_count?: number
uses_limit?: number | null
}
Relationships: []
}
message_attachments: {
Row: {
created_at: string
height: number | null
id: string
message_id: string
mime_type: string
nonce: string
size_bytes: number
storage_path: string
width: number | null
}
Insert: {
created_at?: string
height?: number | null
id?: string
message_id: string
mime_type: string
nonce: string
size_bytes: number
storage_path: string
width?: number | null
}
Update: {
created_at?: string
height?: number | null
id?: string
message_id?: string
mime_type?: string
nonce?: string
size_bytes?: number
storage_path?: string
width?: number | null
}
Relationships: [
{
foreignKeyName: "message_attachments_message_id_fkey"
columns: ["message_id"]
isOneToOne: false
referencedRelation: "messages"
referencedColumns: ["id"]
},
]
}
message_envelopes: {
Row: {
ciphertext: string
message_id: string
nonce: string
recipient_device_id: string
}
Insert: {
ciphertext: string
message_id: string
nonce: string
recipient_device_id: string
}
Update: {
ciphertext?: string
message_id?: string
nonce?: string
recipient_device_id?: string
}
Relationships: [
{
foreignKeyName: "message_envelopes_message_id_fkey"
columns: ["message_id"]
isOneToOne: false
referencedRelation: "messages"
referencedColumns: ["id"]
},
{
foreignKeyName: "message_envelopes_recipient_device_id_fkey"
columns: ["recipient_device_id"]
isOneToOne: false
referencedRelation: "devices"
referencedColumns: ["id"]
},
]
}
message_reactions: {
Row: {
created_at: string
emoji: string
message_id: string
user_id: string
}
Insert: {
created_at?: string
emoji: string
message_id: string
user_id: string
}
Update: {
created_at?: string
emoji?: string
message_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "message_reactions_message_id_fkey"
columns: ["message_id"]
isOneToOne: false
referencedRelation: "messages"
referencedColumns: ["id"]
},
]
}
message_reads: {
Row: {
message_id: string
read_at: string
user_id: string
}
Insert: {
message_id: string
read_at?: string
user_id: string
}
Update: {
message_id?: string
read_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "message_reads_message_id_fkey"
columns: ["message_id"]
isOneToOne: false
referencedRelation: "messages"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
conversation_id: string
created_at: string
deleted_at: string | null
deleted_by: string | null
edited_at: string | null
id: string
reply_to_id: string | null
sender_device_id: string | null
sender_id: string
}
Insert: {
conversation_id: string
created_at?: string
deleted_at?: string | null
deleted_by?: string | null
edited_at?: string | null
id?: string
reply_to_id?: string | null
sender_device_id?: string | null
sender_id: string
}
Update: {
conversation_id?: string
created_at?: string
deleted_at?: string | null
deleted_by?: string | null
edited_at?: string | null
id?: string
reply_to_id?: string | null
sender_device_id?: string | null
sender_id?: string
}
Relationships: [
{
foreignKeyName: "messages_conversation_id_fkey"
columns: ["conversation_id"]
isOneToOne: false
referencedRelation: "conversations"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_reply_to_id_fkey"
columns: ["reply_to_id"]
isOneToOne: false
referencedRelation: "messages"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_sender_device_id_fkey"
columns: ["sender_device_id"]
isOneToOne: false
referencedRelation: "devices"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
allow_dms_from_strangers: boolean
avatar_url: string | null
banned: boolean
blocked_from_inviting: boolean
created_at: string
display_name: string
is_admin: boolean
locale: string
presence_state: Database["public"]["Enums"]["presence_state"]
show_read_receipts: boolean
status_message: string | null
updated_at: string
user_id: string
username: string
}
Insert: {
allow_dms_from_strangers?: boolean
avatar_url?: string | null
banned?: boolean
blocked_from_inviting?: boolean
created_at?: string
display_name: string
is_admin?: boolean
locale?: string
presence_state?: Database["public"]["Enums"]["presence_state"]
show_read_receipts?: boolean
status_message?: string | null
updated_at?: string
user_id: string
username: string
}
Update: {
allow_dms_from_strangers?: boolean
avatar_url?: string | null
banned?: boolean
blocked_from_inviting?: boolean
created_at?: string
display_name?: string
is_admin?: boolean
locale?: string
presence_state?: Database["public"]["Enums"]["presence_state"]
show_read_receipts?: boolean
status_message?: string | null
updated_at?: string
user_id?: string
username?: string
}
Relationships: []
}
push_tokens: {
Row: {
device_id: string
platform: Database["public"]["Enums"]["device_platform"]
token: string
updated_at: string
}
Insert: {
device_id: string
platform: Database["public"]["Enums"]["device_platform"]
token: string
updated_at?: string
}
Update: {
device_id?: string
platform?: Database["public"]["Enums"]["device_platform"]
token?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "push_tokens_device_id_fkey"
columns: ["device_id"]
isOneToOne: true
referencedRelation: "devices"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
are_friends: { Args: { a: string; b: string }; Returns: boolean }
attachment_object_conv_id: {
Args: { object_name: string }
Returns: string
}
create_dm: { Args: { target_user_id: string }; Returns: string }
current_user_is_admin: { Args: never; Returns: boolean }
is_conversation_admin: { Args: { cid: string }; Returns: boolean }
is_conversation_member: { Args: { cid: string }; Returns: boolean }
is_conversation_mod_or_higher: { Args: { cid: string }; Returns: boolean }
send_friend_request: {
Args: { target_user_id: string }
Returns: undefined
}
}
Enums: {
conversation_type: "dm" | "group"
device_platform: "ios" | "android" | "macos" | "windows" | "linux"
friendship_status: "pending" | "accepted" | "blocked"
member_role: "admin" | "mod" | "member"
presence_state: "online" | "idle" | "dnd" | "invisible" | "offline"
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
conversation_type: ["dm", "group"],
device_platform: ["ios", "android", "macos", "windows", "linux"],
friendship_status: ["pending", "accepted", "blocked"],
member_role: ["admin", "mod", "member"],
presence_state: ["online", "idle", "dnd", "invisible", "offline"],
},
},
} as const
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "./src"
},
"include": ["src/**/*"]
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@chat-app/shared",
"version": "0.1.0",
"private": true,
"description": "Shared business logic: Supabase client, crypto wrappers, auth + chat flows",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./supabase": "./src/supabase/index.ts",
"./crypto": "./src/crypto/index.ts",
"./auth": "./src/auth/index.ts",
"./chat": "./src/chat/index.ts",
"./admin": "./src/admin/index.ts",
"./friends": "./src/friends/index.ts",
"./i18n": "./src/i18n/index.ts",
"./rtc": "./src/rtc/index.ts"
},
"scripts": {
"build": "tsc -b",
"dev": "tsc -b --watch",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist .turbo *.tsbuildinfo"
},
"dependencies": {
"@chat-app/db-types": "workspace:*",
"@supabase/supabase-js": "^2.46.0",
"i18next": "^23.16.4"
},
"peerDependencies": {
"react": "^18.3.1",
"react-i18next": "^15.1.1"
},
"peerDependenciesMeta": {
"react": { "optional": false },
"react-i18next": { "optional": false }
},
"devDependencies": {
"@types/react": "^18.3.12",
"react": "^18.3.1",
"react-i18next": "^15.1.1"
}
}
+189
View File
@@ -0,0 +1,189 @@
// Admin-only helpers. Every call is RLS-gated server-side via the
// `current_user_is_admin()` helper + admin-specific policies. Non-admins
// trying to call these still get clean PostgREST 403/empty-result responses.
import type { AppSupabaseClient } from '../supabase/client.js';
// --- Admin settings -------------------------------------------------------
export interface AdminSetting {
key: string;
value: unknown;
updatedAt: string;
}
export async function listAdminSettings(client: AppSupabaseClient): Promise<AdminSetting[]> {
const { data, error } = await client
.from('admin_settings')
.select('key, value, updated_at')
.order('key', { ascending: true });
if (error) throw error;
return (data ?? []).map((r) => ({ key: r.key, value: r.value, updatedAt: r.updated_at }));
}
export async function updateAdminSetting(
client: AppSupabaseClient,
key: string,
value: unknown,
): Promise<void> {
const { error } = await client
.from('admin_settings')
.upsert(
{
key,
value: value as never,
updated_at: new Date().toISOString(),
} as never,
{ onConflict: 'key' },
);
if (error) throw error;
}
// --- Invite codes ---------------------------------------------------------
export interface InviteRecord {
code: string;
createdBy: string | null;
usesLimit: number | null;
usesCount: number;
expiresAt: string | null;
disabled: boolean;
createdAt: string;
}
function mapInvite(row: {
code: string;
created_by: string | null;
uses_limit: number | null;
uses_count: number;
expires_at: string | null;
disabled: boolean;
created_at: string;
}): InviteRecord {
return {
code: row.code,
createdBy: row.created_by,
usesLimit: row.uses_limit,
usesCount: row.uses_count,
expiresAt: row.expires_at,
disabled: row.disabled,
createdAt: row.created_at,
};
}
export async function listInvites(client: AppSupabaseClient): Promise<InviteRecord[]> {
const { data, error } = await client
.from('invites')
.select('code, created_by, uses_limit, uses_count, expires_at, disabled, created_at')
.order('created_at', { ascending: false });
if (error) throw error;
return (data ?? []).map(mapInvite);
}
export interface CreateInviteParams {
code?: string; // omit to auto-generate
usesLimit?: number | null;
expiresAt?: string | null; // ISO
}
const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function randomCode(length = 10): string {
const buf =
typeof crypto !== 'undefined' && 'getRandomValues' in crypto
? crypto.getRandomValues(new Uint8Array(length))
: null;
let out = '';
for (let i = 0; i < length; i++) {
const v = buf ? buf[i]! : Math.floor(Math.random() * 256);
const ch = INVITE_ALPHABET[v % INVITE_ALPHABET.length];
if (ch !== undefined) out += ch;
}
return out;
}
export async function createInvite(
client: AppSupabaseClient,
params: CreateInviteParams = {},
): Promise<InviteRecord> {
const { data: session, error: aErr } = await client.auth.getUser();
if (aErr) throw aErr;
if (!session.user) throw new Error('not authenticated');
const code = params.code?.trim() || randomCode(10);
const { data, error } = await client
.from('invites')
.insert({
code,
created_by: session.user.id,
uses_limit: params.usesLimit ?? null,
expires_at: params.expiresAt ?? null,
disabled: false,
} as never)
.select('code, created_by, uses_limit, uses_count, expires_at, disabled, created_at')
.single();
if (error) throw error;
return mapInvite(data as never);
}
export async function setInviteDisabled(
client: AppSupabaseClient,
code: string,
disabled: boolean,
): Promise<void> {
const { error } = await client
.from('invites')
.update({ disabled } as never)
.eq('code', code);
if (error) throw error;
}
export async function deleteInvite(client: AppSupabaseClient, code: string): Promise<void> {
const { error } = await client.from('invites').delete().eq('code', code);
if (error) throw error;
}
// --- User admin ops -------------------------------------------------------
export interface AdminProfileRow {
userId: string;
username: string;
displayName: string;
isAdmin: boolean;
banned: boolean;
blockedFromInviting: boolean;
createdAt: string;
}
export async function listAllProfiles(client: AppSupabaseClient): Promise<AdminProfileRow[]> {
const { data, error } = await client
.from('profiles')
.select('user_id, username, display_name, is_admin, banned, blocked_from_inviting, created_at')
.order('created_at', { ascending: false });
if (error) throw error;
return (data ?? []).map((r) => ({
userId: r.user_id,
username: r.username,
displayName: r.display_name,
isAdmin: r.is_admin,
banned: r.banned,
blockedFromInviting: r.blocked_from_inviting,
createdAt: r.created_at,
}));
}
export type AdminProfileFlag = 'is_admin' | 'banned' | 'blocked_from_inviting';
export async function setUserFlag(
client: AppSupabaseClient,
userId: string,
flag: AdminProfileFlag,
value: boolean,
): Promise<void> {
const patch: Record<string, boolean> = {};
patch[flag] = value;
const { error } = await client
.from('profiles')
.update(patch as never)
.eq('user_id', userId);
if (error) throw error;
}
+154
View File
@@ -0,0 +1,154 @@
import { fromBase64, generateX25519KeyPair, toBase64, wipe } from '../crypto/index.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { DevicePlatform } from '../supabase/types.js';
import type { SecretStore } from './secure-storage.js';
export interface RegisterDeviceParams {
name: string; // user-facing, e.g. "Dennis Laptop"
platform: DevicePlatform;
publicKey: Uint8Array; // X25519 public key, 32 bytes
}
export interface DeviceRecord {
id: string;
name: string;
platform: DevicePlatform;
publicKey: Uint8Array;
lastSeenAt: string;
}
export async function registerDevice(
client: AppSupabaseClient,
params: RegisterDeviceParams,
): Promise<DeviceRecord> {
const { data: session } = await client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
const { data, error } = await client
.from('devices')
.insert({
user_id: session.user.id,
name: params.name,
platform: params.platform,
public_key: bytesToPgHex(params.publicKey),
})
.select('id, name, platform, public_key, last_seen_at')
.single();
if (error) throw error;
return {
id: data.id,
name: data.name,
platform: data.platform,
publicKey: pgHexToBytes(data.public_key),
lastSeenAt: data.last_seen_at,
};
}
export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceRecord[]> {
const { data: session } = await client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
const { data, error } = await client
.from('devices')
.select('id, name, platform, public_key, last_seen_at')
.eq('user_id', session.user.id)
.order('last_seen_at', { ascending: false });
if (error) throw error;
return data.map((row) => ({
id: row.id,
name: row.name,
platform: row.platform,
publicKey: pgHexToBytes(row.public_key),
lastSeenAt: row.last_seen_at,
}));
}
export async function touchDeviceLastSeen(
client: AppSupabaseClient,
deviceId: string,
): Promise<void> {
const { error } = await client
.from('devices')
.update({ last_seen_at: new Date().toISOString() })
.eq('id', deviceId);
if (error) throw error;
}
// ---------------------------------------------------------------------------
// End-to-end device provisioning flow.
// ---------------------------------------------------------------------------
export interface ProvisionDeviceParams {
client: AppSupabaseClient;
secretStore: SecretStore;
userId: string;
name: string;
platform: DevicePlatform;
}
export interface ProvisionResult {
device: DeviceRecord;
created: boolean;
}
function privateKeySecretName(userId: string, deviceId: string): string {
return `chatapp.priv.${userId}.${deviceId}`;
}
// Creates a brand-new device: generates an X25519 keypair, registers the public
// half with Supabase, stores the private half in the platform secret store.
export async function provisionNewDevice({
client,
secretStore,
userId,
name,
platform,
}: ProvisionDeviceParams): Promise<DeviceRecord> {
const kp = await generateX25519KeyPair();
const device = await registerDevice(client, {
name,
platform,
publicKey: kp.publicKey,
});
try {
await secretStore.setSecret(privateKeySecretName(userId, device.id), kp.privateKey);
} finally {
wipe(kp.privateKey);
}
return device;
}
// Loads the private key for `deviceId` from the secret store. Returns null if
// this install has never stored one.
export async function loadDevicePrivateKey(
secretStore: SecretStore,
userId: string,
deviceId: string,
): Promise<Uint8Array | null> {
return secretStore.getSecret(privateKeySecretName(userId, deviceId));
}
export async function forgetDevicePrivateKey(
secretStore: SecretStore,
userId: string,
deviceId: string,
): Promise<void> {
await secretStore.removeSecret(privateKeySecretName(userId, deviceId));
}
// Lightweight helpers for platforms that want to cache their current device id
// in JSON storage (separate from the secret store, which only holds raw bytes).
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
export function deviceIdStorageKey(userId: string): string {
return `${DEVICE_ID_STORAGE_KEY_PREFIX}.${userId}`;
}
// Intentional re-exports so app layers only need @chat-app/shared/auth.
export type { SecretStore } from './secure-storage.js';
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
+3
View File
@@ -0,0 +1,3 @@
export * from './device.js';
export * from './magic-link.js';
export * from './profile.js';
+108
View File
@@ -0,0 +1,108 @@
import type { SupportedLocale } from '../i18n/types.js';
import type { AppSupabaseClient } from '../supabase/client.js';
export interface SignupParams {
email: string;
// Login handle. Lowercased server-side; must match ^[a-z0-9_]{3,32}$ once lowercased.
username: string;
// Optional human-readable name. Defaults to username server-side if omitted.
displayName?: string;
// Invite code is mandatory (enforced by the handle_new_user trigger).
inviteCode: string;
// Deep link the magic-link email should bounce back to.
// e.g. `chatapp://auth/callback` on mobile, `http://localhost:1420/auth/callback` on desktop dev.
redirectTo: string;
// Optional initial locale for the profile row. Defaults to 'en' server-side.
locale?: SupportedLocale;
}
// Trigger signup. User receives a magic link email. Completion happens in
// `completeSessionFromUrl` below once the callback fires.
export async function signUpWithMagicLink(
client: AppSupabaseClient,
params: SignupParams,
): Promise<void> {
const { error } = await client.auth.signInWithOtp({
email: params.email,
options: {
emailRedirectTo: params.redirectTo,
shouldCreateUser: true,
data: {
invite_code: params.inviteCode,
username: params.username.toLowerCase(),
display_name: params.displayName ?? params.username,
...(params.locale ? { locale: params.locale } : {}),
},
},
});
if (error) throw error;
}
// Trigger magic-link login for an existing user. No invite code needed.
export async function loginWithMagicLink(
client: AppSupabaseClient,
email: string,
redirectTo: string,
): Promise<void> {
const { error } = await client.auth.signInWithOtp({
email,
options: {
emailRedirectTo: redirectTo,
shouldCreateUser: false,
},
});
if (error) throw error;
}
// Parse a callback URL produced by the magic-link email and establish a session.
// Works for both PKCE (`?code=…`) and legacy token-hash (`#access_token=…`).
export async function completeSessionFromUrl(
client: AppSupabaseClient,
url: string,
): Promise<void> {
const u = new URL(url);
// PKCE flow: exchange the code.
const code = u.searchParams.get('code');
if (code) {
const { error } = await client.auth.exchangeCodeForSession(code);
if (error) throw error;
return;
}
// Hash fragment flow.
if (u.hash && u.hash.length > 1) {
const hash = new URLSearchParams(u.hash.slice(1));
const access_token = hash.get('access_token');
const refresh_token = hash.get('refresh_token');
if (access_token && refresh_token) {
const { error } = await client.auth.setSession({ access_token, refresh_token });
if (error) throw error;
return;
}
}
throw new Error('callback URL contained no auth params');
}
export async function signOut(client: AppSupabaseClient): Promise<void> {
const { error } = await client.auth.signOut();
if (error) throw error;
}
// Verify a 6-digit OTP that arrived via magic-link email. Establishes a
// session in the CURRENT webview — no browser redirect involved. Used by the
// desktop/mobile apps where cross-origin redirects don't carry the session
// back to the native webview.
export async function verifyMagicLinkOtp(
client: AppSupabaseClient,
email: string,
token: string,
): Promise<void> {
const { error } = await client.auth.verifyOtp({
email,
token: token.trim(),
type: 'email',
});
if (error) throw error;
}
+131
View File
@@ -0,0 +1,131 @@
import type { SupportedLocale } from '../i18n/types.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { Database, PresenceState } from '../supabase/types.js';
type ProfileUpdate = Database['public']['Tables']['profiles']['Update'];
export interface Profile {
userId: string;
username: string;
displayName: string;
avatarUrl: string | null;
statusMessage: string | null;
presenceState: PresenceState;
showReadReceipts: boolean;
allowDmsFromStrangers: boolean;
isAdmin: boolean;
locale: SupportedLocale;
}
type ProfileRow = {
user_id: string;
username: string;
display_name: string;
avatar_url: string | null;
status_message: string | null;
presence_state: PresenceState;
show_read_receipts: boolean;
allow_dms_from_strangers: boolean;
is_admin: boolean;
locale: string;
};
const PROFILE_COLS =
'user_id, username, display_name, avatar_url, status_message, presence_state, show_read_receipts, allow_dms_from_strangers, is_admin, locale';
function toSupportedLocale(raw: string): SupportedLocale {
return raw === 'de' ? 'de' : 'en';
}
function mapProfile(row: ProfileRow): Profile {
return {
userId: row.user_id,
username: row.username,
displayName: row.display_name,
avatarUrl: row.avatar_url,
statusMessage: row.status_message,
presenceState: row.presence_state,
showReadReceipts: row.show_read_receipts,
allowDmsFromStrangers: row.allow_dms_from_strangers,
isAdmin: row.is_admin,
locale: toSupportedLocale(row.locale),
};
}
export async function getOwnProfile(client: AppSupabaseClient): Promise<Profile | null> {
const { data: session } = await client.auth.getUser();
if (!session.user) return null;
const { data, error } = await client
.from('profiles')
.select(PROFILE_COLS)
.eq('user_id', session.user.id)
.maybeSingle();
if (error) throw error;
return data ? mapProfile(data as unknown as ProfileRow) : null;
}
export async function getProfileByUsername(
client: AppSupabaseClient,
username: string,
): Promise<Profile | null> {
const { data, error } = await client
.from('profiles')
.select(PROFILE_COLS)
.eq('username', username.toLowerCase())
.maybeSingle();
if (error) throw error;
return data ? mapProfile(data as unknown as ProfileRow) : null;
}
export async function isUsernameAvailable(
client: AppSupabaseClient,
username: string,
): Promise<boolean> {
const { count, error } = await client
.from('profiles')
.select('user_id', { count: 'exact', head: true })
.eq('username', username.toLowerCase());
if (error) throw error;
return (count ?? 0) === 0;
}
export interface UpdateProfileParams {
displayName?: string;
avatarUrl?: string | null;
statusMessage?: string | null;
presenceState?: PresenceState;
showReadReceipts?: boolean;
allowDmsFromStrangers?: boolean;
locale?: SupportedLocale;
}
export async function updateOwnProfile(
client: AppSupabaseClient,
params: UpdateProfileParams,
): Promise<Profile> {
const { data: session } = await client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
const patch: ProfileUpdate = {};
if (params.displayName !== undefined) patch.display_name = params.displayName;
if (params.avatarUrl !== undefined) patch.avatar_url = params.avatarUrl;
if (params.statusMessage !== undefined) patch.status_message = params.statusMessage;
if (params.presenceState !== undefined) patch.presence_state = params.presenceState;
if (params.showReadReceipts !== undefined) patch.show_read_receipts = params.showReadReceipts;
if (params.allowDmsFromStrangers !== undefined) {
patch.allow_dms_from_strangers = params.allowDmsFromStrangers;
}
if (params.locale !== undefined) {
(patch as ProfileUpdate & { locale?: string }).locale = params.locale;
}
const { data, error } = await client
.from('profiles')
.update(patch)
.eq('user_id', session.user.id)
.select(PROFILE_COLS)
.single();
if (error) throw error;
return mapProfile(data as unknown as ProfileRow);
}
@@ -0,0 +1,13 @@
// Platform-provided secret store.
//
// Mobile: expo-secure-store (Keychain / Keystore).
// Desktop M1: localStorage-backed (dev only, clearly flagged insecure).
// Desktop prod: tauri-plugin-stronghold.
//
// All implementations encode Uint8Array values as base64 under the hood.
export interface SecretStore {
getSecret(key: string): Promise<Uint8Array | null>;
setSecret(key: string, value: Uint8Array): Promise<void>;
removeSecret(key: string): Promise<void>;
}
+206
View File
@@ -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;
}
+119
View File
@@ -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,
};
});
}
+96
View File
@@ -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;
}
+27
View File
@@ -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;
}
+394
View File
@@ -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;
}
+42
View File
@@ -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;
}
+69
View File
@@ -0,0 +1,69 @@
// Platform-agnostic crypto backend contract. Each host (desktop / mobile)
// registers its own implementation at boot via `setCryptoBackend`.
//
// Why an interface?
// - Desktop: libsodium-wrappers (WASM, needs async ready-gate).
// - Mobile: react-native-libsodium (native bindings, different API shape).
// - Tests: deterministic backend with seeded RNG.
//
// Backends are expected to be *synchronous* after registration. Any async
// initialisation (e.g. libsodium's WASM warm-up) happens before the backend
// object is handed to `setCryptoBackend`.
export interface KeyPair {
publicKey: Uint8Array;
privateKey: Uint8Array;
}
export interface CryptoBackend {
readonly name: string;
readonly nonceLength: number; // crypto_box_NONCEBYTES — 24
readonly publicKeyLength: number; // 32
readonly privateKeyLength: number; // 32
readonly secretboxKeyLength: number; // crypto_secretbox_KEYBYTES — 32
readonly secretboxNonceLength: number; // crypto_secretbox_NONCEBYTES — 24
randomBytes(n: number): Uint8Array;
generateKeyPair(): KeyPair;
// Authenticated encryption (X25519 + XSalsa20-Poly1305, AKA crypto_box).
box(
plaintext: Uint8Array,
nonce: Uint8Array,
recipientPublicKey: Uint8Array,
senderPrivateKey: Uint8Array,
): Uint8Array;
// Must throw on authentication failure.
boxOpen(
ciphertext: Uint8Array,
nonce: Uint8Array,
senderPublicKey: Uint8Array,
recipientPrivateKey: Uint8Array,
): Uint8Array;
// Symmetric authenticated encryption (XSalsa20-Poly1305, crypto_secretbox).
// Used for large blobs (attachments) — one key per blob, key itself is
// distributed per recipient via crypto_box envelopes.
secretbox(plaintext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
secretboxOpen(ciphertext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
}
let current: CryptoBackend | null = null;
export function setCryptoBackend(backend: CryptoBackend): void {
current = backend;
}
export function getCryptoBackend(): CryptoBackend {
if (!current) {
throw new Error(
'Crypto backend not configured. Call setCryptoBackend() at app boot before any crypto operation.',
);
}
return current;
}
export function isCryptoBackendReady(): boolean {
return current !== null;
}
+42
View File
@@ -0,0 +1,42 @@
import { getCryptoBackend } from './backend.js';
// Authenticated encryption using XSalsa20-Poly1305 + X25519 (Curve25519).
// Delegates to the active CryptoBackend (libsodium on desktop, libsodium-rn
// on mobile, etc.).
export interface EncryptedEnvelope {
ciphertext: Uint8Array;
nonce: Uint8Array;
}
export async function encryptFor(
plaintext: Uint8Array,
recipientPublicKey: Uint8Array,
senderPrivateKey: Uint8Array,
): Promise<EncryptedEnvelope> {
const backend = getCryptoBackend();
const nonce = backend.randomBytes(backend.nonceLength);
const ciphertext = backend.box(plaintext, nonce, recipientPublicKey, senderPrivateKey);
return { ciphertext, nonce };
}
export async function decryptFrom(
ciphertext: Uint8Array,
nonce: Uint8Array,
senderPublicKey: Uint8Array,
recipientPrivateKey: Uint8Array,
): Promise<Uint8Array> {
const backend = getCryptoBackend();
return backend.boxOpen(ciphertext, nonce, senderPublicKey, recipientPrivateKey);
}
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export function utf8ToBytes(text: string): Uint8Array {
return textEncoder.encode(text);
}
export function bytesToUtf8(bytes: Uint8Array): string {
return textDecoder.decode(bytes);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './backend.js';
export * from './box.js';
export * from './keys.js';
+39
View File
@@ -0,0 +1,39 @@
import { getCryptoBackend } from './backend.js';
export interface X25519KeyPair {
publicKey: Uint8Array; // 32 bytes
privateKey: Uint8Array; // 32 bytes
}
export async function generateX25519KeyPair(): Promise<X25519KeyPair> {
const backend = getCryptoBackend();
const kp = backend.generateKeyPair();
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
}
// ---- base64 helpers (used to persist private keys client-side) ----------
// Use browser-native btoa/atob. Both are available in Tauri webview and RN
// (Expo polyfills atob/btoa). No external dep needed.
export async function toBase64(bytes: Uint8Array): Promise<string> {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary);
}
export async function fromBase64(input: string): Promise<Uint8Array> {
const binary = atob(input);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
}
// Wipe a key best-effort. JS can't truly guarantee zeroization but
// overwriting the buffer removes the value from live references.
export function wipe(bytes: Uint8Array): void {
for (let i = 0; i < bytes.length; i++) bytes[i] = 0;
}
+160
View File
@@ -0,0 +1,160 @@
import type { AppSupabaseClient } from '../supabase/client.js';
import type { FriendshipStatus } from '../supabase/types.js';
export interface ProfileBrief {
userId: string;
username: string;
displayName: string;
avatarUrl: string | null;
}
export type FriendDirection = 'outgoing' | 'incoming';
export interface Friendship {
peer: ProfileBrief;
status: FriendshipStatus;
// Only meaningful when status === 'pending'.
direction: FriendDirection;
createdAt: string;
acceptedAt: string | null;
}
const PROFILE_BRIEF_COLS = 'user_id, username, display_name, avatar_url';
function mapProfileBrief(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;
}
function pairKey(a: string, b: string): { lo: string; hi: string } {
return a < b ? { lo: a, hi: b } : { lo: b, hi: a };
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
export async function searchProfiles(
client: AppSupabaseClient,
query: string,
limit = 10,
): Promise<ProfileBrief[]> {
const trimmed = query.trim().toLowerCase();
if (trimmed.length < 2) return [];
const myId = await currentUserId(client);
const { data, error } = await client
.from('profiles')
.select(PROFILE_BRIEF_COLS)
.ilike('username', trimmed + '%')
.neq('user_id', myId)
.limit(limit);
if (error) throw error;
return (data ?? []).map(mapProfileBrief);
}
// ---------------------------------------------------------------------------
// Friendships list
// ---------------------------------------------------------------------------
export async function listFriendships(client: AppSupabaseClient): Promise<Friendship[]> {
const myId = await currentUserId(client);
const { data: rows, error } = await client
.from('friendships')
.select('user_lo, user_hi, status, requested_by, created_at, accepted_at')
.order('created_at', { ascending: false });
if (error) throw error;
if (!rows || rows.length === 0) return [];
const peerIds = Array.from(
new Set(rows.map((r) => (r.user_lo === myId ? r.user_hi : r.user_lo))),
);
const { data: profiles, error: pErr } = await client
.from('profiles')
.select(PROFILE_BRIEF_COLS)
.in('user_id', peerIds);
if (pErr) throw pErr;
const profileMap = new Map<string, ProfileBrief>();
for (const p of profiles ?? []) {
const brief = mapProfileBrief(p);
profileMap.set(brief.userId, brief);
}
return rows.map<Friendship>((r) => {
const peerId = r.user_lo === myId ? r.user_hi : r.user_lo;
const peer = profileMap.get(peerId) ?? {
userId: peerId,
username: '?',
displayName: '?',
avatarUrl: null,
};
return {
peer,
status: r.status,
direction: r.requested_by === myId ? 'outgoing' : 'incoming',
createdAt: r.created_at,
acceptedAt: r.accepted_at,
};
});
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
export async function sendFriendRequest(
client: AppSupabaseClient,
targetUserId: string,
): Promise<void> {
const { error } = await client.rpc('send_friend_request', { target_user_id: targetUserId });
if (error) throw error;
}
export async function acceptFriendRequest(
client: AppSupabaseClient,
peerUserId: string,
): Promise<void> {
const myId = await currentUserId(client);
const { lo, hi } = pairKey(myId, peerUserId);
const { error } = await client
.from('friendships')
.update({ status: 'accepted' })
.eq('user_lo', lo)
.eq('user_hi', hi);
if (error) throw error;
}
// Used for both "decline incoming" and "cancel outgoing" — semantically a
// bilateral break.
export async function removeFriendship(
client: AppSupabaseClient,
peerUserId: string,
): Promise<void> {
const myId = await currentUserId(client);
const { lo, hi } = pairKey(myId, peerUserId);
const { error } = await client
.from('friendships')
.delete()
.eq('user_lo', lo)
.eq('user_hi', hi);
if (error) throw error;
}
+28
View File
@@ -0,0 +1,28 @@
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SupportedLocale } from './types.js';
export function isSupportedLocale(value: string | null | undefined): value is SupportedLocale {
return (
typeof value === 'string' &&
(SUPPORTED_LOCALES as readonly string[]).includes(value)
);
}
// Strip region tag ("de-DE" -> "de"), lowercase, validate.
export function normaliseLocale(input: string | null | undefined): SupportedLocale | null {
if (!input) return null;
const short = input.toLowerCase().split('-')[0];
return isSupportedLocale(short) ? short : null;
}
// Detect the best-guess initial locale using only platform-agnostic inputs.
// Host apps layer their own precedence on top (cached pref -> user profile).
export function detectBrowserLocale(
navigatorLanguages: readonly string[] | undefined,
): SupportedLocale {
if (!navigatorLanguages) return DEFAULT_LOCALE;
for (const lang of navigatorLanguages) {
const match = normaliseLocale(lang);
if (match) return match;
}
return DEFAULT_LOCALE;
}
+33
View File
@@ -0,0 +1,33 @@
// Extract an ERR_* code from a thrown error. Supabase wraps Postgres errors
// as `PostgrestError { message, code, details, hint }`; `auth.signInWithOtp`
// passes the trigger's raise text via `AuthApiError { message }`.
//
// We recognise anything that looks like `ERR_[A-Z0-9_]+`.
const CODE_PATTERN = /ERR_[A-Z0-9_]+/;
export function extractErrorCode(err: unknown): string | null {
if (!err) return null;
if (typeof err === 'string') {
const m = err.match(CODE_PATTERN);
return m ? m[0] : null;
}
if (err instanceof Error) {
const m = err.message.match(CODE_PATTERN);
return m ? m[0] : null;
}
if (typeof err === 'object') {
const record = err as Record<string, unknown>;
const candidates: unknown[] = [record.code, record.message, record.details, record.hint];
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
const m = candidate.match(CODE_PATTERN);
if (m) return m[0];
}
}
return null;
}
+56
View File
@@ -0,0 +1,56 @@
import i18next, { type i18n as I18nInstance, type Resource } from 'i18next';
import { initReactI18next } from 'react-i18next';
import { resources } from './resources.js';
import { DEFAULT_LOCALE, type SupportedLocale } from './types.js';
export * from './detect.js';
export * from './error-map.js';
export * from './types.js';
export interface InitI18nOptions {
initialLocale: SupportedLocale;
// Called whenever the active language changes.
onLanguageChanged?: (locale: SupportedLocale) => void;
}
// Idempotent init — safe to call from multiple entry points.
// Returns the configured i18next instance.
export function initI18n(options: InitI18nOptions): I18nInstance {
if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
resources: resources as unknown as Resource,
lng: options.initialLocale,
fallbackLng: DEFAULT_LOCALE,
defaultNS: 'common',
ns: ['common', 'auth', 'errors', 'app'],
interpolation: { escapeValue: false },
returnNull: false,
});
} else if (i18next.language !== options.initialLocale) {
void i18next.changeLanguage(options.initialLocale);
}
if (options.onLanguageChanged) {
i18next.off('languageChanged');
i18next.on('languageChanged', (lng: string) => {
if (isSupportedLocale(lng)) {
options.onLanguageChanged?.(lng);
}
});
}
return i18next;
}
export function changeLocale(locale: SupportedLocale): Promise<unknown> {
return i18next.changeLanguage(locale);
}
export { i18next };
// Local type guard (detect.ts exports this too; re-declared here to keep this
// file free of cyclic references).
function isSupportedLocale(value: string): value is SupportedLocale {
return value === 'en' || value === 'de';
}
@@ -0,0 +1,175 @@
{
"nav": {
"chats": "Chats",
"friends": "Freunde",
"settings": "Einstellungen",
"admin": "Admin"
},
"sidebar": {
"search_placeholder": "Suchen…",
"new_chat": "Neuer Chat",
"sign_out": "Abmelden"
},
"presence": {
"online": "Online",
"idle": "Abwesend",
"dnd": "Nicht stören",
"invisible": "Unsichtbar",
"offline": "Offline"
},
"chats": {
"empty_title": "Noch keine Unterhaltungen",
"empty_subtitle": "Starte einen Chat über den Freunde-Tab.",
"select_prompt": "Unterhaltung auswählen",
"select_subtitle": "Wähle einen Chat aus der Liste oder starte einen neuen.",
"deleted": "(gelöscht)",
"edited": "bearbeitet",
"seen": "Gelesen",
"typing_one": "{{name}} schreibt…",
"typing_many": "{{count}} schreiben…",
"new_chat": "Neuer Chat",
"new_group": "Neue Gruppe",
"call_outgoing": "Ausgehender Anruf",
"call_incoming": "Eingehender Anruf",
"call_missed": "Verpasster Anruf",
"call_no_answer": "Keine Antwort",
"call_declined": "Anruf abgelehnt"
},
"call": {
"start_audio": "Sprachanruf",
"incoming_title": "Eingehender Anruf",
"incoming_from": "{{name}} ruft an",
"incoming_group_from": "{{name}} ruft die Gruppe",
"outgoing_ringing": "Klingelt…",
"connecting": "Verbinde…",
"connected": "Im Gespräch",
"accept": "Annehmen",
"decline": "Ablehnen",
"hangup": "Auflegen",
"mute": "Stumm",
"unmute": "Laut",
"busy": "Besetzt — bereits im Gespräch",
"active_in_conv": "Laufender Anruf · {{count}} im Raum",
"join": "Beitreten",
"in_call": "Im Anruf",
"waiting_for_peers": "Warte auf andere…",
"voice_connected": "Sprachchat verbunden",
"still_live": "Anruf läuft noch",
"share_screen": "Bildschirm teilen",
"stop_share_screen": "Screen-Share stoppen",
"is_sharing_screen": "{{name}} teilt den Bildschirm",
"watch_screen": "Bildschirm anschauen",
"stop_watching": "Nicht mehr anschauen",
"fullscreen": "Vollbild",
"e2ee_active_hint": "Audio + Video sind Ende-zu-Ende-verschlüsselt"
},
"group": {
"create_title": "Neue Gruppe",
"create_name_label": "Gruppenname",
"create_name_placeholder": "Team-Chat",
"create_members_label": "Freunde hinzufügen",
"create_members_empty": "Noch keine Freunde — erst welche hinzufügen.",
"create_cta": "Gruppe erstellen",
"create_cta_loading": "Erstelle…",
"info_title": "Gruppen-Info",
"info_members": "Mitglieder",
"info_role_admin": "Admin",
"info_role_mod": "Mod",
"info_role_member": "Mitglied",
"info_add_title": "Mitglieder hinzufügen",
"info_add_empty": "Alle deine Freunde sind bereits in dieser Gruppe.",
"info_add_help": "Klick auf einen Freund um ihn direkt hinzuzufügen.",
"info_leave": "Gruppe verlassen",
"info_leave_confirm": "Diese Gruppe wirklich verlassen?"
},
"friends": {
"title": "Freunde",
"search_placeholder": "Nach Benutzername suchen…",
"search_min_chars": "Mindestens 2 Zeichen eingeben.",
"search_no_results": "Keine Treffer.",
"search_results_title": "Suchergebnisse",
"send_request": "Anfrage senden",
"request_sent": "Anfrage gesendet",
"already_friends": "Bereits befreundet",
"incoming_request": "Möchte befreundet sein",
"tab_friends": "Freunde",
"tab_pending": "Ausstehend",
"tab_requests": "Anfragen",
"empty_friends": "Noch keine Freunde.",
"empty_pending": "Keine ausgehenden Anfragen.",
"empty_requests": "Keine eingehenden Anfragen.",
"action_message": "Nachricht",
"action_unfriend": "Entfernen",
"action_accept": "Annehmen",
"action_decline": "Ablehnen",
"action_cancel": "Abbrechen",
"confirm_unfriend": "Diesen Freund entfernen?"
},
"admin": {
"nav": "Admin",
"title": "Admin-Panel",
"settings_title": "Globale Einstellungen",
"invites_enabled": "Neue Registrierungen erlauben",
"invites_enabled_hint": "Wenn aus, kann sich niemand neu registrieren — auch nicht mit gültigem Invite.",
"invites_title": "Einladungscodes",
"invites_create": "Neuer Code",
"invites_empty": "Noch keine Codes.",
"invites_disable": "Deaktivieren",
"invites_enable": "Aktivieren",
"invites_delete": "Löschen",
"invites_copy": "Kopieren",
"invites_copied": "Kopiert",
"users_title": "Benutzer",
"users_empty": "Noch keine Profile.",
"users_flag_admin": "Admin",
"users_flag_banned": "Gesperrt",
"users_flag_blocked_inviting": "Darf keine Invites erstellen",
"invite_col_code": "Code",
"invite_col_uses": "Nutzung",
"invite_col_expires": "Läuft ab",
"invite_col_status": "Status",
"invite_col_created": "Erstellt",
"invite_status_active": "Aktiv",
"invite_status_disabled": "Deaktiviert",
"invite_status_expired": "Abgelaufen",
"invite_expires_never": "Nie"
},
"settings": {
"title": "Einstellungen",
"section_account": "Account",
"section_appearance": "Darstellung",
"section_privacy": "Privatsphäre",
"section_devices": "Geräte",
"section_voice": "Sprache",
"audio_quality": "Audio-Qualität",
"audio_voice": "Sprache (Empfohlen)",
"audio_hifi": "HiFi / Musik",
"audio_voice_hint": "Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.",
"audio_hifi_hint": "Stereo 510 kbps Opus ohne DSP — bester Musik/Broadcast-Sound. Erfordert ruhige Umgebung.",
"e2ee_calls": "Ende-zu-Ende-Verschlüsselung (Calls)",
"e2ee_calls_hint": "Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben.",
"e2ee_calls_unsupported": "Dein Browser unterstützt keine Insertable Streams. E2EE-Calls nicht verfügbar.",
"ptt_enabled": "Push-to-Talk",
"ptt_enabled_hint": "Mic bleibt stumm, bis die Taste gehalten wird. Overridet den normalen Mute-Button.",
"ptt_key": "Hotkey",
"ptt_press_key": "Taste drücken…",
"section_screen_share": "Bildschirmfreigabe",
"screen_share_quality": "Qualität",
"screen_share_hint": "WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.",
"language": "Sprache",
"presence": "Status",
"show_read_receipts": "Lesebestätigungen anzeigen",
"show_read_receipts_hint": "Wenn aus, sehen andere nicht wann du ihre Nachrichten gelesen hast — und du siehst nicht wann sie deine gelesen haben.",
"allow_dms_strangers": "DMs von Fremden erlauben",
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
"this_device": "Dieses Gerät",
"danger_zone": "Gefahrenzone",
"sign_out": "Abmelden"
},
"update": {
"available": "Update verfügbar",
"install": "Installieren & Neustarten",
"downloading": "Lade",
"installing": "Installiere…"
}
}
@@ -0,0 +1,77 @@
{
"brand": {
"badge": "Ende-zu-Ende verschlüsselt · nur auf Einladung",
"title_line_1": "Private Nachrichten",
"title_line_2": "für deinen Kreis.",
"subtitle": "Selbst gehostet, Zero-Knowledge-Server, libsodium-Kryptografie. Du und deine Freunde — nichts dazwischen.",
"feature_zk_title": "Zero-Knowledge",
"feature_zk_desc": "Der Server entschlüsselt niemals deinen Chiffretext.",
"feature_selfhost_title": "Self-Hosted",
"feature_selfhost_desc": "Dein Supabase. Dein VPS. Deine Schlüssel.",
"feature_invite_title": "Nur mit Einladung",
"feature_invite_desc": "Keine Suche, keine Fremden. Geschlossener Kreis."
},
"signup": {
"title": "Account erstellen",
"subtitle": "Keine Passwörter. Magic Link per E-Mail.",
"cta": "Magic Link senden",
"cta_sending": "Link wird gesendet…"
},
"login": {
"title": "Willkommen zurück",
"subtitle": "E-Mail eingeben — Magic Link folgt.",
"cta": "Magic Link senden",
"cta_sending": "Link wird gesendet…"
},
"tab_signup": "Registrieren",
"tab_login": "Anmelden",
"fields": {
"email": "E-Mail",
"email_placeholder": "du@beispiel.de",
"username": "Benutzername",
"username_placeholder": "dennis",
"username_hint": "Damit meldest du dich an. Nur Kleinbuchstaben.",
"username_invalid": "Kleinbuchstaben az, Ziffern, Unterstrich · 332 Zeichen.",
"invite_code": "Einladungscode",
"invite_hint": "Pflichtfeld · Zugang nur auf Einladung."
},
"sent_banner": "Magic Link an {{email}} gesendet.",
"sent_banner_hint": "Dev-Stack: Inbucket öffnen und den 6-stelligen Code aus der Mail kopieren.",
"otp_label": "6-stelliger Code",
"otp_placeholder": "123456",
"otp_hint": "Füge den Code aus der Mail ein.",
"otp_cta": "Code prüfen",
"otp_cta_loading": "Prüfe…",
"otp_back": "Andere E-Mail verwenden",
"footer_signup_prompt": "Schon registriert?",
"footer_login_prompt": "Neu hier?",
"footer_switch_to_login": "Anmelden",
"footer_switch_to_signup": "Account erstellen",
"footer_studio": "Supabase Studio",
"legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.",
"signed_in": {
"title": "Angemeldet",
"session_active": "Sitzung aktiv",
"user_id": "Benutzer-ID",
"email": "E-Mail",
"username": "Benutzername",
"display_name": "Anzeigename",
"admin": "Admin",
"yes": "Ja",
"no": "Nein",
"sign_out": "Abmelden",
"device_active": "Aktives Gerät",
"device_platform": "Plattform",
"device_registered_at": "Registriert"
},
"device": {
"title": "Dieses Gerät registrieren",
"subtitle": "Erzeugt ein X25519-Schlüsselpaar. Der private Schlüssel bleibt auf diesem Gerät.",
"name_label": "Gerätename",
"name_hint": "Erscheint in deiner Geräteliste. Wähle einen erkennbaren Namen.",
"name_placeholder": "Dennis Laptop",
"cta": "Gerät registrieren",
"cta_loading": "Schlüsselpaar wird erzeugt…",
"security_note_dev": "Dev-Build: Privater Schlüssel liegt unverschlüsselt im localStorage. Stronghold folgt vor dem Release."
}
}
@@ -0,0 +1,16 @@
{
"app_name": "ChatApp",
"loading": "Lädt…",
"finalising_session": "Sitzung wird abgeschlossen…",
"cancel": "Abbrechen",
"save": "Speichern",
"close": "Schließen",
"retry": "Wiederholen",
"online": "Online",
"offline": "Offline",
"idle": "Abwesend",
"dnd": "Nicht stören",
"invisible": "Unsichtbar",
"local_stack_online": "Lokaler Stack online",
"dev_build": "Dev-Build"
}
@@ -0,0 +1,28 @@
{
"generic": "Etwas ist schiefgelaufen.",
"network": "Netzwerkfehler. Prüfe deine Verbindung.",
"ERR_NOT_AUTH": "Du bist nicht angemeldet.",
"ERR_INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"ERR_USERNAME_INVALID": "Benutzername muss aus Kleinbuchstaben az, Ziffern oder Unterstrich bestehen (332 Zeichen).",
"ERR_INVITES_DISABLED": "Registrierungen sind derzeit vom Admin deaktiviert.",
"ERR_INVITE_NOT_FOUND": "Einladungscode nicht gefunden.",
"ERR_INVITE_DISABLED": "Diese Einladung wurde deaktiviert.",
"ERR_INVITE_EXPIRED": "Diese Einladung ist abgelaufen.",
"ERR_INVITE_EXHAUSTED": "Diese Einladung wurde bereits aufgebraucht.",
"ERR_DM_SELF": "Du kannst dir keine DM an dich selbst schicken.",
"ERR_DM_STRANGERS_DISABLED": "Dieser Benutzer akzeptiert keine DMs von Fremden.",
"ERR_NO_PENDING_DM": "Keine offene DM-Anfrage gefunden.",
"ERR_GROUP_INVITE_NOT_FOUND": "Gruppeneinladung nicht gefunden.",
"ERR_GROUP_INVITE_DISABLED": "Diese Gruppeneinladung wurde deaktiviert.",
"ERR_GROUP_INVITE_EXPIRED": "Diese Gruppeneinladung ist abgelaufen.",
"ERR_GROUP_INVITE_EXHAUSTED": "Diese Gruppeneinladung wurde bereits aufgebraucht.",
"ERR_FRIEND_SELF": "Du kannst dich nicht selbst als Freund hinzufügen.",
"ERR_FRIEND_SELF_ACCEPT": "Du kannst deine eigene Freundschaftsanfrage nicht annehmen.",
"ERR_FRIEND_BAD_TRANSITION": "Ungültiger Freundschaftsstatus-Wechsel.",
"ERR_MESSAGE_DELETED": "Diese Nachricht wurde bereits gelöscht.",
"ERR_DELETE_FORBIDDEN": "Du darfst diese Nachricht nicht löschen.",
"ERR_EDIT_NOT_SENDER": "Nur der Absender kann diese Nachricht bearbeiten.",
"ERR_EDIT_WINDOW_EXPIRED": "Das 24-Stunden-Bearbeitungsfenster ist abgelaufen.",
"ERR_ENVELOPE_NOT_SENDER": "Nur der Absender kann die Envelopes neu schreiben.",
"ERR_ENVELOPE_WINDOW_EXPIRED": "Das 24-Stunden-Envelope-Bearbeitungsfenster ist abgelaufen."
}
@@ -0,0 +1,175 @@
{
"nav": {
"chats": "Chats",
"friends": "Friends",
"settings": "Settings",
"admin": "Admin"
},
"sidebar": {
"search_placeholder": "Search…",
"new_chat": "New chat",
"sign_out": "Sign out"
},
"presence": {
"online": "Online",
"idle": "Idle",
"dnd": "Do not disturb",
"invisible": "Invisible",
"offline": "Offline"
},
"chats": {
"empty_title": "No conversations yet",
"empty_subtitle": "Start a chat from the Friends tab.",
"select_prompt": "Select a conversation",
"select_subtitle": "Pick a chat from the list, or start a new one.",
"deleted": "(deleted)",
"edited": "edited",
"seen": "Seen",
"typing_one": "{{name}} is typing…",
"typing_many": "{{count}} people are typing…",
"new_chat": "New chat",
"new_group": "New group",
"call_outgoing": "Outgoing call",
"call_incoming": "Incoming call",
"call_missed": "Missed call",
"call_no_answer": "No answer",
"call_declined": "Call declined"
},
"call": {
"start_audio": "Voice call",
"incoming_title": "Incoming call",
"incoming_from": "{{name}} is calling",
"incoming_group_from": "{{name}} is calling the group",
"outgoing_ringing": "Calling…",
"connecting": "Connecting…",
"connected": "In call",
"accept": "Accept",
"decline": "Decline",
"hangup": "Hang up",
"mute": "Mute",
"unmute": "Unmute",
"busy": "Busy — already in a call",
"active_in_conv": "Active call · {{count}} in room",
"join": "Join",
"in_call": "In call",
"waiting_for_peers": "Waiting for others…",
"voice_connected": "Voice connected",
"still_live": "Call still live",
"share_screen": "Share screen",
"stop_share_screen": "Stop sharing",
"is_sharing_screen": "{{name}} is sharing their screen",
"watch_screen": "Watch screen",
"stop_watching": "Stop watching",
"fullscreen": "Fullscreen",
"e2ee_active_hint": "Audio + video are end-to-end encrypted"
},
"group": {
"create_title": "New group",
"create_name_label": "Group name",
"create_name_placeholder": "Team chat",
"create_members_label": "Add friends",
"create_members_empty": "No friends yet — add some first.",
"create_cta": "Create group",
"create_cta_loading": "Creating…",
"info_title": "Group info",
"info_members": "Members",
"info_role_admin": "Admin",
"info_role_mod": "Mod",
"info_role_member": "Member",
"info_add_title": "Add members",
"info_add_empty": "All your friends are already in this group.",
"info_add_help": "Pick a friend to add them directly.",
"info_leave": "Leave group",
"info_leave_confirm": "Leave this group?"
},
"friends": {
"title": "Friends",
"search_placeholder": "Search by username…",
"search_min_chars": "Type at least 2 characters.",
"search_no_results": "No users matched.",
"search_results_title": "Search results",
"send_request": "Send request",
"request_sent": "Request sent",
"already_friends": "Already friends",
"incoming_request": "Wants to be friends",
"tab_friends": "Friends",
"tab_pending": "Pending",
"tab_requests": "Requests",
"empty_friends": "No friends yet.",
"empty_pending": "No outgoing requests.",
"empty_requests": "No incoming requests.",
"action_message": "Message",
"action_unfriend": "Unfriend",
"action_accept": "Accept",
"action_decline": "Decline",
"action_cancel": "Cancel",
"confirm_unfriend": "Remove this friend?"
},
"admin": {
"nav": "Admin",
"title": "Admin panel",
"settings_title": "Global settings",
"invites_enabled": "Allow new signups",
"invites_enabled_hint": "When off, no new users can sign up even with a valid invite.",
"invites_title": "Invite codes",
"invites_create": "New code",
"invites_empty": "No invites yet.",
"invites_disable": "Disable",
"invites_enable": "Enable",
"invites_delete": "Delete",
"invites_copy": "Copy",
"invites_copied": "Copied",
"users_title": "Users",
"users_empty": "No profiles yet.",
"users_flag_admin": "Admin",
"users_flag_banned": "Banned",
"users_flag_blocked_inviting": "Blocked from inviting",
"invite_col_code": "Code",
"invite_col_uses": "Uses",
"invite_col_expires": "Expires",
"invite_col_status": "Status",
"invite_col_created": "Created",
"invite_status_active": "Active",
"invite_status_disabled": "Disabled",
"invite_status_expired": "Expired",
"invite_expires_never": "Never"
},
"settings": {
"title": "Settings",
"section_account": "Account",
"section_appearance": "Appearance",
"section_privacy": "Privacy",
"section_devices": "Devices",
"section_voice": "Voice",
"audio_quality": "Audio quality",
"audio_voice": "Voice (Recommended)",
"audio_hifi": "HiFi / Music",
"audio_voice_hint": "Mono 48 kbps with noise suppression, echo cancellation and auto-gain. Optimised for speech in a room.",
"audio_hifi_hint": "Stereo 510 kbps Opus with all DSP off — best for music/broadcast. Requires a quiet environment.",
"e2ee_calls": "End-to-end encryption (calls)",
"e2ee_calls_hint": "Audio + video are encrypted before upload. The server only sees ciphertext. All participants must have the option enabled.",
"e2ee_calls_unsupported": "Your browser doesn't support Insertable Streams. E2EE calls unavailable.",
"ptt_enabled": "Push-to-Talk",
"ptt_enabled_hint": "Mic stays muted until the key is held. Overrides the regular mute button.",
"ptt_key": "Hotkey",
"ptt_press_key": "Press a key…",
"section_screen_share": "Screen share",
"screen_share_quality": "Quality",
"screen_share_hint": "WebRTC dynamically adjusts bitrate + resolution to match network conditions (SVC/VP9). Values are upper bounds. Changes apply on the next call.",
"language": "Language",
"presence": "Presence",
"show_read_receipts": "Show read receipts",
"show_read_receipts_hint": "When off, others can't see when you read their messages — and you won't see when they read yours.",
"allow_dms_strangers": "Allow DMs from strangers",
"allow_dms_strangers_hint": "When off, only friends can DM you.",
"this_device": "This device",
"danger_zone": "Danger zone",
"sign_out": "Sign out"
},
"update": {
"available": "Update available",
"install": "Install & restart",
"downloading": "Downloading",
"installing": "Installing…"
}
}
@@ -0,0 +1,77 @@
{
"brand": {
"badge": "End-to-end encrypted · invite-only",
"title_line_1": "Private messaging",
"title_line_2": "for your circle.",
"subtitle": "Self-hosted, zero-knowledge server, libsodium crypto. You and your friends — nothing between.",
"feature_zk_title": "Zero-knowledge",
"feature_zk_desc": "Ciphertext never gets decrypted on the server.",
"feature_selfhost_title": "Self-hosted",
"feature_selfhost_desc": "Your Supabase. Your VPS. Your keys.",
"feature_invite_title": "Invite-only",
"feature_invite_desc": "No discovery, no strangers. Trusted circle."
},
"signup": {
"title": "Create your account",
"subtitle": "No passwords. Magic link via email.",
"cta": "Send magic link",
"cta_sending": "Sending link…"
},
"login": {
"title": "Welcome back",
"subtitle": "Enter your email — magic link follows.",
"cta": "Send magic link",
"cta_sending": "Sending link…"
},
"tab_signup": "Sign up",
"tab_login": "Log in",
"fields": {
"email": "Email",
"email_placeholder": "you@example.com",
"username": "Username",
"username_placeholder": "dennis",
"username_hint": "You log in with this. Lowercase only.",
"username_invalid": "Lowercase az, digits, underscore · 332 chars.",
"invite_code": "Invite code",
"invite_hint": "Required · invite-only access."
},
"sent_banner": "Magic link sent to {{email}}.",
"sent_banner_hint": "Dev stack: open Inbucket and copy the 6-digit code from the email.",
"otp_label": "6-digit code",
"otp_placeholder": "123456",
"otp_hint": "Paste the code from the email.",
"otp_cta": "Verify code",
"otp_cta_loading": "Verifying…",
"otp_back": "Use a different email",
"footer_signup_prompt": "Already registered?",
"footer_login_prompt": "New here?",
"footer_switch_to_login": "Log in",
"footer_switch_to_signup": "Create account",
"footer_studio": "Supabase Studio",
"legal_note": "By signing up you accept that the server sees only ciphertext.",
"signed_in": {
"title": "Signed in",
"session_active": "Session active",
"user_id": "User ID",
"email": "Email",
"username": "Username",
"display_name": "Display name",
"admin": "Admin",
"yes": "yes",
"no": "no",
"sign_out": "Sign out",
"device_active": "Active device",
"device_platform": "Platform",
"device_registered_at": "Registered"
},
"device": {
"title": "Register this device",
"subtitle": "Generates an X25519 keypair. Private key stays on this device.",
"name_label": "Device name",
"name_hint": "Shown to you in your device list. Keep it recognisable.",
"name_placeholder": "Dennis Laptop",
"cta": "Register device",
"cta_loading": "Generating keypair…",
"security_note_dev": "Dev build: private key stored unencrypted in localStorage. Stronghold comes before release."
}
}
@@ -0,0 +1,16 @@
{
"app_name": "ChatApp",
"loading": "Loading…",
"finalising_session": "Finalising session…",
"cancel": "Cancel",
"save": "Save",
"close": "Close",
"retry": "Retry",
"online": "Online",
"offline": "Offline",
"idle": "Idle",
"dnd": "Do not disturb",
"invisible": "Invisible",
"local_stack_online": "local stack online",
"dev_build": "dev build"
}
@@ -0,0 +1,28 @@
{
"generic": "Something went wrong.",
"network": "Network error. Check your connection.",
"ERR_NOT_AUTH": "You are not signed in.",
"ERR_INVITE_CODE_REQUIRED": "Invite code is required.",
"ERR_USERNAME_INVALID": "Username must be lowercase az, digits, underscore, 332 chars.",
"ERR_INVITES_DISABLED": "Signups are currently disabled by the admin.",
"ERR_INVITE_NOT_FOUND": "Invite code not found.",
"ERR_INVITE_DISABLED": "This invite has been disabled.",
"ERR_INVITE_EXPIRED": "This invite has expired.",
"ERR_INVITE_EXHAUSTED": "This invite has already been used up.",
"ERR_DM_SELF": "You cannot DM yourself.",
"ERR_DM_STRANGERS_DISABLED": "This user does not accept DMs from strangers.",
"ERR_NO_PENDING_DM": "No pending DM request found.",
"ERR_GROUP_INVITE_NOT_FOUND": "Group invite not found.",
"ERR_GROUP_INVITE_DISABLED": "This group invite has been disabled.",
"ERR_GROUP_INVITE_EXPIRED": "This group invite has expired.",
"ERR_GROUP_INVITE_EXHAUSTED": "This group invite has already been used up.",
"ERR_FRIEND_SELF": "You cannot add yourself as a friend.",
"ERR_FRIEND_SELF_ACCEPT": "You cannot accept your own friend request.",
"ERR_FRIEND_BAD_TRANSITION": "Invalid friendship state transition.",
"ERR_MESSAGE_DELETED": "This message was already deleted.",
"ERR_DELETE_FORBIDDEN": "You are not allowed to delete this message.",
"ERR_EDIT_NOT_SENDER": "Only the sender can edit this message.",
"ERR_EDIT_WINDOW_EXPIRED": "The 24-hour edit window has passed.",
"ERR_ENVELOPE_NOT_SENDER": "Only the sender can rewrite envelopes.",
"ERR_ENVELOPE_WINDOW_EXPIRED": "The 24-hour envelope edit window has passed."
}
+14
View File
@@ -0,0 +1,14 @@
import deApp from './locales/de/app.json';
import deAuth from './locales/de/auth.json';
import deCommon from './locales/de/common.json';
import deErrors from './locales/de/errors.json';
import enApp from './locales/en/app.json';
import enAuth from './locales/en/auth.json';
import enCommon from './locales/en/common.json';
import enErrors from './locales/en/errors.json';
import type { Resources, SupportedLocale } from './types.js';
export const resources: Record<SupportedLocale, Resources> = {
en: { common: enCommon, auth: enAuth, errors: enErrors, app: enApp },
de: { common: deCommon, auth: deAuth, errors: deErrors, app: deApp },
};
+18
View File
@@ -0,0 +1,18 @@
import type enApp from './locales/en/app.json';
import type enAuth from './locales/en/auth.json';
import type enCommon from './locales/en/common.json';
import type enErrors from './locales/en/errors.json';
export const SUPPORTED_LOCALES = ['en', 'de'] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: SupportedLocale = 'en';
export interface Resources {
common: typeof enCommon;
auth: typeof enAuth;
errors: typeof enErrors;
app: typeof enApp;
}
export type Namespace = keyof Resources;
+8
View File
@@ -0,0 +1,8 @@
export * as admin from './admin/index.js';
export * as auth from './auth/index.js';
export * as chat from './chat/index.js';
export * as crypto from './crypto/index.js';
export * as friends from './friends/index.js';
export * as i18n from './i18n/index.js';
export * as rtc from './rtc/index.js';
export * as supabase from './supabase/index.js';
+2
View File
@@ -0,0 +1,2 @@
export * from './token.js';
export * from './types.js';
+27
View File
@@ -0,0 +1,27 @@
import type { AppSupabaseClient } from '../supabase/client.js';
import type { LivekitToken } from './types.js';
// Fetch a short-lived LiveKit access token via the mint-livekit-token
// edge function. The server-side RLS check lives inside that function.
export async function fetchLivekitToken(
client: AppSupabaseClient,
conversationId: string,
): Promise<LivekitToken> {
const { data, error } = await client.functions.invoke('mint-livekit-token', {
body: { conversationId },
});
if (error) throw error;
if (!data || typeof data !== 'object') {
throw new Error('invalid token response');
}
const d = data as Partial<LivekitToken>;
if (!d.token || !d.url || !d.roomName || !d.identity) {
throw new Error('incomplete token response');
}
return {
token: d.token,
url: d.url,
roomName: d.roomName,
identity: d.identity,
};
}
+56
View File
@@ -0,0 +1,56 @@
// Call signaling + token types. Realtime broadcast payloads live on a
// per-user channel (`call-signals:<userId>`) so incoming invites surface
// even if the recipient isn't currently on the relevant chat route.
export type CallKind = 'audio' | 'video';
export interface CallInvitePayload {
type: 'invite';
callId: string;
conversationId: string;
fromUserId: string;
kind: CallKind;
sentAt: string; // ISO
}
export interface CallAcceptPayload {
type: 'accept';
callId: string;
byUserId: string;
}
export interface CallRejectPayload {
type: 'reject';
callId: string;
byUserId: string;
}
export interface CallCancelPayload {
type: 'cancel';
callId: string;
byUserId: string;
}
export interface CallEndPayload {
type: 'end';
callId: string;
byUserId: string;
}
export type CallSignal =
| CallInvitePayload
| CallAcceptPayload
| CallRejectPayload
| CallCancelPayload
| CallEndPayload;
export function signalTopic(userId: string): string {
return 'call-signals:' + userId;
}
export interface LivekitToken {
token: string;
url: string;
roomName: string;
identity: string;
}
+25
View File
@@ -0,0 +1,25 @@
// Postgres bytea <-> Uint8Array.
//
// PostgREST serializes bytea as a "\x<hex>" string in JSON. When inserting via
// the Supabase client, sending the same literal back round-trips cleanly.
export function bytesToPgHex(bytes: Uint8Array): string {
let out = '\\x';
for (let i = 0; i < bytes.length; i++) {
out += bytes[i]!.toString(16).padStart(2, '0');
}
return out;
}
export function pgHexToBytes(hex: string): Uint8Array {
if (!hex.startsWith('\\x')) {
throw new Error(`not a postgres hex literal: ${hex.slice(0, 8)}`);
}
const stripped = hex.slice(2);
if (stripped.length % 2 !== 0) throw new Error('odd hex length');
const out = new Uint8Array(stripped.length / 2);
for (let i = 0; i < stripped.length; i += 2) {
out[i / 2] = Number.parseInt(stripped.slice(i, i + 2), 16);
}
return out;
}
+17
View File
@@ -0,0 +1,17 @@
import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js';
import type { Database, SupabaseConfig } from './types.js';
// Typed client alias used throughout the app.
export type AppSupabaseClient = SupabaseClient<Database>;
export function createClient(config: SupabaseConfig): AppSupabaseClient {
return createSupabaseClient<Database>(config.url, config.anonKey, {
auth: {
storage: config.sessionStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: config.detectSessionInUrl ?? false,
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from './bytea.js';
export * from './client.js';
export * from './types.js';
+25
View File
@@ -0,0 +1,25 @@
import type { Database } from '@chat-app/db-types';
export type { Database };
// Storage contract used by Supabase auth. Both `expo-secure-store` and
// `@tauri-apps/plugin-store` can be wrapped to satisfy this.
export interface KeyValueStore {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
export interface SupabaseConfig {
url: string;
anonKey: string;
sessionStorage: KeyValueStore;
// Desktop Tauri webview may need this true to parse magic-link callbacks.
detectSessionInUrl?: boolean;
}
export type DevicePlatform = Database['public']['Enums']['device_platform'];
export type PresenceState = Database['public']['Enums']['presence_state'];
export type FriendshipStatus = Database['public']['Enums']['friendship_status'];
export type MemberRole = Database['public']['Enums']['member_role'];
export type ConversationType = Database['public']['Enums']['conversation_type'];
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"]
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@chat-app/ui-web",
"version": "0.1.0",
"private": true,
"description": "React web UI components shared by the Tauri desktop app (not consumed by React Native)",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
},
"scripts": {
"build": "tsc -b",
"dev": "tsc -b --watch",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist .turbo *.tsbuildinfo"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1"
}
}
+5
View File
@@ -0,0 +1,5 @@
// Barrel for shared web UI components.
// Re-export individual components from ./components as they are authored.
// Keep this tree shakable: no side-effect imports here.
export {};
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "./src",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx"
},
"include": ["src/**/*"]
}