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