mobile v0.1 with deployment for ios (#191)

* stage 1: project init

* stage 2: skeleton with navigation

* step 2.5: streams list

* step 4: stream playback experience

* step 5-6: compose experience

* fix: broken record

* transcode media particles to mp4

* build: reproducible go generate

* build: rename skaffold module for particle processor worker

* infra: increase particle processor worker resources

Was dealing with OOM errors

* tweaks to mobile

* log transcode work

* view on desktop placeholder

* tweak padding

* cap video resolution to save on memory

* infra: bump memory limits as insurance

* ux improvements

* update bundle id for mobile

* config for mobile
This commit was merged in pull request #191.
This commit is contained in:
Arjun Patel
2026-04-29 17:39:11 -07:00
committed by GitHub
parent 3a11a82cd3
commit e3461dd5cd
110 changed files with 14682 additions and 22 deletions
+137
View File
@@ -0,0 +1,137 @@
import { create } from "zustand";
import {
signInWithCustomToken,
signOut as firebaseSignOut,
} from "firebase/auth";
import { apiClient } from "@/api/client";
import type { Human } from "@/api/types";
import { firebaseAuth } from "@/firebase";
import { logError, ApiError } from "@/lib/errors";
import { hydrateSession, useSessionStore } from "./session-store";
async function signInToFirebase(): Promise<void> {
try {
const { token } = await apiClient.getFirebaseToken();
await signInWithCustomToken(firebaseAuth, token);
} catch (err) {
// Firestore subscriptions will fail until the next successful sign-in; the
// rest of the app keeps working against Orion. Sentry catches the failure.
logError(err, { scope: "auth.firebase" });
}
}
type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated";
interface AuthState {
status: AuthStatus;
user: Human | null;
isRequestingCode: boolean;
isSigningIn: boolean;
isSigningOut: boolean;
error: string | null;
restoreSession: () => Promise<void>;
requestCode: (email: string) => Promise<void>;
signIn: (email: string, code: string) => Promise<void>;
signOut: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
status: "idle",
user: null,
isRequestingCode: false,
isSigningIn: false,
isSigningOut: false,
error: null,
restoreSession: async () => {
set({ status: "restoring" });
if (!useSessionStore.getState().hydrated) {
await hydrateSession();
}
const token = useSessionStore.getState().token;
if (!token) {
set({ status: "unauthenticated" });
return;
}
try {
const user = await apiClient.me();
await signInToFirebase();
set({ status: "authenticated", user });
} catch (err) {
// Expected on expired/invalid tokens — fall back to the login screen.
logError(err, { scope: "auth.restore" });
await useSessionStore.getState().clearToken();
set({ status: "unauthenticated", user: null });
}
},
requestCode: async (email: string) => {
set({ isRequestingCode: true, error: null });
try {
await apiClient.requestCode({ email });
} catch (e) {
const message =
e instanceof ApiError ? e.message : "Failed to send code";
set({ error: message });
throw e;
} finally {
set({ isRequestingCode: false });
}
},
signIn: async (email: string, code: string) => {
set({ isSigningIn: true, error: null });
try {
const { human, token } = await apiClient.signIn({ email, code });
await useSessionStore.getState().setToken(token);
await signInToFirebase();
set({ status: "authenticated", user: human });
} catch (e) {
const message = e instanceof ApiError ? e.message : "Failed to sign in";
set({ error: message });
throw e;
} finally {
set({ isSigningIn: false });
}
},
signOut: async () => {
set({ isSigningOut: true });
try {
await apiClient.signOut();
} catch (err) {
// Best-effort — sign out locally regardless.
logError(err, { scope: "auth.signOut" });
} finally {
await firebaseSignOut(firebaseAuth).catch((err) =>
logError(err, { scope: "auth.firebaseSignOut" }),
);
await useSessionStore.getState().clearToken();
set({
status: "unauthenticated",
user: null,
isSigningOut: false,
error: null,
});
}
},
clearError: () => set({ error: null }),
}));
// React to token being cleared externally (e.g. 401 from API client).
useSessionStore.subscribe((state, prevState) => {
if (prevState.token && !state.token) {
const authState = useAuthStore.getState();
if (authState.status === "authenticated") {
useAuthStore.setState({
status: "unauthenticated",
user: null,
error: null,
});
}
}
});
@@ -0,0 +1,33 @@
import { create } from "zustand";
/**
* Single source of truth for "is stream playback paused." Each component that
* wants to pause playback registers a unique id via `useSuspendPlayback`; the
* label is for devtools only. Playback is paused while any id is registered.
*/
interface PlaybackPauseState {
activeIds: Record<string, string>;
composing: boolean;
add: (id: string, label: string) => void;
remove: (id: string) => void;
setComposing: (composing: boolean) => void;
}
export const usePlaybackPauseStore = create<PlaybackPauseState>((set) => ({
activeIds: {},
composing: false,
add: (id, label) =>
set((s) => ({ activeIds: { ...s.activeIds, [id]: label } })),
remove: (id) =>
set((s) => {
if (!(id in s.activeIds)) return s;
const { [id]: _, ...rest } = s.activeIds;
return { activeIds: rest };
}),
setComposing: (composing) => set({ composing }),
}));
export const selectIsPaused = (s: PlaybackPauseState) =>
Object.keys(s.activeIds).length > 0 || s.composing;
export const selectIsComposing = (s: PlaybackPauseState) => s.composing;
+46
View File
@@ -0,0 +1,46 @@
import * as SecureStore from "expo-secure-store";
import { create } from "zustand";
const AUTH_TOKEN_KEY = "auth_token";
interface SessionState {
token: string | null;
/**
* False until SecureStore returns the persisted token (or confirms absence).
* The API client should treat requests as unauthenticated until this flips —
* see `useSessionStore.subscribe` in App.tsx for the bootstrap.
*/
hydrated: boolean;
setToken: (token: string) => Promise<void>;
clearToken: () => Promise<void>;
}
export const useSessionStore = create<SessionState>((set) => ({
token: null,
hydrated: false,
setToken: async (token: string) => {
await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token);
set({ token });
},
clearToken: async () => {
await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY);
set({ token: null });
},
}));
/**
* Bootstrap the session by reading SecureStore once. Call from App.tsx before
* mounting the navigator. Resolves after the store reflects whatever was in
* persistent storage.
*/
export async function hydrateSession(): Promise<void> {
try {
const token = await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
useSessionStore.setState({ token: token ?? null, hydrated: true });
} catch {
// SecureStore failures are non-fatal — proceed unauthenticated.
useSessionStore.setState({ token: null, hydrated: true });
}
}