stage 1: project init

This commit is contained in:
talksik
2026-04-29 10:42:20 -07:00
parent 3a11a82cd3
commit 06918f033d
18 changed files with 6405 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules/
.expo/
dist/
ios/
android/
*.log
.DS_Store
.env
.env.local
expo-env.d.ts
+49
View File
@@ -0,0 +1,49 @@
import type { ExpoConfig } from "expo/config";
const APP_ENV = (process.env.EXPO_PUBLIC_APP_ENV ?? "dev") as "dev" | "prod";
const config: ExpoConfig = {
name: "Flowy",
slug: "flowy",
version: "0.1.0",
orientation: "portrait",
icon: "./assets/icon.png",
scheme: "flowy",
userInterfaceStyle: "automatic",
newArchEnabled: true,
splash: {
image: "./assets/splash.png",
resizeMode: "contain",
backgroundColor: "#000000",
},
ios: {
supportsTablet: true,
bundleIdentifier: "com.llink.flowy",
infoPlist: {
NSCameraUsageDescription:
"Flowy uses your camera to record video messages.",
NSMicrophoneUsageDescription:
"Flowy uses your microphone to record voice and video messages.",
ITSAppUsesNonExemptEncryption: false,
},
},
plugins: [
[
"expo-build-properties",
{
ios: {
deploymentTarget: "16.0",
},
},
],
"expo-secure-store",
],
experiments: {
typedRoutes: false,
},
extra: {
appEnv: APP_ENV,
},
};
export default config;
+9
View File
@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+5
View File
@@ -0,0 +1,5 @@
import "./global.css";
import { registerRootComponent } from "expo";
import App from "./src/App";
registerRootComponent(App);
+6
View File
@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });
+1
View File
@@ -0,0 +1 @@
/// <reference types="nativewind/types" />
+38
View File
@@ -0,0 +1,38 @@
{
"name": "flowy-mobile",
"version": "0.1.0",
"private": true,
"main": "index.ts",
"scripts": {
"start": "expo start",
"ios": "expo run:ios",
"android": "expo run:android",
"compile": "tsc --noEmit",
"lint": "expo lint"
},
"packageManager": "yarn@1.22.22",
"dependencies": {
"@react-native-async-storage/async-storage": "2.1.2",
"@tanstack/react-query": "^5.90.21",
"expo": "~54.0.0",
"expo-constants": "~17.0.0",
"expo-secure-store": "~14.0.0",
"expo-status-bar": "~2.0.0",
"nativewind": "^4.1.23",
"react": "19.1.0",
"react-native": "0.81.4",
"react-native-gesture-handler": "~2.21.0",
"react-native-reanimated": "~4.0.0",
"react-native-safe-area-context": "~4.12.0",
"react-native-screens": "~4.4.0",
"sonner-native": "^0.21.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
"expo-build-properties": "~0.13.0",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { StatusBar } from "expo-status-bar";
import { QueryClientProvider } from "@tanstack/react-query";
import { Text, View } from "react-native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { Toaster } from "sonner-native";
import { createQueryClient } from "@/lib/query-client";
const queryClient = createQueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<View className="flex-1 items-center justify-center bg-background">
<Text className="text-foreground text-2xl font-semibold">Flowy</Text>
<Text className="text-muted-foreground mt-2">
Mobile scaffold step 1
</Text>
</View>
<Toaster />
<StatusBar style="auto" />
</SafeAreaProvider>
</QueryClientProvider>
);
}
+270
View File
@@ -0,0 +1,270 @@
import { appConfig } from "@/config/env";
import { useSessionStore } from "@/stores/session-store";
import { ApiError } from "@/lib/errors";
import type { z } from "zod";
import {
BillingStatusSchema,
CheckoutSessionResponseSchema,
DepotObjectSchema,
FirebaseTokenResponseSchema,
GetLivekitTokenResponseSchema,
HumanSchema,
ListInvitationsResponseSchema,
ListNetworksResponseSchema,
NetworkSchema,
NetworkUsageSchema,
PortalSessionResponseSchema,
PrepareUploadResponseSchema,
SignInResponseSchema,
} from "./types";
import type {
AcceptInvitationRequest,
AddMembersRequest,
BillingCadence,
CreateNetworkRequest,
PrepareUploadRequest,
RequestCodeRequest,
RevokeInvitationRequest,
SignInRequest,
} from "./types";
interface ApiClientConfig {
baseUrl: string;
getToken: () => string | null;
onUnauthorized: () => void;
}
class ApiClient {
private config: ApiClientConfig;
constructor(config: ApiClientConfig) {
this.config = config;
}
private async fetch(
method: string,
path: string,
body?: unknown,
): Promise<Response> {
const headers: Record<string, string> = {};
if (body) {
headers["Content-Type"] = "application/json";
}
const token = this.config.getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(`${this.config.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401) {
this.config.onUnauthorized();
throw new ApiError(401, "Unauthorized");
}
if (!response.ok) {
const text = await response.text().catch(() => "Unknown error");
throw new ApiError(response.status, text);
}
return response;
}
private async request<T>(
schema: z.ZodType<T>,
method: string,
path: string,
body?: unknown,
): Promise<T> {
const response = await this.fetch(method, path, body);
const json = await response.json();
return schema.parse(json);
}
private async requestVoid(
method: string,
path: string,
body?: unknown,
): Promise<void> {
await this.fetch(method, path, body);
}
// --- Auth ---
async requestCode(data: RequestCodeRequest): Promise<void> {
await this.requestVoid("POST", "/auth/request-code", data);
}
async signIn(data: SignInRequest) {
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
}
async me() {
return this.request(HumanSchema, "GET", "/auth/me");
}
async signOut(): Promise<void> {
await this.requestVoid("POST", "/auth/sign-out");
}
async getFirebaseToken() {
return this.request(
FirebaseTokenResponseSchema,
"POST",
"/auth/firebase-token",
);
}
// TODO: security: require passing in the particle id once api deprecates this
async getParticleDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch(
"GET",
`/particles/${objectId}/download`,
);
const data = await response.json();
return data.url;
}
// --- Settings ---
async updateSettings(data: {
email_notifications_enabled?: boolean;
}): Promise<void> {
await this.requestVoid("PATCH", "/humans/me/settings", data);
}
// --- Depot ---
async prepareUpload(data: PrepareUploadRequest) {
return this.request(
PrepareUploadResponseSchema,
"POST",
"/depot/upload",
data,
);
}
async confirmUpload(objectId: string) {
return this.request(
DepotObjectSchema,
"POST",
`/depot/objects/${objectId}/confirm`,
);
}
// --- Networks ---
async listNetworks() {
return this.request(ListNetworksResponseSchema, "GET", "/networks");
}
async createNetwork(data: CreateNetworkRequest) {
return this.request(NetworkSchema, "POST", "/networks", data);
}
async getNetwork(id: string) {
return this.request(NetworkSchema, "GET", `/networks/${id}`);
}
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
await this.requestVoid("POST", `/networks/${networkId}/members`, data);
}
async removeMember(networkId: string, humanId: string): Promise<void> {
await this.requestVoid(
"DELETE",
`/networks/${networkId}/members/${humanId}`,
);
}
// --- Invitations ---
async listNetworkInvitations(networkId: string) {
return this.request(
ListInvitationsResponseSchema,
"GET",
`/networks/${networkId}/invitations`,
);
}
async listMyInvitations() {
return this.request(ListInvitationsResponseSchema, "GET", "/invitations");
}
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
await this.requestVoid("POST", "/invitations/accept", data);
}
async revokeInvitation(
networkId: string,
data: RevokeInvitationRequest,
): Promise<void> {
await this.requestVoid(
"DELETE",
`/networks/${networkId}/invitations`,
data,
);
}
// --- LiveKit ---
async getLivekitToken(networkId: string, streamId: string) {
return this.request(
GetLivekitTokenResponseSchema,
"POST",
"/livekit/token",
{ network_id: networkId, stream_id: streamId },
);
}
// --- Billing (network admin only) ---
async getNetworkBilling(networkId: string) {
return this.request(
BillingStatusSchema,
"GET",
`/networks/${networkId}/billing`,
);
}
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
return this.request(
CheckoutSessionResponseSchema,
"POST",
`/networks/${networkId}/billing/checkout-session`,
{ cadence },
);
}
async createPortalSession(networkId: string) {
return this.request(
PortalSessionResponseSchema,
"POST",
`/networks/${networkId}/billing/portal-session`,
);
}
async getNetworkUsage(networkId: string) {
return this.request(
NetworkUsageSchema,
"GET",
`/networks/${networkId}/usage`,
);
}
}
export const apiClient = new ApiClient({
baseUrl: appConfig.orionUrl,
getToken: () => useSessionStore.getState().token,
// SecureStore writes are async; we fire-and-forget so the throwing
// request doesn't have to wait for persistence to finish.
onUnauthorized: () => {
void useSessionStore.getState().clearToken();
},
});
+319
View File
@@ -0,0 +1,319 @@
import { z } from "zod";
export const HumanSchema = z.object({
id: z.string(),
created_at: z.coerce.date(),
email: z.string().email(),
email_prefix: z.string(),
email_notifications_enabled: z.boolean(),
});
export type Human = z.infer<typeof HumanSchema>;
export const NetworkSchema = z.object({
id: z.string(),
name: z.string(),
admin_human: HumanSchema,
humans: z.array(HumanSchema),
created_at: z.coerce.date(),
});
export type Network = z.infer<typeof NetworkSchema>;
export const ListNetworksResponseSchema = z.array(NetworkSchema);
export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
// --- Network request/response types ---
const CreateNetworkRequestSchema = z.object({
name: z.string(),
});
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
const AddMembersRequestSchema = z.object({
email_addresses: z.array(z.string().email()),
});
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
// --- Invitation types ---
export const InvitationSchema = z.object({
network_id: z.string(),
network_name: z.string(),
email: z.string(),
created_at: z.coerce.date(),
});
export type Invitation = z.infer<typeof InvitationSchema>;
export const ListInvitationsResponseSchema = z.array(InvitationSchema);
export type AcceptInvitationRequest = { network_id: string };
export type RevokeInvitationRequest = { email: string };
// --- Depot types ---
const PrepareUploadRequestSchema = z.object({
network_id: z.string(),
name: z.string(),
content_type: z.string(),
content_length: z.number(),
});
export type PrepareUploadRequest = z.infer<typeof PrepareUploadRequestSchema>;
export const PrepareUploadResponseSchema = z.object({
object_id: z.string(),
upload_url: z.string(),
upload_headers: z.record(z.string(), z.string()),
});
export type PrepareUploadResponse = z.infer<typeof PrepareUploadResponseSchema>;
export const DepotObjectSchema = z.object({
id: z.string(),
name: z.string(),
content_type: z.string(),
content_length: z.number(),
contains_content: z.boolean(),
created_at: z.coerce.date(),
});
export type DepotObject = z.infer<typeof DepotObjectSchema>;
// --- Particle property schemas ---
export const StreamPropertiesSchema = z.object({
name: z.string(),
description: z.string().optional(),
});
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
export const FolderPropertiesSchema = z.object({
name: z.string(),
color: z.string().optional(),
});
export type FolderProperties = z.infer<typeof FolderPropertiesSchema>;
const TranscriptWordSchema = z.object({
word: z.string(),
start: z.number(),
end: z.number(),
});
const TranscriptSentenceSchema = z.object({
text: z.string(),
start: z.number(),
end: z.number(),
});
const TranscriptParagraphSchema = z.object({
sentences: z.array(TranscriptSentenceSchema),
start: z.number(),
end: z.number(),
});
export const TranscriptSchema = z.object({
transcript: z.string(),
words: z.array(TranscriptWordSchema),
paragraphs: z.array(TranscriptParagraphSchema),
});
export type Transcript = z.infer<typeof TranscriptSchema>;
export const MediaPropertiesSchema = z.object({
object_id: z.string(),
mime_type: z.string(),
duration_ms: z.number(),
size_bytes: z.number(),
transcript: TranscriptSchema.optional(),
source: z.enum(["camera", "screen"]).optional(),
});
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
export const FilePropertiesSchema = z.object({
object_id: z.string(),
filename: z.string(),
mime_type: z.string(),
size_bytes: z.number(),
});
export type FileProperties = z.infer<typeof FilePropertiesSchema>;
export const TextPropertiesSchema = z.object({
content: z.string(),
edited_at: z.coerce.date().optional(),
});
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestPropertiesSchema = z.object({
title: z.string(),
description: z.string(),
status: z.string().optional(),
// humanId
assigned_to: z.string().optional(),
});
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
export const PaperPropertiesSchema = z.object({
title: z.string(),
content: z.string(),
});
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
// --- Reactions ---
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional();
export type Reactions = z.infer<typeof ReactionsSchema>;
// --- Tombstone (soft-delete) ---
// Fields added to non-container particles when their creator deletes them.
// We keep the doc around so concurrent viewers can see a "This particle was
// deleted" message in place, rather than being jumped to the next particle.
const TombstoneFields = {
deleted_at: z.coerce.date().optional(),
deleted_by_human_id: z.string().optional(),
};
export const REACTION_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F525}", "\u{1F440}", "\u{2705}", "\u{2753}", "\u{1F602}"] as const;
export interface ParticlePropertiesMap {
stream: StreamProperties;
folder: FolderProperties;
media: MediaProperties;
file: FileProperties;
text: TextProperties;
quest: QuestProperties;
paper: PaperProperties;
}
// --- Unified Particle types ---
const ParticleBaseSchema = z.object({
id: z.string(),
created_at: z.coerce.date(),
created_by_human_id: z.string(),
updated_at: z.coerce.date().optional(),
});
export const ParticleSchema = z.discriminatedUnion("type", [
ParticleBaseSchema.extend({
type: z.literal("stream"),
properties: StreamPropertiesSchema,
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:xywx"] - visible to everyone in the network
visible_to: z.array(z.string()),
// Marks human_id to their `playback_position_at`: where they left off in a conversation
playback_markers: z.record(z.string(), z.coerce.date()).optional(),
// Timestamp of the most recent child particle
// used for sorting streams by recent activity without needing to query subcollections
last_child_created_at: z.coerce.date().optional(),
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
huddle_active_participants: z.array(z.string()).optional(),
status: z.enum(["open", "closed"]).optional(),
}),
ParticleBaseSchema.extend({
type: z.literal("folder"), properties: FolderPropertiesSchema,
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()),
}),
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }),
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }),
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }),
]);
export type Particle = z.infer<typeof ParticleSchema>;
export type ParticleType = Particle["type"];
/** Container types can have children subcollections */
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
export function isContainerType(type: ParticleType): boolean {
return CONTAINER_TYPES.has(type);
}
/** True when a non-container particle has been soft-deleted (tombstoned). */
export function isParticleDeleted(particle: Particle): boolean {
return "deleted_at" in particle && particle.deleted_at != null;
}
// --- LiveKit types ---
export const GetLivekitTokenResponseSchema = z.object({
token: z.string(),
server_url: z.string(),
});
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
// --- Auth types ---
const RequestCodeRequestSchema = z.object({
email: z.string().email(),
});
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
const SignInRequestSchema = z.object({
email: z.string().email(),
code: z.string(),
});
export type SignInRequest = z.infer<typeof SignInRequestSchema>;
export const SignInResponseSchema = z.object({
human: HumanSchema,
token: z.string(),
});
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
export const FirebaseTokenResponseSchema = z.object({
token: z.string(),
});
export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
// --- Billing types ---
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
export const NetworkPlanSchema = z.enum(["free", "pro"]);
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
export const BillingPlanStatusSchema = z.enum([
"active",
"trialing",
"past_due",
"canceled",
"incomplete",
"incomplete_expired",
"unpaid",
]);
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
export const BillingStatusSchema = z.object({
plan: NetworkPlanSchema,
plan_status: BillingPlanStatusSchema,
cadence: BillingCadenceSchema.nullable(),
seats: z.number().int(),
current_period_end: z.coerce.date().nullable(),
cancel_at_period_end: z.boolean(),
price_monthly_cents: z.number().int(),
price_annual_cents: z.number().int(),
});
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
export const CheckoutSessionResponseSchema = z.object({
url: z.string().url(),
});
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
export const PortalSessionResponseSchema = z.object({
url: z.string().url(),
});
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
export const NetworkUsageSchema = z.object({
plan: NetworkPlanSchema,
used: z.number().int().nonnegative(),
limit: z.number().int().nonnegative().nullable(),
reset_at: z.coerce.date(),
});
export type NetworkUsage = z.infer<typeof NetworkUsageSchema>;
+63
View File
@@ -0,0 +1,63 @@
import Constants from "expo-constants";
// Expo-side equivalent of desktop's __APP_ENV__ build-time replacement
// (see js/desktop/src/config/env.ts). On mobile we read from app.config.ts
// `extra.appEnv`, which itself reads `process.env.EXPO_PUBLIC_APP_ENV` at
// build time. Defaults to "dev".
//
// Firebase web config is public by design (security is enforced via
// Firestore rules + App Check), so both configs live in source. To refresh,
// run: cd infra/gcp/{dev,prod} && terraform output -json firebase_config
type FirebaseConfig = {
apiKey: string;
appId: string;
authDomain: string;
messagingSenderId: string;
projectId: string;
storageBucket: string;
};
type AppConfig = {
orionUrl: string;
pusherUrl: string;
firebase: FirebaseConfig;
/** Empty string disables Sentry. Same DSN across envs; events are split by `environment` tag. */
sentryDsn: string;
};
const configs: Record<"dev" | "prod", AppConfig> = {
dev: {
orionUrl: "https://orion.dev.flowy.live",
pusherUrl: "wss://pusher.dev.flowy.live/ws",
firebase: {
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
appId: "1:1006580076785:web:e2a0736d60a78e02b15950",
authDomain: "flowy-dev-440017.firebaseapp.com",
messagingSenderId: "1006580076785",
projectId: "flowy-dev-440017",
storageBucket: "flowy-dev-440017.firebasestorage.app",
},
sentryDsn:
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
},
prod: {
orionUrl: "https://orion.flowy.live",
pusherUrl: "wss://pusher.flowy.live/ws",
firebase: {
apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg",
appId: "1:68063426854:web:5054f16f50898f5706e9e7",
authDomain: "flowy-prod-440017.firebaseapp.com",
messagingSenderId: "68063426854",
projectId: "flowy-prod-440017",
storageBucket: "flowy-prod-440017.firebasestorage.app",
},
sentryDsn:
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
},
};
const rawEnv = (Constants.expoConfig?.extra as { appEnv?: string } | undefined)
?.appEnv;
export const appEnv: "dev" | "prod" = rawEnv === "prod" ? "prod" : "dev";
export const appConfig: AppConfig = configs[appEnv];
+90
View File
@@ -0,0 +1,90 @@
import { z } from "zod";
import { appEnv } from "@/config/env";
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
/**
* Thrown when a free-plan network attempts to create a non-container particle
* after hitting its daily message limit. Compose UI also disables triggers
* proactively via `useNetworkUsage` — this throw is a last-line defense.
*/
export class QuotaExceededError extends Error {
constructor(public readonly networkId: string) {
super("Daily message limit reached");
this.name = "QuotaExceededError";
}
}
function normalizeMessage(message: string): string {
return message.replace(/^Error:\s*/, "").trim();
}
export function toUserMessage(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 401) return "Please sign in again.";
if (err.status === 403) return "You don't have permission to do that.";
if (err.status === 404) return "Not found.";
if (err.status === 408 || err.status === 429) {
return "Please try again in a moment.";
}
if (err.status >= 500) {
return "Something went wrong on our end. Please try again.";
}
return normalizeMessage(err.message) || "Request failed.";
}
if (err instanceof z.ZodError) {
return "Received unexpected data from the server.";
}
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
return "Network error. Check your connection.";
}
if (err instanceof Error) {
return normalizeMessage(err.message) || "Something went wrong.";
}
return "Something went wrong.";
}
type ErrorContext = Record<string, unknown>;
type ErrorSink = (err: unknown, context?: ErrorContext) => void;
// Sentry (or any observability backend) installs itself via `installErrorSinks`
// from App.tsx. Until then, logError is a dev-only console call and reportError
// always prints — no call site needs to know.
let captureSink: ErrorSink | null = null;
let breadcrumbSink: ErrorSink | null = null;
export function installErrorSinks(sinks: {
capture: ErrorSink;
breadcrumb: ErrorSink;
}): void {
captureSink = sinks.capture;
breadcrumbSink = sinks.breadcrumb;
}
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
export function logError(err: unknown, context?: ErrorContext): void {
if (appEnv === "dev") {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
}
breadcrumbSink?.(err, context);
}
/** Unexpected failures the user may not see. Always captured. */
export function reportError(err: unknown, context?: ErrorContext): void {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
captureSink?.(err, context);
}
+56
View File
@@ -0,0 +1,56 @@
import {
MutationCache,
QueryCache,
QueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner-native";
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
declare module "@tanstack/react-query" {
interface Register {
queryMeta: { toastOnError?: boolean };
mutationMeta: { suppressToast?: boolean };
}
}
function shouldRetryQuery(failureCount: number, err: unknown): boolean {
if (err instanceof ApiError) {
// Retry only on transient status codes; 4xx generally won't succeed on retry.
if (err.status === 408 || err.status === 429) return failureCount < 2;
if (err.status >= 400 && err.status < 500) return false;
}
return failureCount < 2;
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: shouldRetryQuery,
refetchOnWindowFocus: false,
},
mutations: {
// Mutations have side effects — never auto-retry.
retry: 0,
},
},
queryCache: new QueryCache({
onError: (err, query) => {
logError(err, { scope: "query", queryKey: query.queryKey });
if (query.meta?.toastOnError) {
toast.error(toUserMessage(err));
}
},
}),
mutationCache: new MutationCache({
onError: (err, _variables, _context, mutation) => {
reportError(err, {
scope: "mutation",
mutationKey: mutation.options.mutationKey,
});
if (mutation.meta?.suppressToast) return;
toast.error(toUserMessage(err));
},
}),
});
}
+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 });
}
}
+53
View File
@@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
// Tokens mirror js/desktop/src/styles/globals.css. Desktop authors values in
// OKLCH; React Native's color parser is not guaranteed to handle oklch(), so
// we ship hex equivalents here and keep the OKLCH source in comments.
//
// Names match desktop 1:1 — components written against `bg-background`,
// `text-foreground`, etc. behave identically.
module.exports = {
content: ["./index.ts", "./src/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
darkMode: "class",
theme: {
extend: {
colors: {
// ---- Light tokens (desktop :root) ----
background: { DEFAULT: "#ffffff", dark: "#252525" }, // oklch(1 0 0) / oklch(0.145 0 0)
foreground: { DEFAULT: "#252525", dark: "#fafafa" }, // oklch(0.145 0 0) / oklch(0.985 0 0)
card: { DEFAULT: "#ffffff", dark: "#363636" }, // oklch(1 0 0) / oklch(0.205 0 0)
"card-foreground": { DEFAULT: "#252525", dark: "#fafafa" },
popover: { DEFAULT: "#ffffff", dark: "#363636" },
"popover-foreground": { DEFAULT: "#252525", dark: "#fafafa" },
primary: { DEFAULT: "#363636", dark: "#ebebeb" }, // oklch(0.205 0 0) / oklch(0.922 0 0)
"primary-foreground": { DEFAULT: "#fafafa", dark: "#363636" },
secondary: { DEFAULT: "#f4f4f4", dark: "#454545" }, // oklch(0.97 0 0) / oklch(0.269 0 0)
"secondary-foreground": { DEFAULT: "#363636", dark: "#fafafa" },
muted: { DEFAULT: "#f4f4f4", dark: "#454545" },
"muted-foreground": { DEFAULT: "#878787", dark: "#a6a6a6" }, // oklch(0.556 0 0) / oklch(0.708 0 0)
accent: { DEFAULT: "#f4f4f4", dark: "#454545" },
"accent-foreground": { DEFAULT: "#363636", dark: "#fafafa" },
destructive: { DEFAULT: "#dc2626", dark: "#ef4444" }, // oklch(0.577 0.245 27.325) / oklch(0.704 0.191 22.216)
border: { DEFAULT: "#dcdcdc", dark: "rgba(255,255,255,0.1)" },
input: { DEFAULT: "#dcdcdc", dark: "rgba(255,255,255,0.15)" },
ring: { DEFAULT: "#a6a6a6", dark: "#878787" }, // oklch(0.708 0 0) / oklch(0.556 0 0)
// Sidebar (drawer) tokens — desktop also defines these
sidebar: { DEFAULT: "#fafafa", dark: "#363636" },
"sidebar-foreground": { DEFAULT: "#252525", dark: "#fafafa" },
"sidebar-primary": { DEFAULT: "#363636", dark: "#6366f1" }, // chart-1 dark = oklch(0.488 0.243 264.376)
"sidebar-primary-foreground": { DEFAULT: "#fafafa", dark: "#fafafa" },
"sidebar-accent": { DEFAULT: "#f4f4f4", dark: "#454545" },
"sidebar-accent-foreground": { DEFAULT: "#363636", dark: "#fafafa" },
"sidebar-border": { DEFAULT: "#dcdcdc", dark: "rgba(255,255,255,0.1)" },
"sidebar-ring": { DEFAULT: "#a6a6a6", dark: "#878787" },
},
borderRadius: {
lg: "10px", // --radius: 0.625rem
md: "8px",
sm: "6px",
xl: "14px",
},
},
},
plugins: [],
};
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["nativewind/types"]
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": ["node_modules"]
}
+5343
View File
File diff suppressed because it is too large Load Diff