stage 1: project init
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
ios/
|
||||
android/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
expo-env.d.ts
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,5 @@
|
||||
import "./global.css";
|
||||
import { registerRootComponent } from "expo";
|
||||
import App from "./src/App";
|
||||
|
||||
registerRootComponent(App);
|
||||
@@ -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" });
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user