stage 1: project init
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user