Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,22 +107,22 @@ instance without a lookup table.

```mermaid
sequenceDiagram
participant A as Sender device
participant IA as UserInbox sender
participant C as Chat chatId
participant N as Neon
participant IB as UserInbox recipient
participant B as Recipient device

A->>IA: send · MLS ciphertext (msgpack)
IA->>C: RPC forward
Note over C: buffer ~100ms<br/>assign monotonic serverSerial
C->>N: batch INSERT over HTTP
C-->>IA: ack (serverSerial)
IA-->>A: ack
C->>IB: msg (ciphertext)
IB-->>B: WS deliver → MLS decrypt → SQLite
Note over C,B: recipient offline →<br/>SNS ChatDelivered → SQS → chat-push → APNs/FCM
participant A as Sender device
participant IA as UserInbox sender
participant C as Chat chatId
participant N as Neon
participant IB as UserInbox recipient
participant B as Recipient device

A->>IA: send · MLS ciphertext (msgpack)
IA->>C: RPC forward
Note over C: buffer ~100ms<br/>assign monotonic serverSerial
C->>N: batch INSERT over HTTP
C-->>IA: ack (serverSerial)
IA-->>A: ack
C->>IB: msg (ciphertext)
IB-->>B: WS deliver → MLS decrypt → SQLite
Note over C,B: recipient offline →<br/>SNS ChatDelivered → SQS → chat-push → APNs/FCM
```

### Trust boundary
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,12 @@ getOrCreateChatIdentity()
console.error("[chat-mvp/M3] chat-identity init failed", err);
});

// chat-db is per-account (chat-<userId>.sqlite) and opens only once the auth
// store pushes a signed-in user — this boot probe resolves after the first
// sign-in rather than at launch, and stays pending on a signed-out cold boot.
getChatDb()
.then((handle) => {
console.log("[chat-mvp/M2] chat-db ready", {
console.log("[chat-mvp/M2] chat-db ready (post-auth)", {
schemaVersion: handle.version,
keySource: handle.keySource,
});
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/app/settings/blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default function BlocksScreen() {
data={rows}
keyExtractor={(r) => r.accountId}
renderItem={({ item }) => (
<BlockedRow
<BlockedRowItem
row={item}
isPending={unblocking === item.accountId}
onUnblock={() => onUnblock(item)}
Expand All @@ -117,7 +117,7 @@ export default function BlocksScreen() {
);
}

function BlockedRow({
function BlockedRowItem({
row,
isPending,
onUnblock,
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ export default [
...expo,
eslintConfigPrettier,
{
ignores: ["dist/**", "android/**", "ios/**", ".expo/**"],
ignores: ["dist/**", "android/**", "ios/**", ".expo/**", "sst-env.d.ts"],
},
];
13 changes: 13 additions & 0 deletions apps/mobile/lib/chat/create-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@
// step fails (e.g. peer has 0 KeyPackages right now), the chat row + member
// rows still exist on the server. UI surfaces the failure; user can retry.

import { useChatStore } from "@repo/chat";
import { getChatDb, mls as mlsStore } from "@repo/chat-db";
import { trpc } from "@/lib/trpc/client";
import { getMlsClient } from "@/lib/chat/mls-auto-publish";
import { clearChatGroupCache } from "./group-resolver";
import { createTrpcChatApi } from "./store-provider";

// The chat screen renders from the zustand store, and nothing refreshes the
// store after a create — the WS `open` refresh fired long before this chat
// existed. Without this pull the freshly-created chat has no store record and
// /chat/[chatId] shows an indefinite spinner. refresh() merges + never throws.
async function syncChatStore(): Promise<void> {
await useChatStore.getState().refresh(createTrpcChatApi());
}

// GroupId (Uint8Array) → base64 for the linkMlsGroup wire. Mirrors the encoder
// in publish.ts (Hermes provides global btoa); avoids a Buffer polyfill dep.
Expand Down Expand Up @@ -58,6 +68,7 @@ export async function createNewChat(
if (created.existing) {
const chat = await trpc.chat.get.query({ chatId: created.chatId });
if (chat.mlsGroupIdB64) {
await syncChatStore();
return {
chatId: created.chatId,
existing: true,
Expand Down Expand Up @@ -115,6 +126,8 @@ export async function createNewChat(
}
}

await syncChatStore();

return {
chatId: created.chatId,
existing: created.existing,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/lib/chat/mls-auto-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { trpc } from "@/lib/trpc/client";
import { loadSessionToken } from "@/lib/auth/session";
import { useAuthStore } from "@/store/auth";
import { getOrCreateChatIdentity } from "./identity";
import { kickChatBackfill } from "./store-provider";
import { publishMyChatDevice } from "./publish";
import { getCurrentChatTransport } from "./transport";
import { clearChatGroupCache } from "./group-resolver";
Expand Down Expand Up @@ -196,6 +197,10 @@ async function tick(reason: string): Promise<void> {
hexStr += (gid[i] ?? 0).toString(16).padStart(2, "0");
clearChatGroupCache(`mls-${hexStr}`);
}
// Newly-joined groups may have backfill rows sitting behind a capped
// cursor (dropped recoverably before the join) — re-request them now
// that the engine holds the group.
kickChatBackfill();
}

// Poll commits for every locally-known group. At Phase 1 scale the
Expand Down Expand Up @@ -267,6 +272,12 @@ async function onAuthChange(reason: string): Promise<void> {

await tick(reason);
startTimer();

// Engine is initialised for this account only from here on. Any backfill
// page that arrived earlier (WS open races bootstrap, esp. on account
// switch) dropped its rows with "engine not initialised" and capped the
// cursor — re-request those rows now that decrypt can succeed.
kickChatBackfill();
}

// Foreground transitions trigger an immediate catch-up tick. Saves the user
Expand Down
43 changes: 41 additions & 2 deletions apps/mobile/lib/chat/store-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AppState } from "react-native";
import {
ChatStoreProvider,
createOutboxWorker,
useChatBackfill,
useChatStore,
type BackfillCursorApi,
type BoundOutboxApi,
Expand Down Expand Up @@ -66,7 +67,9 @@ async function mirrorChatsToLocalDb(
}
}

function createTrpcChatApi(): ChatApi {
// Exported for non-React callers that need a one-off store refresh with the
// same list→local-mirror semantics the provider uses (e.g. create-chat).
export function createTrpcChatApi(): ChatApi {
return {
chatList: async (input) => {
const res = await trpc.chat.list.query(input);
Expand All @@ -87,6 +90,30 @@ function createTrpcChatApi(): ChatApi {
};
}

// Module-level bridge so non-React code (the MLS auto-publish loop) can kick
// a backfill pass. The decisive consumer: a backfill page that raced
// initEngine(accountId) drops its rows recoverably and caps the cursor — the
// engine-ready kick is what re-requests those rows once decrypt can succeed.
// Mirrors the getCurrentChatTransport() singleton pattern (transport.tsx).
let backfillKicker: (() => void) | null = null;

export function kickChatBackfill(): void {
backfillKicker?.();
}

// Rendered inside ChatStoreProvider so useChatBackfill is in scope; registers
// the provider's runner into the module-level bridge above.
function BackfillKickBridge() {
const kick = useChatBackfill();
useEffect(() => {
backfillKicker = kick;
return () => {
if (backfillKicker === kick) backfillKicker = null;
};
}, [kick]);
return null;
}

// Curry chat-db's backfill_cursors helpers onto a handle → the injected
// BackfillCursorApi the store provider drives (docs/message-backfill.md).
function bindBackfillCursors(handle: ChatDbHandle): BackfillCursorApi {
Expand All @@ -113,6 +140,9 @@ function bindOutbox(handle: ChatDbHandle): BoundOutboxApi {

export function MobileChatStoreProvider({ children }: { children: ReactNode }) {
const authenticated = useAuthStore((s) => !!s.session);
// chat-db is per-account (chat-<userId>.sqlite) — bindings must be rebuilt
// when the signed-in user changes, not once per mount.
const userId = useAuthStore((s) => s.session?.user.id ?? null);
const transport = useChatTransport();
const api = useMemo(createTrpcChatApi, []);

Expand Down Expand Up @@ -143,6 +173,14 @@ export function MobileChatStoreProvider({ children }: { children: ReactNode }) {
);

useEffect(() => {
if (!userId) {
// Signed out (or not yet in) — detach the previous account's bindings
// so nothing keeps writing through a closed/foreign handle.
setPersistApi(undefined);
setBackfillApi(undefined);
setOutboxBindings(undefined);
return;
}
let active = true;
(async () => {
try {
Expand Down Expand Up @@ -175,7 +213,7 @@ export function MobileChatStoreProvider({ children }: { children: ReactNode }) {
return () => {
active = false;
};
}, [confirmOptimisticMessage, failOptimisticMessage, transport]);
}, [userId, confirmOptimisticMessage, failOptimisticMessage, transport]);

// AppState foreground kick. The chat-store provider already wires
// worker.kick on transport.onState("open"), and the worker runs a 30s
Expand All @@ -200,6 +238,7 @@ export function MobileChatStoreProvider({ children }: { children: ReactNode }) {
backfillCursors={backfillApi}
authenticated={authenticated}
>
<BackfillKickBridge />
{children}
</ChatStoreProvider>
);
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/lib/chat/transport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,13 @@ export function ChatTransportProvider({ children }: { children: ReactNode }) {
} satisfies MlsApi,
onDecryptFailure: (msg, reason) => {
console.warn(
"[chat/transport] decrypt failed",
"[DEBUG-bkfl] decrypt failed",
{
chatId: msg.chatId,
serverMsgId: msg.serverMsgId,
senderId: msg.senderId,
// version byte: 2 = MLS (v=2), anything else = v=1 libsodium.
v: msg.ciphertext.length > 0 ? msg.ciphertext[0] : -1,
},
reason,
);
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"@types/react": "~19.1.10",
"eslint": "^9.39.1",
"eslint-config-expo": "^10.0.0",
"typescript": "5.9.2"
"typescript": "5.9.2",
"@react-native-community/cli": "latest"
}
}
29 changes: 27 additions & 2 deletions apps/mobile/store/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { create } from "zustand";
import { setActiveChatDbUser } from "@repo/chat-db";

import { clearChatGroupCache } from "@/lib/chat/group-resolver";
import { forgetMyAccount } from "@/lib/account/me";

type User = {
id: string;
Expand All @@ -25,12 +29,33 @@ type AuthState = {
setActiveProfile: (profileId: string) => void;
};

// Per-account local state pivots on the signed-in Better Auth user id: the
// chat DB file (chat-<userId>.sqlite), the chatId→groupId resolver cache and
// the account.me singleton all belong to exactly one account. Token refreshes
// for the SAME user must not churn any of them — only an actual user change.
function onUserChange(prev: Session | null, next: Session | null): void {
const prevUser = prev?.user.id ?? null;
const nextUser = next?.user.id ?? null;
if (prevUser === nextUser) return;
setActiveChatDbUser(nextUser);
clearChatGroupCache();
forgetMyAccount();
}

export const useAuthStore = create<AuthState>((set) => ({
session: null,

setSession: (session) => set({ session }),
setSession: (session) =>
set((state) => {
onUserChange(state.session, session);
return { session };
}),

clearSession: () => set({ session: null }),
clearSession: () =>
set((state) => {
onUserChange(state.session, null);
return { session: null };
}),

setActiveProfile: (profileId) =>
set((state) =>
Expand Down
Loading
Loading