wip(mobile): lint and format
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.expo
|
||||||
|
ios
|
||||||
|
android
|
||||||
|
yarn.lock
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"jsxSingleQuote": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require("eslint/config");
|
||||||
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
const eslintConfigPrettier = require("eslint-config-prettier");
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
// Turn off ESLint rules that conflict with Prettier. Keep this after expoConfig.
|
||||||
|
eslintConfigPrettier,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ["dist/*", ".expo/*"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"publish:ios": "eas build --platform ios --auto-submit && echo 'Go to App Store Connect and submit the testflight build for app review. Visit for more information: https://docs.expo.dev/submit/introduction/'",
|
"publish:ios": "eas build --platform ios --auto-submit && echo 'Go to App Store Connect and submit the testflight build for app review. Visit for more information: https://docs.expo.dev/submit/introduction/'",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"compile": "tsc --noEmit",
|
"compile": "tsc --noEmit",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\""
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -53,7 +54,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-expo": "^56.0.4",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"expo-build-properties": "~1.0.10",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "~5.9.0"
|
"typescript": "~5.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -1,22 +1,22 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { NavigationContainer } from "@react-navigation/native";
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
import {
|
import {
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
SafeAreaProvider,
|
SafeAreaProvider,
|
||||||
} from "react-native-safe-area-context";
|
} from 'react-native-safe-area-context';
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from 'sonner-native';
|
||||||
import { createQueryClient } from "@/lib/query-client";
|
import { createQueryClient } from '@/lib/query-client';
|
||||||
import {
|
import {
|
||||||
flushPendingNavigation,
|
flushPendingNavigation,
|
||||||
navigationRef,
|
navigationRef,
|
||||||
} from "@/lib/notification-routing";
|
} from '@/lib/notification-routing';
|
||||||
import { configureNotifications } from "@/lib/push-notifications";
|
import { configureNotifications } from '@/lib/push-notifications';
|
||||||
import { PusherProvider } from "@/lib/pusher-provider";
|
import { PusherProvider } from '@/lib/pusher-provider';
|
||||||
import { RootNavigator } from "@/navigation/RootNavigator";
|
import { RootNavigator } from '@/navigation/RootNavigator';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
|
|||||||
+38
-41
@@ -1,6 +1,6 @@
|
|||||||
import { appConfig } from "@/config/env";
|
import { appConfig } from '@/config/env';
|
||||||
import { ApiError } from "@/lib/errors";
|
import { ApiError } from '@/lib/errors';
|
||||||
import type { z } from "zod";
|
import type { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
BillingStatusSchema,
|
BillingStatusSchema,
|
||||||
CheckoutSessionResponseSchema,
|
CheckoutSessionResponseSchema,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
PortalSessionResponseSchema,
|
PortalSessionResponseSchema,
|
||||||
PrepareUploadResponseSchema,
|
PrepareUploadResponseSchema,
|
||||||
SignInResponseSchema,
|
SignInResponseSchema,
|
||||||
} from "./types";
|
} from './types';
|
||||||
import type {
|
import type {
|
||||||
AcceptInvitationRequest,
|
AcceptInvitationRequest,
|
||||||
AddMembersRequest,
|
AddMembersRequest,
|
||||||
@@ -25,7 +25,7 @@ import type {
|
|||||||
RequestCodeRequest,
|
RequestCodeRequest,
|
||||||
RevokeInvitationRequest,
|
RevokeInvitationRequest,
|
||||||
SignInRequest,
|
SignInRequest,
|
||||||
} from "./types";
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP transport for Orion. Holds the bearer token as private state — the auth
|
* HTTP transport for Orion. Holds the bearer token as private state — the auth
|
||||||
@@ -51,11 +51,11 @@ class ApiClient {
|
|||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
headers["Content-Type"] = "application/json";
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
headers["Authorization"] = `Bearer ${this.token}`;
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
@@ -65,11 +65,11 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
throw new ApiError(401, "Unauthorized");
|
throw new ApiError(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => "Unknown error");
|
const text = await response.text().catch(() => 'Unknown error');
|
||||||
throw new ApiError(response.status, text);
|
throw new ApiError(response.status, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,35 +98,32 @@ class ApiClient {
|
|||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
|
|
||||||
async requestCode(data: RequestCodeRequest): Promise<void> {
|
async requestCode(data: RequestCodeRequest): Promise<void> {
|
||||||
await this.requestVoid("POST", "/auth/request-code", data);
|
await this.requestVoid('POST', '/auth/request-code', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async signIn(data: SignInRequest) {
|
async signIn(data: SignInRequest) {
|
||||||
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
|
return this.request(SignInResponseSchema, 'POST', '/auth/sign-in', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async me() {
|
async me() {
|
||||||
return this.request(HumanSchema, "GET", "/auth/me");
|
return this.request(HumanSchema, 'GET', '/auth/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
async signOut(): Promise<void> {
|
async signOut(): Promise<void> {
|
||||||
await this.requestVoid("POST", "/auth/sign-out");
|
await this.requestVoid('POST', '/auth/sign-out');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFirebaseToken() {
|
async getFirebaseToken() {
|
||||||
return this.request(
|
return this.request(
|
||||||
FirebaseTokenResponseSchema,
|
FirebaseTokenResponseSchema,
|
||||||
"POST",
|
'POST',
|
||||||
"/auth/firebase-token",
|
'/auth/firebase-token',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: security: require passing in the particle id once api deprecates this
|
// TODO: security: require passing in the particle id once api deprecates this
|
||||||
async getParticleDownloadUrl(objectId: string): Promise<string> {
|
async getParticleDownloadUrl(objectId: string): Promise<string> {
|
||||||
const response = await this.fetch(
|
const response = await this.fetch('GET', `/particles/${objectId}/download`);
|
||||||
"GET",
|
|
||||||
`/particles/${objectId}/download`,
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.url;
|
return data.url;
|
||||||
}
|
}
|
||||||
@@ -136,21 +133,21 @@ class ApiClient {
|
|||||||
async updateSettings(data: {
|
async updateSettings(data: {
|
||||||
email_notifications_enabled?: boolean;
|
email_notifications_enabled?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await this.requestVoid("PATCH", "/humans/me/settings", data);
|
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Push notification tokens ---
|
// --- Push notification tokens ---
|
||||||
|
|
||||||
async registerPushToken(data: {
|
async registerPushToken(data: {
|
||||||
token: string;
|
token: string;
|
||||||
platform: "ios" | "android";
|
platform: 'ios' | 'android';
|
||||||
app_version: string;
|
app_version: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await this.requestVoid("POST", "/humans/me/push-tokens", data);
|
await this.requestVoid('POST', '/humans/me/push-tokens', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unregisterPushToken(token: string): Promise<void> {
|
async unregisterPushToken(token: string): Promise<void> {
|
||||||
await this.requestVoid("DELETE", "/humans/me/push-tokens", { token });
|
await this.requestVoid('DELETE', '/humans/me/push-tokens', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Depot ---
|
// --- Depot ---
|
||||||
@@ -158,8 +155,8 @@ class ApiClient {
|
|||||||
async prepareUpload(data: PrepareUploadRequest) {
|
async prepareUpload(data: PrepareUploadRequest) {
|
||||||
return this.request(
|
return this.request(
|
||||||
PrepareUploadResponseSchema,
|
PrepareUploadResponseSchema,
|
||||||
"POST",
|
'POST',
|
||||||
"/depot/upload",
|
'/depot/upload',
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,7 +164,7 @@ class ApiClient {
|
|||||||
async confirmUpload(objectId: string) {
|
async confirmUpload(objectId: string) {
|
||||||
return this.request(
|
return this.request(
|
||||||
DepotObjectSchema,
|
DepotObjectSchema,
|
||||||
"POST",
|
'POST',
|
||||||
`/depot/objects/${objectId}/confirm`,
|
`/depot/objects/${objectId}/confirm`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -175,24 +172,24 @@ class ApiClient {
|
|||||||
// --- Networks ---
|
// --- Networks ---
|
||||||
|
|
||||||
async listNetworks() {
|
async listNetworks() {
|
||||||
return this.request(ListNetworksResponseSchema, "GET", "/networks");
|
return this.request(ListNetworksResponseSchema, 'GET', '/networks');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createNetwork(data: CreateNetworkRequest) {
|
async createNetwork(data: CreateNetworkRequest) {
|
||||||
return this.request(NetworkSchema, "POST", "/networks", data);
|
return this.request(NetworkSchema, 'POST', '/networks', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNetwork(id: string) {
|
async getNetwork(id: string) {
|
||||||
return this.request(NetworkSchema, "GET", `/networks/${id}`);
|
return this.request(NetworkSchema, 'GET', `/networks/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
|
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
|
||||||
await this.requestVoid("POST", `/networks/${networkId}/members`, data);
|
await this.requestVoid('POST', `/networks/${networkId}/members`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeMember(networkId: string, humanId: string): Promise<void> {
|
async removeMember(networkId: string, humanId: string): Promise<void> {
|
||||||
await this.requestVoid(
|
await this.requestVoid(
|
||||||
"DELETE",
|
'DELETE',
|
||||||
`/networks/${networkId}/members/${humanId}`,
|
`/networks/${networkId}/members/${humanId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,17 +199,17 @@ class ApiClient {
|
|||||||
async listNetworkInvitations(networkId: string) {
|
async listNetworkInvitations(networkId: string) {
|
||||||
return this.request(
|
return this.request(
|
||||||
ListInvitationsResponseSchema,
|
ListInvitationsResponseSchema,
|
||||||
"GET",
|
'GET',
|
||||||
`/networks/${networkId}/invitations`,
|
`/networks/${networkId}/invitations`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMyInvitations() {
|
async listMyInvitations() {
|
||||||
return this.request(ListInvitationsResponseSchema, "GET", "/invitations");
|
return this.request(ListInvitationsResponseSchema, 'GET', '/invitations');
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
|
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
|
||||||
await this.requestVoid("POST", "/invitations/accept", data);
|
await this.requestVoid('POST', '/invitations/accept', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeInvitation(
|
async revokeInvitation(
|
||||||
@@ -220,7 +217,7 @@ class ApiClient {
|
|||||||
data: RevokeInvitationRequest,
|
data: RevokeInvitationRequest,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.requestVoid(
|
await this.requestVoid(
|
||||||
"DELETE",
|
'DELETE',
|
||||||
`/networks/${networkId}/invitations`,
|
`/networks/${networkId}/invitations`,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
@@ -231,8 +228,8 @@ class ApiClient {
|
|||||||
async getLivekitToken(networkId: string, streamId: string) {
|
async getLivekitToken(networkId: string, streamId: string) {
|
||||||
return this.request(
|
return this.request(
|
||||||
GetLivekitTokenResponseSchema,
|
GetLivekitTokenResponseSchema,
|
||||||
"POST",
|
'POST',
|
||||||
"/livekit/token",
|
'/livekit/token',
|
||||||
{ network_id: networkId, stream_id: streamId },
|
{ network_id: networkId, stream_id: streamId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -242,7 +239,7 @@ class ApiClient {
|
|||||||
async getNetworkBilling(networkId: string) {
|
async getNetworkBilling(networkId: string) {
|
||||||
return this.request(
|
return this.request(
|
||||||
BillingStatusSchema,
|
BillingStatusSchema,
|
||||||
"GET",
|
'GET',
|
||||||
`/networks/${networkId}/billing`,
|
`/networks/${networkId}/billing`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,7 +247,7 @@ class ApiClient {
|
|||||||
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
|
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
|
||||||
return this.request(
|
return this.request(
|
||||||
CheckoutSessionResponseSchema,
|
CheckoutSessionResponseSchema,
|
||||||
"POST",
|
'POST',
|
||||||
`/networks/${networkId}/billing/checkout-session`,
|
`/networks/${networkId}/billing/checkout-session`,
|
||||||
{ cadence },
|
{ cadence },
|
||||||
);
|
);
|
||||||
@@ -259,7 +256,7 @@ class ApiClient {
|
|||||||
async createPortalSession(networkId: string) {
|
async createPortalSession(networkId: string) {
|
||||||
return this.request(
|
return this.request(
|
||||||
PortalSessionResponseSchema,
|
PortalSessionResponseSchema,
|
||||||
"POST",
|
'POST',
|
||||||
`/networks/${networkId}/billing/portal-session`,
|
`/networks/${networkId}/billing/portal-session`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -267,7 +264,7 @@ class ApiClient {
|
|||||||
async getNetworkUsage(networkId: string) {
|
async getNetworkUsage(networkId: string) {
|
||||||
return this.request(
|
return this.request(
|
||||||
NetworkUsageSchema,
|
NetworkUsageSchema,
|
||||||
"GET",
|
'GET',
|
||||||
`/networks/${networkId}/usage`,
|
`/networks/${networkId}/usage`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-32
@@ -1,4 +1,4 @@
|
|||||||
import { z } from "zod";
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const HumanSchema = z.object({
|
export const HumanSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -25,12 +25,12 @@ export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
|
|||||||
|
|
||||||
// --- Network request/response types ---
|
// --- Network request/response types ---
|
||||||
|
|
||||||
const CreateNetworkRequestSchema = z.object({
|
export const CreateNetworkRequestSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
|
||||||
|
|
||||||
const AddMembersRequestSchema = z.object({
|
export const AddMembersRequestSchema = z.object({
|
||||||
email_addresses: z.array(z.string().email()),
|
email_addresses: z.array(z.string().email()),
|
||||||
});
|
});
|
||||||
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
|
||||||
@@ -52,7 +52,7 @@ export type RevokeInvitationRequest = { email: string };
|
|||||||
|
|
||||||
// --- Depot types ---
|
// --- Depot types ---
|
||||||
|
|
||||||
const PrepareUploadRequestSchema = z.object({
|
export const PrepareUploadRequestSchema = z.object({
|
||||||
network_id: z.string(),
|
network_id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
content_type: z.string(),
|
content_type: z.string(),
|
||||||
@@ -122,7 +122,7 @@ export const MediaPropertiesSchema = z.object({
|
|||||||
duration_ms: z.number(),
|
duration_ms: z.number(),
|
||||||
size_bytes: z.number(),
|
size_bytes: z.number(),
|
||||||
transcript: TranscriptSchema.optional(),
|
transcript: TranscriptSchema.optional(),
|
||||||
source: z.enum(["camera", "screen"]).optional(),
|
source: z.enum(['camera', 'screen']).optional(),
|
||||||
// Set by the particle processor worker once an iOS-playable MP4/m4a variant
|
// Set by the particle processor worker once an iOS-playable MP4/m4a variant
|
||||||
// has been produced from a non-iOS-playable original (e.g. WebM from desktop).
|
// has been produced from a non-iOS-playable original (e.g. WebM from desktop).
|
||||||
// When present, clients should prefer these over object_id/mime_type for playback.
|
// When present, clients should prefer these over object_id/mime_type for playback.
|
||||||
@@ -162,7 +162,9 @@ export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
|
|||||||
|
|
||||||
// --- Reactions ---
|
// --- Reactions ---
|
||||||
|
|
||||||
export const ReactionsSchema = z.record(z.string(), z.array(z.string())).optional();
|
export const ReactionsSchema = z
|
||||||
|
.record(z.string(), z.array(z.string()))
|
||||||
|
.optional();
|
||||||
export type Reactions = z.infer<typeof ReactionsSchema>;
|
export type Reactions = z.infer<typeof ReactionsSchema>;
|
||||||
|
|
||||||
// --- Tombstone (soft-delete) ---
|
// --- Tombstone (soft-delete) ---
|
||||||
@@ -175,7 +177,15 @@ const TombstoneFields = {
|
|||||||
deleted_by_human_id: z.string().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 const REACTION_EMOJIS = [
|
||||||
|
'\u{1F44D}',
|
||||||
|
'\u{2764}\u{FE0F}',
|
||||||
|
'\u{1F525}',
|
||||||
|
'\u{1F440}',
|
||||||
|
'\u{2705}',
|
||||||
|
'\u{2753}',
|
||||||
|
'\u{1F602}',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export interface ParticlePropertiesMap {
|
export interface ParticlePropertiesMap {
|
||||||
stream: StreamProperties;
|
stream: StreamProperties;
|
||||||
@@ -196,9 +206,9 @@ const ParticleBaseSchema = z.object({
|
|||||||
updated_at: z.coerce.date().optional(),
|
updated_at: z.coerce.date().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ParticleSchema = z.discriminatedUnion("type", [
|
export const ParticleSchema = z.discriminatedUnion('type', [
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal("stream"),
|
type: z.literal('stream'),
|
||||||
properties: StreamPropertiesSchema,
|
properties: StreamPropertiesSchema,
|
||||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||||
// e.g. ["network:xywx"] - visible to everyone in the network
|
// e.g. ["network:xywx"] - visible to everyone in the network
|
||||||
@@ -210,27 +220,53 @@ export const ParticleSchema = z.discriminatedUnion("type", [
|
|||||||
last_child_created_at: z.coerce.date().optional(),
|
last_child_created_at: z.coerce.date().optional(),
|
||||||
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
// Array of humanIds currently in the huddle (updated via LiveKit webhooks)
|
||||||
huddle_active_participants: z.array(z.string()).optional(),
|
huddle_active_participants: z.array(z.string()).optional(),
|
||||||
status: z.enum(["open", "closed"]).optional(),
|
status: z.enum(['open', 'closed']).optional(),
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal("folder"), properties: FolderPropertiesSchema,
|
type: z.literal('folder'),
|
||||||
|
properties: FolderPropertiesSchema,
|
||||||
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
|
||||||
// e.g. ["network:123"] - visible to everyone in the network
|
// e.g. ["network:123"] - visible to everyone in the network
|
||||||
visible_to: z.array(z.string()),
|
visible_to: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
ParticleBaseSchema.extend({
|
||||||
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema, ...TombstoneFields }),
|
type: z.literal('media'),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema, reactions: ReactionsSchema, ...TombstoneFields }),
|
properties: MediaPropertiesSchema,
|
||||||
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema, ...TombstoneFields }),
|
reactions: ReactionsSchema,
|
||||||
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema, ...TombstoneFields }),
|
...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 Particle = z.infer<typeof ParticleSchema>;
|
||||||
|
|
||||||
export type ParticleType = Particle["type"];
|
export type ParticleType = Particle['type'];
|
||||||
|
|
||||||
/** Container types can have children subcollections */
|
/** Container types can have children subcollections */
|
||||||
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
|
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([
|
||||||
|
'stream',
|
||||||
|
'folder',
|
||||||
|
]);
|
||||||
|
|
||||||
export function isContainerType(type: ParticleType): boolean {
|
export function isContainerType(type: ParticleType): boolean {
|
||||||
return CONTAINER_TYPES.has(type);
|
return CONTAINER_TYPES.has(type);
|
||||||
@@ -238,7 +274,7 @@ export function isContainerType(type: ParticleType): boolean {
|
|||||||
|
|
||||||
/** True when a non-container particle has been soft-deleted (tombstoned). */
|
/** True when a non-container particle has been soft-deleted (tombstoned). */
|
||||||
export function isParticleDeleted(particle: Particle): boolean {
|
export function isParticleDeleted(particle: Particle): boolean {
|
||||||
return "deleted_at" in particle && particle.deleted_at != null;
|
return 'deleted_at' in particle && particle.deleted_at != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LiveKit types ---
|
// --- LiveKit types ---
|
||||||
@@ -247,16 +283,18 @@ export const GetLivekitTokenResponseSchema = z.object({
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
server_url: z.string(),
|
server_url: z.string(),
|
||||||
});
|
});
|
||||||
export type GetLivekitTokenResponse = z.infer<typeof GetLivekitTokenResponseSchema>;
|
export type GetLivekitTokenResponse = z.infer<
|
||||||
|
typeof GetLivekitTokenResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
// --- Auth types ---
|
// --- Auth types ---
|
||||||
|
|
||||||
const RequestCodeRequestSchema = z.object({
|
export const RequestCodeRequestSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
});
|
});
|
||||||
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
|
||||||
|
|
||||||
const SignInRequestSchema = z.object({
|
export const SignInRequestSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
});
|
});
|
||||||
@@ -275,21 +313,21 @@ export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
|
|||||||
|
|
||||||
// --- Billing types ---
|
// --- Billing types ---
|
||||||
|
|
||||||
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
export const BillingCadenceSchema = z.enum(['monthly', 'annual']);
|
||||||
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
||||||
|
|
||||||
export const NetworkPlanSchema = z.enum(["free", "pro"]);
|
export const NetworkPlanSchema = z.enum(['free', 'pro']);
|
||||||
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
||||||
|
|
||||||
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
||||||
export const BillingPlanStatusSchema = z.enum([
|
export const BillingPlanStatusSchema = z.enum([
|
||||||
"active",
|
'active',
|
||||||
"trialing",
|
'trialing',
|
||||||
"past_due",
|
'past_due',
|
||||||
"canceled",
|
'canceled',
|
||||||
"incomplete",
|
'incomplete',
|
||||||
"incomplete_expired",
|
'incomplete_expired',
|
||||||
"unpaid",
|
'unpaid',
|
||||||
]);
|
]);
|
||||||
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
||||||
|
|
||||||
@@ -308,7 +346,9 @@ export type BillingStatus = z.infer<typeof BillingStatusSchema>;
|
|||||||
export const CheckoutSessionResponseSchema = z.object({
|
export const CheckoutSessionResponseSchema = z.object({
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
});
|
});
|
||||||
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
|
export type CheckoutSessionResponse = z.infer<
|
||||||
|
typeof CheckoutSessionResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const PortalSessionResponseSchema = z.object({
|
export const PortalSessionResponseSchema = z.object({
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native';
|
||||||
import type { Human } from "@/api/types";
|
import type { Human } from '@/api/types';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Size = "xs" | "sm" | "md";
|
type Size = 'xs' | 'sm' | 'md';
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
humanId: string | null | undefined;
|
humanId: string | null | undefined;
|
||||||
@@ -17,9 +17,9 @@ interface AvatarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
|
const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
|
||||||
xs: { box: "h-6 w-6", text: "text-[9px]", ring: 1.5 },
|
xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 },
|
||||||
sm: { box: "h-9 w-9", text: "text-xs", ring: 2 },
|
sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 },
|
||||||
md: { box: "h-10 w-10", text: "text-sm", ring: 2 },
|
md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +30,7 @@ const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
|
|||||||
export function Avatar({
|
export function Avatar({
|
||||||
humanId,
|
humanId,
|
||||||
humans,
|
humans,
|
||||||
size = "sm",
|
size = 'sm',
|
||||||
online = false,
|
online = false,
|
||||||
stackBg,
|
stackBg,
|
||||||
className,
|
className,
|
||||||
@@ -41,7 +41,7 @@ export function Avatar({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-black/15 items-center justify-center rounded-full",
|
'bg-black/15 items-center justify-center rounded-full',
|
||||||
dims.box,
|
dims.box,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -49,10 +49,10 @@ export function Avatar({
|
|||||||
// Online ring is the priority; if not online, show the stack
|
// Online ring is the priority; if not online, show the stack
|
||||||
// separator ring (if requested) so adjacent avatars stay distinct.
|
// separator ring (if requested) so adjacent avatars stay distinct.
|
||||||
borderWidth: online ? dims.ring : stackBg ? dims.ring : 0,
|
borderWidth: online ? dims.ring : stackBg ? dims.ring : 0,
|
||||||
borderColor: online ? "#22c55e" : stackBg ?? "transparent",
|
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className={cn("text-white font-semibold", dims.text)}>
|
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||||
{initials}
|
{initials}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
SafeAreaProvider,
|
SafeAreaProvider,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
} from "react-native-safe-area-context";
|
} from 'react-native-safe-area-context';
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
Extrapolation,
|
Extrapolation,
|
||||||
@@ -22,9 +22,9 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withSpring,
|
withSpring,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||||
const ANIMATION_MS = 240;
|
const ANIMATION_MS = 240;
|
||||||
|
|
||||||
interface BottomSheetProps {
|
interface BottomSheetProps {
|
||||||
@@ -58,7 +58,7 @@ export function BottomSheet({
|
|||||||
onClose,
|
onClose,
|
||||||
onClosed,
|
onClosed,
|
||||||
avoidKeyboard = false,
|
avoidKeyboard = false,
|
||||||
maxHeight = "85%",
|
maxHeight = '85%',
|
||||||
children,
|
children,
|
||||||
}: BottomSheetProps) {
|
}: BottomSheetProps) {
|
||||||
// Mount slightly past `open` so the slide-in animation has its starting
|
// Mount slightly past `open` so the slide-in animation has its starting
|
||||||
@@ -66,6 +66,9 @@ export function BottomSheet({
|
|||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const translateY = useSharedValue(SCREEN_HEIGHT);
|
const translateY = useSharedValue(SCREEN_HEIGHT);
|
||||||
|
|
||||||
|
// Mount as soon as we open; the close path unmounts after the exit animation.
|
||||||
|
if (open && !mounted) setMounted(true);
|
||||||
|
|
||||||
// Latest onClosed in a ref so the worklet→JS bridge always invokes the
|
// Latest onClosed in a ref so the worklet→JS bridge always invokes the
|
||||||
// current callback even if the parent re-rendered with a new closure.
|
// current callback even if the parent re-rendered with a new closure.
|
||||||
const onClosedRef = useRef(onClosed);
|
const onClosedRef = useRef(onClosed);
|
||||||
@@ -80,7 +83,6 @@ export function BottomSheet({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true);
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
translateY.value = withSpring(0, {
|
translateY.value = withSpring(0, {
|
||||||
damping: 24,
|
damping: 24,
|
||||||
@@ -104,14 +106,18 @@ export function BottomSheet({
|
|||||||
.activeOffsetY(10)
|
.activeOffsetY(10)
|
||||||
.failOffsetX([-25, 25])
|
.failOffsetX([-25, 25])
|
||||||
.onUpdate((e) => {
|
.onUpdate((e) => {
|
||||||
"worklet";
|
'worklet';
|
||||||
|
// Reanimated shared values are mutated by design; react-hooks/immutability
|
||||||
|
// doesn't model worklets, so the mutations below are flagged spuriously.
|
||||||
|
// eslint-disable-next-line react-hooks/immutability
|
||||||
translateY.value = Math.max(0, e.translationY);
|
translateY.value = Math.max(0, e.translationY);
|
||||||
})
|
})
|
||||||
.onEnd((e) => {
|
.onEnd((e) => {
|
||||||
"worklet";
|
'worklet';
|
||||||
if (e.translationY > 120 || e.velocityY > 800) {
|
if (e.translationY > 120 || e.velocityY > 800) {
|
||||||
runOnJS(onClose)();
|
runOnJS(onClose)();
|
||||||
} else {
|
} else {
|
||||||
|
// eslint-disable-next-line react-hooks/immutability
|
||||||
translateY.value = withSpring(0, {
|
translateY.value = withSpring(0, {
|
||||||
damping: 24,
|
damping: 24,
|
||||||
stiffness: 260,
|
stiffness: 260,
|
||||||
@@ -138,7 +144,7 @@ export function BottomSheet({
|
|||||||
|
|
||||||
const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View;
|
const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View;
|
||||||
const wrapperProps = avoidKeyboard
|
const wrapperProps = avoidKeyboard
|
||||||
? { behavior: Platform.OS === "ios" ? ("padding" as const) : undefined }
|
? { behavior: Platform.OS === 'ios' ? ('padding' as const) : undefined }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -151,9 +157,9 @@ export function BottomSheet({
|
|||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents={open ? "auto" : "none"}
|
pointerEvents={open ? 'auto' : 'none'}
|
||||||
style={[
|
style={[
|
||||||
{ position: "absolute", inset: 0, backgroundColor: "black" },
|
{ position: 'absolute', inset: 0, backgroundColor: 'black' },
|
||||||
backdropStyle,
|
backdropStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -162,23 +168,23 @@ export function BottomSheet({
|
|||||||
|
|
||||||
<Wrapper
|
<Wrapper
|
||||||
{...wrapperProps}
|
{...wrapperProps}
|
||||||
style={{ flex: 1, justifyContent: "flex-end" }}
|
style={{ flex: 1, justifyContent: 'flex-end' }}
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
>
|
>
|
||||||
<GestureDetector gesture={sheetPan}>
|
<GestureDetector gesture={sheetPan}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
backgroundColor: "#1c1c1c",
|
backgroundColor: '#1c1c1c',
|
||||||
borderTopLeftRadius: 22,
|
borderTopLeftRadius: 22,
|
||||||
borderTopRightRadius: 22,
|
borderTopRightRadius: 22,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
maxHeight,
|
maxHeight,
|
||||||
},
|
},
|
||||||
sheetStyle,
|
sheetStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SafeAreaView edges={["bottom"]}>
|
<SafeAreaView edges={['bottom']}>
|
||||||
<View className="px-5 pt-3 items-center">
|
<View className="px-5 pt-3 items-center">
|
||||||
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withRepeat,
|
withRepeat,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from 'react-native-reanimated';
|
||||||
import type { Human } from "@/api/types";
|
import type { Human } from '@/api/types';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import type { ComposingUser } from "@/features/stream-view/stream-presence-context";
|
import type { ComposingUser } from '@/features/stream-view/stream-presence-context';
|
||||||
|
|
||||||
interface ComposingIndicatorProps {
|
interface ComposingIndicatorProps {
|
||||||
users: ComposingUser[];
|
users: ComposingUser[];
|
||||||
@@ -29,13 +29,16 @@ export function ComposingIndicator({
|
|||||||
if (users.length === 0) return null;
|
if (users.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View pointerEvents="none" className="flex-row flex-wrap items-center gap-1.5">
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
className="flex-row flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
|
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
|
||||||
const label =
|
const label =
|
||||||
u.mode === "recording"
|
u.mode === 'recording'
|
||||||
? `${displayName} is recording`
|
? `${displayName} is recording`
|
||||||
: u.mode === "screen"
|
: u.mode === 'screen'
|
||||||
? `${displayName} is sharing`
|
? `${displayName} is sharing`
|
||||||
: `${displayName} is typing`;
|
: `${displayName} is typing`;
|
||||||
|
|
||||||
@@ -87,9 +90,6 @@ function Dot({ delay }: { delay: number }) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View className="bg-white/85 h-1 w-1 rounded-full" style={style} />
|
||||||
className="bg-white/85 h-1 w-1 rounded-full"
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Svg, { Path } from "react-native-svg";
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
height?: number;
|
height?: number;
|
||||||
@@ -7,7 +7,7 @@ type Props = {
|
|||||||
|
|
||||||
const ASPECT_RATIO = 89 / 18;
|
const ASPECT_RATIO = 89 / 18;
|
||||||
|
|
||||||
export function FlowyLogo({ height = 18, color = "#828282" }: Props) {
|
export function FlowyLogo({ height = 18, color = '#828282' }: Props) {
|
||||||
const width = height * ASPECT_RATIO;
|
const width = height * ASPECT_RATIO;
|
||||||
return (
|
return (
|
||||||
<Svg width={width} height={height} viewBox="0 0 89 18" fill="none">
|
<Svg width={width} height={height} viewBox="0 0 89 18" fill="none">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View } from "react-native";
|
import { View } from 'react-native';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inset hairline separator for edge-to-edge list rows. Pass to a FlatList
|
* Inset hairline separator for edge-to-edge list rows. Pass to a FlatList
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import { Text, type TextProps } from "react-native";
|
import { Text, type TextProps } from 'react-native';
|
||||||
import { formatDistanceToNow } from "@/lib/time-utils";
|
import { formatDistanceToNow } from '@/lib/time-utils';
|
||||||
|
|
||||||
const MINUTE_MS = 60_000;
|
const MINUTE_MS = 60_000;
|
||||||
|
|
||||||
interface RelativeTimestampProps extends Omit<TextProps, "children"> {
|
interface RelativeTimestampProps extends Omit<TextProps, 'children'> {
|
||||||
date: Date;
|
date: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-21
@@ -1,4 +1,4 @@
|
|||||||
import Constants from "expo-constants";
|
import Constants from 'expo-constants';
|
||||||
|
|
||||||
// Expo-side equivalent of desktop's __APP_ENV__ build-time replacement
|
// 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
|
// (see js/desktop/src/config/env.ts). On mobile we read from app.config.ts
|
||||||
@@ -26,38 +26,38 @@ type AppConfig = {
|
|||||||
sentryDsn: string;
|
sentryDsn: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const configs: Record<"dev" | "prod", AppConfig> = {
|
const configs: Record<'dev' | 'prod', AppConfig> = {
|
||||||
dev: {
|
dev: {
|
||||||
orionUrl: "https://orion.dev.flowy.live",
|
orionUrl: 'https://orion.dev.flowy.live',
|
||||||
pusherUrl: "wss://pusher.dev.flowy.live/ws",
|
pusherUrl: 'wss://pusher.dev.flowy.live/ws',
|
||||||
firebase: {
|
firebase: {
|
||||||
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
|
apiKey: 'AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk',
|
||||||
appId: "1:1006580076785:web:e2a0736d60a78e02b15950",
|
appId: '1:1006580076785:web:e2a0736d60a78e02b15950',
|
||||||
authDomain: "flowy-dev-440017.firebaseapp.com",
|
authDomain: 'flowy-dev-440017.firebaseapp.com',
|
||||||
messagingSenderId: "1006580076785",
|
messagingSenderId: '1006580076785',
|
||||||
projectId: "flowy-dev-440017",
|
projectId: 'flowy-dev-440017',
|
||||||
storageBucket: "flowy-dev-440017.firebasestorage.app",
|
storageBucket: 'flowy-dev-440017.firebasestorage.app',
|
||||||
},
|
},
|
||||||
sentryDsn:
|
sentryDsn:
|
||||||
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
|
'https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528',
|
||||||
},
|
},
|
||||||
prod: {
|
prod: {
|
||||||
orionUrl: "https://orion.flowy.live",
|
orionUrl: 'https://orion.flowy.live',
|
||||||
pusherUrl: "wss://pusher.flowy.live/ws",
|
pusherUrl: 'wss://pusher.flowy.live/ws',
|
||||||
firebase: {
|
firebase: {
|
||||||
apiKey: "AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg",
|
apiKey: 'AIzaSyB8it3SKr9DHScHGlpu4uwWicvt8wzjdBg',
|
||||||
appId: "1:68063426854:web:5054f16f50898f5706e9e7",
|
appId: '1:68063426854:web:5054f16f50898f5706e9e7',
|
||||||
authDomain: "flowy-prod-440017.firebaseapp.com",
|
authDomain: 'flowy-prod-440017.firebaseapp.com',
|
||||||
messagingSenderId: "68063426854",
|
messagingSenderId: '68063426854',
|
||||||
projectId: "flowy-prod-440017",
|
projectId: 'flowy-prod-440017',
|
||||||
storageBucket: "flowy-prod-440017.firebasestorage.app",
|
storageBucket: 'flowy-prod-440017.firebasestorage.app',
|
||||||
},
|
},
|
||||||
sentryDsn:
|
sentryDsn:
|
||||||
"https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528",
|
'https://4bb3af832825929eeec8ab4edd56930f@o4511282312118272.ingest.us.sentry.io/4511282313494528',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawEnv = (Constants.expoConfig?.extra as { appEnv?: string } | undefined)
|
const rawEnv = (Constants.expoConfig?.extra as { appEnv?: string } | undefined)
|
||||||
?.appEnv;
|
?.appEnv;
|
||||||
export const appEnv: "dev" | "prod" = rawEnv === "prod" ? "prod" : "dev";
|
export const appEnv: 'dev' | 'prod' = rawEnv === 'prod' ? 'prod' : 'dev';
|
||||||
export const appConfig: AppConfig = configs[appEnv];
|
export const appConfig: AppConfig = configs[appEnv];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -6,32 +6,32 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from 'react-native';
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
type Step = "email" | "code";
|
type Step = 'email' | 'code';
|
||||||
|
|
||||||
export function SignInScreen() {
|
export function SignInScreen() {
|
||||||
const [step, setStep] = useState<Step>("email");
|
const [step, setStep] = useState<Step>('email');
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background">
|
<SafeAreaView className="flex-1 bg-background">
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<View className="flex-1 justify-center px-6">
|
<View className="flex-1 justify-center px-6">
|
||||||
{step === "email" ? (
|
{step === 'email' ? (
|
||||||
<EmailStep
|
<EmailStep
|
||||||
onCodeSent={(submittedEmail) => {
|
onCodeSent={(submittedEmail) => {
|
||||||
setEmail(submittedEmail);
|
setEmail(submittedEmail);
|
||||||
setStep("code");
|
setStep('code');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CodeStep email={email} onBack={() => setStep("email")} />
|
<CodeStep email={email} onBack={() => setStep('email')} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -40,7 +40,7 @@ export function SignInScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState('');
|
||||||
const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
|
const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
|
||||||
const error = useAuthStore((s) => s.error);
|
const error = useAuthStore((s) => s.error);
|
||||||
const requestCode = useAuthStore((s) => s.requestCode);
|
const requestCode = useAuthStore((s) => s.requestCode);
|
||||||
@@ -88,23 +88,21 @@ function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{error ? (
|
{error ? <Text className="text-destructive text-sm">{error}</Text> : null}
|
||||||
<Text className="text-destructive text-sm">{error}</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`rounded-lg px-4 py-3.5 items-center ${
|
className={`rounded-lg px-4 py-3.5 items-center ${
|
||||||
disabled ? "bg-muted" : "bg-primary"
|
disabled ? 'bg-muted' : 'bg-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`text-base font-semibold ${
|
className={`text-base font-semibold ${
|
||||||
disabled ? "text-muted-foreground" : "text-primary-foreground"
|
disabled ? 'text-muted-foreground' : 'text-primary-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isRequestingCode ? "Sending..." : "Continue"}
|
{isRequestingCode ? 'Sending...' : 'Continue'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -112,7 +110,7 @@ function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState('');
|
||||||
const isSigningIn = useAuthStore((s) => s.isSigningIn);
|
const isSigningIn = useAuthStore((s) => s.isSigningIn);
|
||||||
const error = useAuthStore((s) => s.error);
|
const error = useAuthStore((s) => s.error);
|
||||||
const signIn = useAuthStore((s) => s.signIn);
|
const signIn = useAuthStore((s) => s.signIn);
|
||||||
@@ -135,7 +133,7 @@ function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
|||||||
Check your email
|
Check your email
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground text-base">
|
<Text className="text-muted-foreground text-base">
|
||||||
We sent a code to{" "}
|
We sent a code to{' '}
|
||||||
<Text className="text-foreground font-medium">{email}</Text>.
|
<Text className="text-foreground font-medium">{email}</Text>.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -159,24 +157,22 @@ function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{error ? (
|
{error ? <Text className="text-destructive text-sm">{error}</Text> : null}
|
||||||
<Text className="text-destructive text-sm">{error}</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View className="gap-2">
|
<View className="gap-2">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`rounded-lg px-4 py-3.5 items-center ${
|
className={`rounded-lg px-4 py-3.5 items-center ${
|
||||||
disabled ? "bg-muted" : "bg-primary"
|
disabled ? 'bg-muted' : 'bg-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`text-base font-semibold ${
|
className={`text-base font-semibold ${
|
||||||
disabled ? "text-muted-foreground" : "text-primary-foreground"
|
disabled ? 'text-muted-foreground' : 'text-primary-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
{isSigningIn ? 'Signing in...' : 'Sign in'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { Mic } from "lucide-react-native";
|
import { Mic } from 'lucide-react-native';
|
||||||
import {
|
import {
|
||||||
RecordingPresets,
|
RecordingPresets,
|
||||||
useAudioRecorder,
|
useAudioRecorder,
|
||||||
useAudioRecorderState,
|
useAudioRecorderState,
|
||||||
} from "expo-audio";
|
} from 'expo-audio';
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from '@/lib/errors';
|
||||||
import {
|
import {
|
||||||
acquireRecordingAudioSession,
|
acquireRecordingAudioSession,
|
||||||
releaseRecordingAudioSession,
|
releaseRecordingAudioSession,
|
||||||
} from "@/lib/recording-audio-session";
|
} from '@/lib/recording-audio-session';
|
||||||
|
|
||||||
const MAX_DURATION_S = 60;
|
const MAX_DURATION_S = 60;
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function AudioRecordingOverlay({
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
recorder.record();
|
recorder.record();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "compose.audio.start" });
|
logError(err, { scope: 'compose.audio.start' });
|
||||||
if (active) onCancel();
|
if (active) onCancel();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -48,7 +48,7 @@ export function AudioRecordingOverlay({
|
|||||||
recorder.stop().catch(() => {});
|
recorder.stop().catch(() => {});
|
||||||
}
|
}
|
||||||
void releaseRecordingAudioSession().catch((err) =>
|
void releaseRecordingAudioSession().catch((err) =>
|
||||||
logError(err, { scope: "compose.audio.exit" }),
|
logError(err, { scope: 'compose.audio.exit' }),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -56,23 +56,16 @@ export function AudioRecordingOverlay({
|
|||||||
|
|
||||||
const elapsedMs = state.durationMillis ?? 0;
|
const elapsedMs = state.durationMillis ?? 0;
|
||||||
|
|
||||||
useEffect(() => {
|
const finish = async (kind: 'commit' | 'cancel') => {
|
||||||
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
|
|
||||||
void finish("commit");
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [elapsedMs]);
|
|
||||||
|
|
||||||
const finish = async (kind: "commit" | "cancel") => {
|
|
||||||
if (finalizedRef.current) return;
|
if (finalizedRef.current) return;
|
||||||
finalizedRef.current = true;
|
finalizedRef.current = true;
|
||||||
const durationMs = state.durationMillis ?? 0;
|
const durationMs = state.durationMillis ?? 0;
|
||||||
try {
|
try {
|
||||||
await recorder.stop();
|
await recorder.stop();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "compose.audio.stop" });
|
logError(err, { scope: 'compose.audio.stop' });
|
||||||
}
|
}
|
||||||
if (kind === "cancel") {
|
if (kind === 'cancel') {
|
||||||
onCancel();
|
onCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -84,6 +77,14 @@ export function AudioRecordingOverlay({
|
|||||||
onComplete({ uri, durationMs });
|
onComplete({ uri, durationMs });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-commit when we hit the max duration.
|
||||||
|
useEffect(() => {
|
||||||
|
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
|
||||||
|
void finish('commit');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [elapsedMs]);
|
||||||
|
|
||||||
const elapsedSec = Math.floor(elapsedMs / 1000);
|
const elapsedSec = Math.floor(elapsedMs / 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,22 +98,22 @@ export function AudioRecordingOverlay({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white mt-6 text-lg font-semibold">
|
<Text className="text-white mt-6 text-lg font-semibold">
|
||||||
{state.isRecording ? "Recording" : "Starting…"}
|
{state.isRecording ? 'Recording' : 'Starting…'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-white/60 mt-1 text-sm">
|
<Text className="text-white/60 mt-1 text-sm">
|
||||||
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
|
{elapsedSec.toString().padStart(2, '0')}s · max {MAX_DURATION_S}s
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
|
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => void finish("cancel")}
|
onPress={() => void finish('cancel')}
|
||||||
accessibilityLabel="Cancel recording"
|
accessibilityLabel="Cancel recording"
|
||||||
className="rounded-full bg-white/15 px-6 py-3"
|
className="rounded-full bg-white/15 px-6 py-3"
|
||||||
>
|
>
|
||||||
<Text className="text-white text-base font-medium">Cancel</Text>
|
<Text className="text-white text-base font-medium">Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => void finish("commit")}
|
onPress={() => void finish('commit')}
|
||||||
accessibilityLabel="Stop recording"
|
accessibilityLabel="Stop recording"
|
||||||
className="rounded-full bg-white px-7 py-3"
|
className="rounded-full bg-white px-7 py-3"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,43 +1,37 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native";
|
import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native';
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEvent } from '@/hooks/use-event';
|
||||||
|
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { createTextParticle, uploadMediaParticle } from '@/lib/upload';
|
||||||
|
import type { ParticlePath } from '@/lib/particle-path';
|
||||||
import {
|
import {
|
||||||
useCameraPermissions,
|
useStreamComposingBroadcastOptional,
|
||||||
useMicrophonePermissions,
|
|
||||||
} from "expo-camera";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useEvent } from "@/hooks/use-event";
|
|
||||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
|
||||||
import {
|
|
||||||
createTextParticle,
|
|
||||||
uploadMediaParticle,
|
|
||||||
} from "@/lib/upload";
|
|
||||||
import type { ParticlePath } from "@/lib/particle-path";
|
|
||||||
import {
|
|
||||||
useStreamComposingBroadcast,
|
|
||||||
type ComposingMode,
|
type ComposingMode,
|
||||||
} from "@/features/stream-view/stream-presence-context";
|
} from '@/features/stream-view/stream-presence-context';
|
||||||
import { TextComposeModal } from "./TextComposeModal";
|
import { TextComposeModal } from './TextComposeModal';
|
||||||
import { VideoRecordingOverlay } from "./VideoRecordingOverlay";
|
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
||||||
import { AudioRecordingOverlay } from "./AudioRecordingOverlay";
|
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
||||||
import { ReviewSheet } from "./ReviewSheet";
|
import { ReviewSheet } from './ReviewSheet';
|
||||||
|
|
||||||
type RecordingMode = "video" | "audio";
|
type RecordingMode = 'video' | 'audio';
|
||||||
|
|
||||||
type ComposeUiState =
|
type ComposeUiState =
|
||||||
| { kind: "idle" }
|
| { kind: 'idle' }
|
||||||
| { kind: "recording"; mode: RecordingMode }
|
| { kind: 'recording'; mode: RecordingMode }
|
||||||
| {
|
| {
|
||||||
kind: "review";
|
kind: 'review';
|
||||||
mode: RecordingMode;
|
mode: RecordingMode;
|
||||||
uri: string;
|
uri: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "uploading";
|
kind: 'uploading';
|
||||||
mode: RecordingMode;
|
mode: RecordingMode;
|
||||||
uri: string;
|
uri: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
@@ -47,7 +41,7 @@ interface SubmitMediaParams {
|
|||||||
fileUri: string;
|
fileUri: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
source: "camera" | "screen";
|
source: 'camera' | 'screen';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComposeDockProps {
|
interface ComposeDockProps {
|
||||||
@@ -75,8 +69,8 @@ export function ComposeDock({
|
|||||||
}: ComposeDockProps) {
|
}: ComposeDockProps) {
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
const [mode, setMode] = useState<RecordingMode>("video");
|
const [mode, setMode] = useState<RecordingMode>('video');
|
||||||
const [ui, setUi] = useState<ComposeUiState>({ kind: "idle" });
|
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
||||||
const [textOpen, setTextOpen] = useState(false);
|
const [textOpen, setTextOpen] = useState(false);
|
||||||
|
|
||||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||||
@@ -85,7 +79,7 @@ export function ComposeDock({
|
|||||||
// Tell StreamView to fully unmount its expo-video player while we record.
|
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||||
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||||
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||||
const isComposing = ui.kind !== "idle" || textOpen;
|
const isComposing = ui.kind !== 'idle' || textOpen;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setComposing(isComposing);
|
setComposing(isComposing);
|
||||||
return () => setComposing(false);
|
return () => setComposing(false);
|
||||||
@@ -98,13 +92,13 @@ export function ComposeDock({
|
|||||||
if (forVideo) {
|
if (forVideo) {
|
||||||
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
|
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
|
||||||
if (!cam.granted) {
|
if (!cam.granted) {
|
||||||
toast.error("Camera permission is required to record video.");
|
toast.error('Camera permission is required to record video.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
|
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
|
||||||
if (!mic.granted) {
|
if (!mic.granted) {
|
||||||
toast.error("Microphone permission is required to record.");
|
toast.error('Microphone permission is required to record.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -113,46 +107,45 @@ export function ComposeDock({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const startRecording = useEvent(async () => {
|
const startRecording = useEvent(async () => {
|
||||||
if (ui.kind !== "idle") return;
|
if (ui.kind !== 'idle') return;
|
||||||
const ok = await ensurePermissions(mode === "video");
|
const ok = await ensurePermissions(mode === 'video');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
setUi({ kind: "recording", mode });
|
setUi({ kind: 'recording', mode });
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRecordingComplete = useCallback(
|
const handleRecordingComplete = useCallback(
|
||||||
({ uri, durationMs }: { uri: string; durationMs: number }) => {
|
({ uri, durationMs }: { uri: string; durationMs: number }) => {
|
||||||
void Haptics.selectionAsync();
|
void Haptics.selectionAsync();
|
||||||
setUi((prev) => {
|
setUi((prev) => {
|
||||||
const m = "mode" in prev ? prev.mode : mode;
|
const m = 'mode' in prev ? prev.mode : mode;
|
||||||
return { kind: "review", mode: m, uri, durationMs };
|
return { kind: 'review', mode: m, uri, durationMs };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mode],
|
[mode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRecordingCancel = useCallback(() => {
|
const handleRecordingCancel = useCallback(() => {
|
||||||
setUi({ kind: "idle" });
|
setUi({ kind: 'idle' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendReview = useEvent(async () => {
|
const sendReview = useEvent(async () => {
|
||||||
if (ui.kind !== "review" || !userId) return;
|
if (ui.kind !== 'review' || !userId) return;
|
||||||
const captured = ui;
|
const captured = ui;
|
||||||
setUi({
|
setUi({
|
||||||
kind: "uploading",
|
kind: 'uploading',
|
||||||
mode: captured.mode,
|
mode: captured.mode,
|
||||||
uri: captured.uri,
|
uri: captured.uri,
|
||||||
durationMs: captured.durationMs,
|
durationMs: captured.durationMs,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const mimeType =
|
const mimeType = captured.mode === 'audio' ? 'audio/mp4' : 'video/mp4';
|
||||||
captured.mode === "audio" ? "audio/mp4" : "video/mp4";
|
|
||||||
if (submitMedia) {
|
if (submitMedia) {
|
||||||
await submitMedia({
|
await submitMedia({
|
||||||
fileUri: captured.uri,
|
fileUri: captured.uri,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationMs: captured.durationMs,
|
durationMs: captured.durationMs,
|
||||||
source: "camera",
|
source: 'camera',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const particleId = await uploadMediaParticle({
|
const particleId = await uploadMediaParticle({
|
||||||
@@ -161,13 +154,13 @@ export function ComposeDock({
|
|||||||
fileUri: captured.uri,
|
fileUri: captured.uri,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationMs: captured.durationMs,
|
durationMs: captured.durationMs,
|
||||||
source: "camera",
|
source: 'camera',
|
||||||
createdByHumanId: userId,
|
createdByHumanId: userId,
|
||||||
});
|
});
|
||||||
onParticleCreated?.(particleId);
|
onParticleCreated?.(particleId);
|
||||||
}
|
}
|
||||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
setUi({ kind: "idle" });
|
setUi({ kind: 'idle' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
setUi(captured);
|
setUi(captured);
|
||||||
@@ -175,11 +168,11 @@ export function ComposeDock({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const retake = useCallback(() => setUi({ kind: "idle" }), []);
|
const retake = useCallback(() => setUi({ kind: 'idle' }), []);
|
||||||
const cancelReview = useCallback(() => setUi({ kind: "idle" }), []);
|
const cancelReview = useCallback(() => setUi({ kind: 'idle' }), []);
|
||||||
|
|
||||||
const submitText = useEvent(async (content: string) => {
|
const submitText = useEvent(async (content: string) => {
|
||||||
if (!userId) throw new Error("Not signed in.");
|
if (!userId) throw new Error('Not signed in.');
|
||||||
if (submitTextOverride) {
|
if (submitTextOverride) {
|
||||||
await submitTextOverride(content);
|
await submitTextOverride(content);
|
||||||
} else {
|
} else {
|
||||||
@@ -195,9 +188,7 @@ export function ComposeDock({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dockHidden =
|
const dockHidden =
|
||||||
ui.kind === "review" ||
|
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
|
||||||
ui.kind === "uploading" ||
|
|
||||||
ui.kind === "recording";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -209,18 +200,18 @@ export function ComposeDock({
|
|||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setMode((m) => (m === "video" ? "audio" : "video"))
|
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||||
}
|
}
|
||||||
disabled={ui.kind !== "idle"}
|
disabled={ui.kind !== 'idle'}
|
||||||
accessibilityLabel={`Switch to ${
|
accessibilityLabel={`Switch to ${
|
||||||
mode === "video" ? "audio" : "video"
|
mode === 'video' ? 'audio' : 'video'
|
||||||
} mode`}
|
} mode`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
ui.kind !== "idle" && "opacity-40",
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mode === "video" ? (
|
{mode === 'video' ? (
|
||||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||||
) : (
|
) : (
|
||||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||||
@@ -230,24 +221,22 @@ export function ComposeDock({
|
|||||||
<View className="items-center">
|
<View className="items-center">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={startRecording}
|
onPress={startRecording}
|
||||||
disabled={ui.kind !== "idle"}
|
disabled={ui.kind !== 'idle'}
|
||||||
accessibilityLabel={`Record ${mode}`}
|
accessibilityLabel={`Record ${mode}`}
|
||||||
className="h-20 w-20 items-center justify-center rounded-full bg-white"
|
className="h-20 w-20 items-center justify-center rounded-full bg-white"
|
||||||
>
|
>
|
||||||
<View className="h-6 w-6 rounded bg-black" />
|
<View className="h-6 w-6 rounded bg-black" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text className="text-white/60 mt-2 text-xs">
|
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
||||||
Tap to record
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setTextOpen(true)}
|
onPress={() => setTextOpen(true)}
|
||||||
disabled={ui.kind !== "idle"}
|
disabled={ui.kind !== 'idle'}
|
||||||
accessibilityLabel="Compose text"
|
accessibilityLabel="Compose text"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
ui.kind !== "idle" && "opacity-40",
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
||||||
@@ -256,8 +245,8 @@ export function ComposeDock({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{ui.kind === "recording" ? (
|
{ui.kind === 'recording' ? (
|
||||||
ui.mode === "video" ? (
|
ui.mode === 'video' ? (
|
||||||
<VideoRecordingOverlay
|
<VideoRecordingOverlay
|
||||||
onComplete={handleRecordingComplete}
|
onComplete={handleRecordingComplete}
|
||||||
onCancel={handleRecordingCancel}
|
onCancel={handleRecordingCancel}
|
||||||
@@ -271,17 +260,13 @@ export function ComposeDock({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ReviewSheet
|
<ReviewSheet
|
||||||
open={ui.kind === "review" || ui.kind === "uploading"}
|
open={ui.kind === 'review' || ui.kind === 'uploading'}
|
||||||
uri={
|
uri={ui.kind === 'review' || ui.kind === 'uploading' ? ui.uri : null}
|
||||||
ui.kind === "review" || ui.kind === "uploading" ? ui.uri : null
|
mode={ui.kind === 'review' || ui.kind === 'uploading' ? ui.mode : null}
|
||||||
}
|
|
||||||
mode={
|
|
||||||
ui.kind === "review" || ui.kind === "uploading" ? ui.mode : null
|
|
||||||
}
|
|
||||||
durationMs={
|
durationMs={
|
||||||
ui.kind === "review" || ui.kind === "uploading" ? ui.durationMs : 0
|
ui.kind === 'review' || ui.kind === 'uploading' ? ui.durationMs : 0
|
||||||
}
|
}
|
||||||
sending={ui.kind === "uploading"}
|
sending={ui.kind === 'uploading'}
|
||||||
onSend={sendReview}
|
onSend={sendReview}
|
||||||
onRetake={retake}
|
onRetake={retake}
|
||||||
onCancel={cancelReview}
|
onCancel={cancelReview}
|
||||||
@@ -305,15 +290,11 @@ function useComposingBroadcast({
|
|||||||
textOpen: boolean;
|
textOpen: boolean;
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
}) {
|
}) {
|
||||||
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null;
|
// null when the dock is rendered outside a stream (no presence provider).
|
||||||
try {
|
const broadcast = useStreamComposingBroadcastOptional();
|
||||||
broadcast = useStreamComposingBroadcast();
|
|
||||||
} catch {
|
|
||||||
broadcast = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode: ComposingMode | null =
|
const mode: ComposingMode | null =
|
||||||
ui.kind === "recording" ? "recording" : textOpen ? "typing" : null;
|
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (silent || !broadcast) return;
|
if (silent || !broadcast) return;
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { ActivityIndicator, Modal, Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, Modal, Pressable, Text, View } from 'react-native';
|
||||||
import {
|
import {
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
SafeAreaProvider,
|
SafeAreaProvider,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
} from "react-native-safe-area-context";
|
} from 'react-native-safe-area-context';
|
||||||
import { useVideoPlayer, VideoView } from "expo-video";
|
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||||
import { Mic } from "lucide-react-native";
|
import { Mic } from 'lucide-react-native';
|
||||||
import { toast } from "sonner-native";
|
import { toast } from 'sonner-native';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
|
|
||||||
interface ReviewSheetProps {
|
interface ReviewSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** Local file URI from the recorder. */
|
/** Local file URI from the recorder. */
|
||||||
uri: string | null;
|
uri: string | null;
|
||||||
mode: "video" | "audio" | null;
|
mode: 'video' | 'audio' | null;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
/**
|
/**
|
||||||
* True once the parent has flipped to the uploading state. The sheet stays
|
* True once the parent has flipped to the uploading state. The sheet stays
|
||||||
@@ -43,10 +43,10 @@ export function ReviewSheet({
|
|||||||
onRetake,
|
onRetake,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ReviewSheetProps) {
|
}: ReviewSheetProps) {
|
||||||
const player = useVideoPlayer(uri ?? "", (p) => {
|
const player = useVideoPlayer(uri ?? '', (p) => {
|
||||||
p.loop = true;
|
p.loop = true;
|
||||||
p.muted = false;
|
p.muted = false;
|
||||||
p.audioMixingMode = "mixWithOthers";
|
p.audioMixingMode = 'mixWithOthers';
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,102 +77,102 @@ export function ReviewSheet({
|
|||||||
onRequestClose={sending ? undefined : onCancel}
|
onRequestClose={sending ? undefined : onCancel}
|
||||||
>
|
>
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<View className="flex-1 bg-black">
|
<View className="flex-1 bg-black">
|
||||||
{uri ? (
|
{uri ? (
|
||||||
mode === "audio" ? (
|
mode === 'audio' ? (
|
||||||
<View className="flex-1 items-center justify-center px-8">
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
<View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full">
|
<View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full">
|
||||||
<Mic color="white" size={42} strokeWidth={1.5} />
|
<Mic color="white" size={42} strokeWidth={1.5} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-white mt-6 text-lg font-semibold">
|
||||||
|
Voice message · {seconds}s
|
||||||
|
</Text>
|
||||||
|
<Text className="text-white/50 mt-2 text-sm">
|
||||||
|
Tap send to share, or retake.
|
||||||
|
</Text>
|
||||||
|
<View className="absolute" style={{ width: 1, height: 1 }}>
|
||||||
|
<VideoView
|
||||||
|
style={{ width: 1, height: 1 }}
|
||||||
|
player={player}
|
||||||
|
nativeControls={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white mt-6 text-lg font-semibold">
|
) : (
|
||||||
Voice message · {seconds}s
|
<VideoView
|
||||||
</Text>
|
style={{ flex: 1 }}
|
||||||
<Text className="text-white/50 mt-2 text-sm">
|
player={player}
|
||||||
Tap send to share, or retake.
|
nativeControls={false}
|
||||||
</Text>
|
contentFit="cover"
|
||||||
<View className="absolute" style={{ width: 1, height: 1 }}>
|
allowsFullscreen={false}
|
||||||
<VideoView
|
allowsPictureInPicture={false}
|
||||||
style={{ width: 1, height: 1 }}
|
/>
|
||||||
player={player}
|
)
|
||||||
nativeControls={false}
|
) : null}
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<VideoView
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
player={player}
|
|
||||||
nativeControls={false}
|
|
||||||
contentFit="cover"
|
|
||||||
allowsFullscreen={false}
|
|
||||||
allowsPictureInPicture={false}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
edges={["top"]}
|
edges={['top']}
|
||||||
className="absolute top-0 left-0 right-0"
|
className="absolute top-0 left-0 right-0"
|
||||||
>
|
>
|
||||||
<View className="px-4 pt-3">
|
<View className="px-4 pt-3">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onCancel}
|
onPress={onCancel}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
accessibilityLabel="Cancel"
|
accessibilityLabel="Cancel"
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={cn(
|
|
||||||
"text-base",
|
|
||||||
sending ? "text-white/30" : "text-white/80",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Cancel
|
<Text
|
||||||
</Text>
|
className={cn(
|
||||||
</Pressable>
|
'text-base',
|
||||||
</View>
|
sending ? 'text-white/30' : 'text-white/80',
|
||||||
</SafeAreaView>
|
)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
edges={["bottom"]}
|
edges={['bottom']}
|
||||||
className="absolute bottom-0 left-0 right-0"
|
className="absolute bottom-0 left-0 right-0"
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center justify-between px-6 pb-4 pt-3">
|
<View className="flex-row items-center justify-between px-6 pb-4 pt-3">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onRetake}
|
onPress={onRetake}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full bg-white/15 px-5 py-3",
|
'rounded-full bg-white/15 px-5 py-3',
|
||||||
sending && "opacity-40",
|
sending && 'opacity-40',
|
||||||
)}
|
)}
|
||||||
accessibilityLabel="Retake"
|
accessibilityLabel="Retake"
|
||||||
>
|
>
|
||||||
<Text className="text-white text-base font-medium">Retake</Text>
|
<Text className="text-white text-base font-medium">Retake</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleSend}
|
onPress={handleSend}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-7 py-3",
|
'rounded-full px-7 py-3',
|
||||||
sending ? "bg-white/40" : "bg-white",
|
sending ? 'bg-white/40' : 'bg-white',
|
||||||
)}
|
)}
|
||||||
accessibilityLabel="Send"
|
accessibilityLabel="Send"
|
||||||
>
|
>
|
||||||
<Text className="text-black text-base font-semibold">
|
<Text className="text-black text-base font-semibold">
|
||||||
{sending ? "Sending..." : "Send"}
|
{sending ? 'Sending...' : 'Send'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<View className="absolute inset-0 items-center justify-center bg-black/85">
|
<View className="absolute inset-0 items-center justify-center bg-black/85">
|
||||||
<ActivityIndicator color="white" />
|
<ActivityIndicator color="white" />
|
||||||
<Text className="text-white/70 mt-4 text-sm">Sending...</Text>
|
<Text className="text-white/70 mt-4 text-sm">Sending...</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -7,26 +7,23 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
SafeAreaProvider,
|
SafeAreaProvider,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
} from "react-native-safe-area-context";
|
} from 'react-native-safe-area-context';
|
||||||
import { toast } from "sonner-native";
|
import { toast } from 'sonner-native';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
|
|
||||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||||
|
|
||||||
function getImmersiveStyle(length: number) {
|
function getImmersiveStyle(length: number) {
|
||||||
if (length === 0)
|
if (length === 0) return { className: 'text-3xl font-semibold leading-snug' };
|
||||||
return { className: "text-3xl font-semibold leading-snug" };
|
if (length < 30) return { className: 'text-5xl font-semibold leading-tight' };
|
||||||
if (length < 30)
|
if (length < 70) return { className: 'text-3xl font-semibold leading-snug' };
|
||||||
return { className: "text-5xl font-semibold leading-tight" };
|
return { className: 'text-2xl font-normal leading-snug' };
|
||||||
if (length < 70)
|
|
||||||
return { className: "text-3xl font-semibold leading-snug" };
|
|
||||||
return { className: "text-2xl font-normal leading-snug" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextComposeModalProps {
|
interface TextComposeModalProps {
|
||||||
@@ -50,20 +47,26 @@ export function TextComposeModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: TextComposeModalProps) {
|
}: TextComposeModalProps) {
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
// Reset whenever the modal opens fresh.
|
// Reset whenever the modal opens fresh.
|
||||||
useEffect(() => {
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setContent("");
|
setContent('');
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
// Re-focus on next tick; iOS occasionally drops the autoFocus call when
|
|
||||||
// the modal animation is mid-flight.
|
|
||||||
const t = setTimeout(() => inputRef.current?.focus(), 60);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-focus on next tick; iOS occasionally drops the autoFocus call when the
|
||||||
|
// modal animation is mid-flight.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const t = setTimeout(() => inputRef.current?.focus(), 60);
|
||||||
|
return () => clearTimeout(t);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
@@ -92,60 +95,60 @@ export function TextComposeModal({
|
|||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
|
<SafeAreaView className="flex-1 bg-black" edges={['top', 'bottom']}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center justify-between px-4 py-3">
|
<View className="flex-row items-center justify-between px-4 py-3">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
accessibilityLabel="Cancel"
|
accessibilityLabel="Cancel"
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
>
|
|
||||||
<Text className="text-white/70 text-base">Cancel</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleSend}
|
|
||||||
disabled={!canSend}
|
|
||||||
hitSlop={12}
|
|
||||||
accessibilityLabel="Send"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={cn(
|
|
||||||
"text-base font-semibold",
|
|
||||||
canSend ? "text-white" : "text-white/30",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{submitting ? "Sending..." : "Send"}
|
<Text className="text-white/70 text-base">Cancel</Text>
|
||||||
</Text>
|
</Pressable>
|
||||||
</Pressable>
|
<Pressable
|
||||||
</View>
|
onPress={handleSend}
|
||||||
|
disabled={!canSend}
|
||||||
|
hitSlop={12}
|
||||||
|
accessibilityLabel="Send"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-base font-semibold',
|
||||||
|
canSend ? 'text-white' : 'text-white/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting ? 'Sending...' : 'Send'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex-1 justify-center px-6 pb-6">
|
<View className="flex-1 justify-center px-6 pb-6">
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={content}
|
value={content}
|
||||||
onChangeText={setContent}
|
onChangeText={setContent}
|
||||||
placeholder="Type a message"
|
placeholder="Type a message"
|
||||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||||
multiline
|
multiline
|
||||||
autoFocus
|
autoFocus
|
||||||
autoCorrect
|
autoCorrect
|
||||||
autoCapitalize="sentences"
|
autoCapitalize="sentences"
|
||||||
editable={!submitting}
|
editable={!submitting}
|
||||||
scrollEnabled={!isImmersive}
|
scrollEnabled={!isImmersive}
|
||||||
textAlignVertical={isImmersive ? "center" : "top"}
|
textAlignVertical={isImmersive ? 'center' : 'top'}
|
||||||
style={{
|
style={{
|
||||||
color: "white",
|
color: 'white',
|
||||||
textAlign: isImmersive ? "center" : "left",
|
textAlign: isImmersive ? 'center' : 'left',
|
||||||
maxHeight: isImmersive ? undefined : 540,
|
maxHeight: isImmersive ? undefined : 540,
|
||||||
}}
|
}}
|
||||||
className={cn("text-white", style.className)}
|
className={cn('text-white', style.className)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { CameraView, type CameraType } from "expo-camera";
|
import { CameraView, type CameraType } from 'expo-camera';
|
||||||
import { SwitchCamera } from "lucide-react-native";
|
import { SwitchCamera } from 'lucide-react-native';
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from '@/lib/errors';
|
||||||
import {
|
import {
|
||||||
acquireRecordingAudioSession,
|
acquireRecordingAudioSession,
|
||||||
releaseRecordingAudioSession,
|
releaseRecordingAudioSession,
|
||||||
} from "@/lib/recording-audio-session";
|
} from '@/lib/recording-audio-session';
|
||||||
|
|
||||||
const MAX_DURATION_S = 60;
|
const MAX_DURATION_S = 60;
|
||||||
const VIDEO_BITRATE_BPS = 1_200_000;
|
const VIDEO_BITRATE_BPS = 1_200_000;
|
||||||
@@ -24,7 +24,7 @@ export function VideoRecordingOverlay({
|
|||||||
const [cameraReady, setCameraReady] = useState(false);
|
const [cameraReady, setCameraReady] = useState(false);
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
const [facing, setFacing] = useState<CameraType>("front");
|
const [facing, setFacing] = useState<CameraType>('front');
|
||||||
const startedAtRef = useRef<number | null>(null);
|
const startedAtRef = useRef<number | null>(null);
|
||||||
const cancelledRef = useRef(false);
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export function VideoRecordingOverlay({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelledRef.current = true;
|
cancelledRef.current = true;
|
||||||
void releaseRecordingAudioSession().catch((err) =>
|
void releaseRecordingAudioSession().catch((err) =>
|
||||||
logError(err, { scope: "compose.video.exit" }),
|
logError(err, { scope: 'compose.video.exit' }),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -44,7 +44,7 @@ export function VideoRecordingOverlay({
|
|||||||
try {
|
try {
|
||||||
await acquireRecordingAudioSession();
|
await acquireRecordingAudioSession();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "compose.video.audioSession" });
|
logError(err, { scope: 'compose.video.audioSession' });
|
||||||
onCancel();
|
onCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -56,16 +56,16 @@ export function VideoRecordingOverlay({
|
|||||||
try {
|
try {
|
||||||
result = await cam.recordAsync({
|
result = await cam.recordAsync({
|
||||||
maxDuration: MAX_DURATION_S,
|
maxDuration: MAX_DURATION_S,
|
||||||
...(Platform.OS === "ios" ? { codec: "hvc1" as const } : {}),
|
...(Platform.OS === 'ios' ? { codec: 'hvc1' as const } : {}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelledRef.current) return;
|
if (cancelledRef.current) return;
|
||||||
logError(err, { scope: "compose.video.recordAsync" });
|
logError(err, { scope: 'compose.video.recordAsync' });
|
||||||
onCancel();
|
onCancel();
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
void releaseRecordingAudioSession().catch((err) =>
|
void releaseRecordingAudioSession().catch((err) =>
|
||||||
logError(err, { scope: "compose.video.release" }),
|
logError(err, { scope: 'compose.video.release' }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (cancelledRef.current) return;
|
if (cancelledRef.current) return;
|
||||||
@@ -118,9 +118,7 @@ export function VideoRecordingOverlay({
|
|||||||
{!recording && cameraReady ? (
|
{!recording && cameraReady ? (
|
||||||
<View className="absolute top-0 right-0 pt-14 pr-5">
|
<View className="absolute top-0 right-0 pt-14 pr-5">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() =>
|
onPress={() => setFacing((f) => (f === 'front' ? 'back' : 'front'))}
|
||||||
setFacing((f) => (f === "front" ? "back" : "front"))
|
|
||||||
}
|
|
||||||
accessibilityLabel="Flip camera"
|
accessibilityLabel="Flip camera"
|
||||||
className="h-11 w-11 items-center justify-center rounded-full bg-white/15"
|
className="h-11 w-11 items-center justify-center rounded-full bg-white/15"
|
||||||
>
|
>
|
||||||
@@ -137,7 +135,7 @@ export function VideoRecordingOverlay({
|
|||||||
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
|
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
|
||||||
<View className="h-2 w-2 rounded-full bg-white" />
|
<View className="h-2 w-2 rounded-full bg-white" />
|
||||||
<Text className="text-white text-xs font-semibold tracking-wide">
|
<Text className="text-white text-xs font-semibold tracking-wide">
|
||||||
REC · {elapsedSec.toString().padStart(2, "0")}s
|
REC · {elapsedSec.toString().padStart(2, '0')}s
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -166,8 +164,8 @@ export function VideoRecordingOverlay({
|
|||||||
accessibilityLabel="Start recording"
|
accessibilityLabel="Start recording"
|
||||||
className={
|
className={
|
||||||
cameraReady
|
cameraReady
|
||||||
? "h-20 w-20 items-center justify-center rounded-full bg-white"
|
? 'h-20 w-20 items-center justify-center rounded-full bg-white'
|
||||||
: "h-20 w-20 items-center justify-center rounded-full bg-white/40"
|
: 'h-20 w-20 items-center justify-center rounded-full bg-white/40'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="h-16 w-16 rounded-full bg-red-500" />
|
<View className="h-16 w-16 rounded-full bg-red-500" />
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from 'react';
|
||||||
import {
|
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
||||||
Alert,
|
import type { Human } from '@/api/types';
|
||||||
Dimensions,
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
Pressable,
|
import { StatusBar } from 'expo-status-bar';
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import type { Human } from "@/api/types";
|
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
|
||||||
import { StatusBar } from "expo-status-bar";
|
|
||||||
import {
|
import {
|
||||||
AudioSession,
|
AudioSession,
|
||||||
LiveKitRoom,
|
LiveKitRoom,
|
||||||
@@ -17,14 +11,14 @@ import {
|
|||||||
useLocalParticipant,
|
useLocalParticipant,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
useTracks,
|
useTracks,
|
||||||
} from "@livekit/react-native";
|
} from '@livekit/react-native';
|
||||||
import type { TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
|
||||||
import { Track } from "livekit-client";
|
import { Track } from 'livekit-client';
|
||||||
import { Mic, MicOff, PhoneOff, Video, VideoOff } from "lucide-react-native";
|
import { Mic, MicOff, PhoneOff, Video, VideoOff } from 'lucide-react-native';
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile huddle screen — LiveKit room with a tile grid, basic mic/camera
|
* Mobile huddle screen — LiveKit room with a tile grid, basic mic/camera
|
||||||
@@ -35,7 +29,7 @@ import type { RootStackScreenProps } from "@/navigation/types";
|
|||||||
export function HuddleScreen({
|
export function HuddleScreen({
|
||||||
route,
|
route,
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<"Huddle">) {
|
}: RootStackScreenProps<'Huddle'>) {
|
||||||
const { token, serverUrl, streamName, networkId } = route.params;
|
const { token, serverUrl, streamName, networkId } = route.params;
|
||||||
|
|
||||||
// iOS in particular requires us to bracket the room session with
|
// iOS in particular requires us to bracket the room session with
|
||||||
@@ -66,10 +60,10 @@ export function HuddleScreen({
|
|||||||
connect={true}
|
connect={true}
|
||||||
audio={true}
|
audio={true}
|
||||||
video={false}
|
video={false}
|
||||||
options={{ adaptiveStream: { pixelDensity: "screen" } }}
|
options={{ adaptiveStream: { pixelDensity: 'screen' } }}
|
||||||
onDisconnected={leave}
|
onDisconnected={leave}
|
||||||
onError={(err) => {
|
onError={(err) => {
|
||||||
Alert.alert("Huddle error", err.message ?? "Failed to connect.");
|
Alert.alert('Huddle error', err.message ?? 'Failed to connect.');
|
||||||
leave();
|
leave();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -119,15 +113,18 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
|
|||||||
}, [room, onLeave]);
|
}, [room, onLeave]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1" edges={["top", "bottom"]}>
|
<SafeAreaView className="flex-1" edges={['top', 'bottom']}>
|
||||||
<View className="flex-row items-center justify-between px-4 pt-2 pb-3">
|
<View className="flex-row items-center justify-between px-4 pt-2 pb-3">
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="text-white text-base font-semibold" numberOfLines={1}>
|
<Text
|
||||||
|
className="text-white text-base font-semibold"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{streamName}
|
{streamName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-white/60 text-xs mt-0.5">
|
<Text className="text-white/60 text-xs mt-0.5">
|
||||||
{tracks.length === 1
|
{tracks.length === 1
|
||||||
? "1 participant"
|
? '1 participant'
|
||||||
: `${tracks.length} participants`}
|
: `${tracks.length} participants`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -139,7 +136,7 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
|
|||||||
|
|
||||||
<View className="flex-row items-center justify-center gap-4 px-4 py-4">
|
<View className="flex-row items-center justify-center gap-4 px-4 py-4">
|
||||||
<ControlButton
|
<ControlButton
|
||||||
label={isMicrophoneEnabled ? "Mute" : "Unmute"}
|
label={isMicrophoneEnabled ? 'Mute' : 'Unmute'}
|
||||||
active={isMicrophoneEnabled}
|
active={isMicrophoneEnabled}
|
||||||
onPress={toggleMic}
|
onPress={toggleMic}
|
||||||
icon={
|
icon={
|
||||||
@@ -151,7 +148,7 @@ function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
label={isCameraEnabled ? "Stop video" : "Start video"}
|
label={isCameraEnabled ? 'Stop video' : 'Start video'}
|
||||||
active={isCameraEnabled}
|
active={isCameraEnabled}
|
||||||
onPress={toggleCamera}
|
onPress={toggleCamera}
|
||||||
icon={
|
icon={
|
||||||
@@ -182,7 +179,7 @@ function TileGrid({ tiles, humans }: TileGridProps) {
|
|||||||
// Compute a square-ish grid: 1 → 1col, 2 → 1col (stacked), 3-4 → 2col,
|
// Compute a square-ish grid: 1 → 1col, 2 → 1col (stacked), 3-4 → 2col,
|
||||||
// 5+ → 2col with scroll. Keeps each tile big enough on a phone screen.
|
// 5+ → 2col with scroll. Keeps each tile big enough on a phone screen.
|
||||||
const columns = tiles.length <= 1 ? 1 : 2;
|
const columns = tiles.length <= 1 ? 1 : 2;
|
||||||
const { width, height } = Dimensions.get("window");
|
const { width, height } = Dimensions.get('window');
|
||||||
const rows = Math.max(1, Math.ceil(tiles.length / columns));
|
const rows = Math.max(1, Math.ceil(tiles.length / columns));
|
||||||
const tileWidth = (width - 16) / columns - 8;
|
const tileWidth = (width - 16) / columns - 8;
|
||||||
// Subtract approx chrome height (header + control bar ≈ 200px). This is a
|
// Subtract approx chrome height (header + control bar ≈ 200px). This is a
|
||||||
@@ -229,16 +226,12 @@ function Tile({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 overflow-hidden rounded-2xl bg-neutral-900",
|
'flex-1 overflow-hidden rounded-2xl bg-neutral-900',
|
||||||
isSpeaking && "border-2 border-emerald-400",
|
isSpeaking && 'border-2 border-emerald-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasVideo ? (
|
{hasVideo ? (
|
||||||
<VideoTrack
|
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
|
||||||
trackRef={tile}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
objectFit="cover"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
|
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
|
||||||
@@ -261,7 +254,9 @@ function Tile({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function trackKey(tile: TrackReferenceOrPlaceholder): string {
|
function trackKey(tile: TrackReferenceOrPlaceholder): string {
|
||||||
const sid = isTrackReference(tile) ? tile.publication.trackSid : "placeholder";
|
const sid = isTrackReference(tile)
|
||||||
|
? tile.publication.trackSid
|
||||||
|
: 'placeholder';
|
||||||
return `${tile.participant.identity}:${tile.source}:${sid}`;
|
return `${tile.participant.identity}:${tile.source}:${sid}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +265,7 @@ interface ControlButtonProps {
|
|||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
tone?: "default" | "danger";
|
tone?: 'default' | 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ControlButton({
|
function ControlButton({
|
||||||
@@ -278,7 +273,7 @@ function ControlButton({
|
|||||||
label,
|
label,
|
||||||
onPress,
|
onPress,
|
||||||
active = false,
|
active = false,
|
||||||
tone = "default",
|
tone = 'default',
|
||||||
}: ControlButtonProps) {
|
}: ControlButtonProps) {
|
||||||
// Used purely for the visual state — destructive tone always wins so
|
// Used purely for the visual state — destructive tone always wins so
|
||||||
// "Leave" is unmistakable regardless of toggle state.
|
// "Leave" is unmistakable regardless of toggle state.
|
||||||
@@ -287,16 +282,15 @@ function ControlButton({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-14 w-14 items-center justify-center rounded-full",
|
'h-14 w-14 items-center justify-center rounded-full',
|
||||||
tone === "danger"
|
tone === 'danger'
|
||||||
? "bg-red-600 active:bg-red-700"
|
? 'bg-red-600 active:bg-red-700'
|
||||||
: active
|
: active
|
||||||
? "bg-white/20 active:bg-white/30"
|
? 'bg-white/20 active:bg-white/30'
|
||||||
: "bg-white/10 active:bg-white/20",
|
: 'bg-white/10 active:bg-white/20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from 'react';
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { toast } from "sonner-native";
|
import { toast } from 'sonner-native';
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from '@/api/client';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import type { RootStackParamList } from "@/navigation/types";
|
import type { RootStackParamList } from '@/navigation/types';
|
||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) — fetch a fresh
|
* Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) — fetch a fresh
|
||||||
@@ -26,7 +26,7 @@ export function useOpenHuddle() {
|
|||||||
networkId,
|
networkId,
|
||||||
streamId,
|
streamId,
|
||||||
);
|
);
|
||||||
navigation.navigate("Huddle", {
|
navigation.navigate('Huddle', {
|
||||||
networkId,
|
networkId,
|
||||||
streamId,
|
streamId,
|
||||||
streamName,
|
streamName,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -7,15 +7,15 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
SafeAreaProvider,
|
SafeAreaProvider,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
} from "react-native-safe-area-context";
|
} from 'react-native-safe-area-context';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get("window").width;
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
|
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
|
||||||
const ANIM_MS = 220;
|
const ANIM_MS = 220;
|
||||||
|
|
||||||
@@ -26,13 +26,11 @@ interface DrawerProps {
|
|||||||
onNavigateSettings: () => void;
|
onNavigateSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Drawer({
|
export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
||||||
open,
|
// Lazy-init so each Animated.Value is created once; the setters are never
|
||||||
onClose,
|
// called — the values are mutated internally by the native driver.
|
||||||
onNavigateAccount,
|
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
|
||||||
}: DrawerProps) {
|
const [backdropOpacity] = useState(() => new Animated.Value(0));
|
||||||
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
|
||||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
@@ -55,7 +53,7 @@ export function Drawer({
|
|||||||
const signOut = useAuthStore((s) => s.signOut);
|
const signOut = useAuthStore((s) => s.signOut);
|
||||||
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||||
|
|
||||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -69,68 +67,68 @@ export function Drawer({
|
|||||||
inside reports {0,0,0,0} on the first frame and content snaps from
|
inside reports {0,0,0,0} on the first frame and content snaps from
|
||||||
the status bar down to the safe area once metrics resolve. */}
|
the status bar down to the safe area once metrics resolve. */}
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents={open ? "auto" : "none"}
|
pointerEvents={open ? 'auto' : 'none'}
|
||||||
style={{ opacity: backdropOpacity }}
|
style={{ opacity: backdropOpacity }}
|
||||||
className="absolute inset-0 bg-black"
|
className="absolute inset-0 bg-black"
|
||||||
>
|
>
|
||||||
<Pressable className="flex-1" onPress={onClose} />
|
<Pressable className="flex-1" onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
width: DRAWER_WIDTH,
|
width: DRAWER_WIDTH,
|
||||||
transform: [{ translateX }],
|
transform: [{ translateX }],
|
||||||
}}
|
}}
|
||||||
className="absolute left-0 top-0 bottom-0 bg-sidebar"
|
className="absolute left-0 top-0 bottom-0 bg-sidebar"
|
||||||
>
|
>
|
||||||
<SafeAreaView edges={["top", "bottom", "left"]} className="flex-1">
|
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
|
||||||
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
||||||
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
||||||
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
||||||
{initials}
|
{initials}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className="text-sidebar-foreground text-base font-medium"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{user?.email_prefix ?? ''}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{user?.email ?? ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
|
||||||
<Text
|
<View className="flex-1 py-2">
|
||||||
className="text-sidebar-foreground text-base font-medium"
|
<DrawerRow
|
||||||
numberOfLines={1}
|
label="Account"
|
||||||
>
|
onPress={() => {
|
||||||
{user?.email_prefix ?? ""}
|
onClose();
|
||||||
</Text>
|
onNavigateAccount();
|
||||||
<Text
|
}}
|
||||||
className="text-muted-foreground text-xs"
|
/>
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{user?.email ?? ""}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-1 py-2">
|
<View className="border-sidebar-border border-t px-2 py-2">
|
||||||
<DrawerRow
|
<DrawerRow
|
||||||
label="Account"
|
label={isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||||
onPress={() => {
|
disabled={isSigningOut}
|
||||||
onClose();
|
onPress={() => {
|
||||||
onNavigateAccount();
|
void signOut();
|
||||||
}}
|
}}
|
||||||
/>
|
tone="destructive"
|
||||||
</View>
|
/>
|
||||||
|
</View>
|
||||||
<View className="border-sidebar-border border-t px-2 py-2">
|
</SafeAreaView>
|
||||||
<DrawerRow
|
</Animated.View>
|
||||||
label={isSigningOut ? "Signing out..." : "Sign out"}
|
</View>
|
||||||
disabled={isSigningOut}
|
|
||||||
onPress={() => {
|
|
||||||
void signOut();
|
|
||||||
}}
|
|
||||||
tone="destructive"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -140,12 +138,12 @@ function DrawerRow({
|
|||||||
label,
|
label,
|
||||||
onPress,
|
onPress,
|
||||||
disabled,
|
disabled,
|
||||||
tone = "default",
|
tone = 'default',
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
tone?: "default" | "destructive";
|
tone?: 'default' | 'destructive';
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -155,10 +153,10 @@ function DrawerRow({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`text-base font-medium ${
|
className={`text-base font-medium ${
|
||||||
tone === "destructive"
|
tone === 'destructive'
|
||||||
? "text-destructive"
|
? 'text-destructive'
|
||||||
: "text-sidebar-foreground"
|
: 'text-sidebar-foreground'
|
||||||
} ${disabled ? "opacity-50" : ""}`}
|
} ${disabled ? 'opacity-50' : ''}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
@@ -6,24 +6,24 @@ import {
|
|||||||
RefreshControl,
|
RefreshControl,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from 'react-native';
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import type { Network } from "@/api/types";
|
import type { Network } from '@/api/types';
|
||||||
import { useNetworks } from "@/hooks/use-networks";
|
import { useNetworks } from '@/hooks/use-networks';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
import { FlowyLogo } from "@/components/FlowyLogo";
|
import { FlowyLogo } from '@/components/FlowyLogo';
|
||||||
import { ListSeparator } from "@/components/ListSeparator";
|
import { ListSeparator } from '@/components/ListSeparator';
|
||||||
import { Drawer } from "./Drawer";
|
import { Drawer } from './Drawer';
|
||||||
|
|
||||||
export function NetworkListScreen({
|
export function NetworkListScreen({
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<"NetworkList">) {
|
}: RootStackScreenProps<'NetworkList'>) {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const { data, isLoading, refetch, error } = useNetworks();
|
const { data, isLoading, refetch, error } = useNetworks();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
||||||
|
|
||||||
// Local refreshing state — driving RefreshControl from react-query's
|
// Local refreshing state — driving RefreshControl from react-query's
|
||||||
// isRefetching can leave the native spinner visually stuck after the
|
// isRefetching can leave the native spinner visually stuck after the
|
||||||
@@ -39,7 +39,7 @@ export function NetworkListScreen({
|
|||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setDrawerOpen(true)}
|
onPress={() => setDrawerOpen(true)}
|
||||||
@@ -81,7 +81,7 @@ export function NetworkListScreen({
|
|||||||
<NetworkCard
|
<NetworkCard
|
||||||
network={item}
|
network={item}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigation.navigate("StreamList", { networkId: item.id })
|
navigation.navigate('StreamList', { networkId: item.id })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -91,8 +91,8 @@ export function NetworkListScreen({
|
|||||||
<Drawer
|
<Drawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
onNavigateAccount={() => navigation.navigate("Account")}
|
onNavigateAccount={() => navigation.navigate('Account')}
|
||||||
onNavigateSettings={() => navigation.navigate("Settings")}
|
onNavigateSettings={() => navigation.navigate('Settings')}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -115,8 +115,8 @@ function NetworkCard({
|
|||||||
{network.name}
|
{network.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground text-sm">
|
<Text className="text-muted-foreground text-sm">
|
||||||
{network.humans.length}{" "}
|
{network.humans.length}{' '}
|
||||||
{network.humans.length === 1 ? "member" : "members"}
|
{network.humans.length === 1 ? 'member' : 'members'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-muted-foreground text-xl">›</Text>
|
<Text className="text-muted-foreground text-xl">›</Text>
|
||||||
@@ -128,7 +128,7 @@ function EmptyState() {
|
|||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center px-6">
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
<Text className="text-foreground text-lg font-medium text-center">
|
<Text className="text-foreground text-lg font-medium text-center">
|
||||||
You aren't in any networks yet.
|
You aren’t in any networks yet.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground mt-2 text-center">
|
<Text className="text-muted-foreground mt-2 text-center">
|
||||||
Ask a friend for an invite, or create one on desktop.
|
Ask a friend for an invite, or create one on desktop.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) {
|
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||||
<Text className="text-foreground text-2xl">‹</Text>
|
<Text className="text-foreground text-2xl">‹</Text>
|
||||||
@@ -19,7 +19,7 @@ export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="px-6 py-6 gap-4">
|
<View className="px-6 py-6 gap-4">
|
||||||
<Field label="Email" value={user?.email ?? "—"} />
|
<Field label="Email" value={user?.email ?? '—'} />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
export function SettingsScreen({
|
export function SettingsScreen({
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<"Settings">) {
|
}: RootStackScreenProps<'Settings'>) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||||
<Text className="text-foreground text-2xl">‹</Text>
|
<Text className="text-foreground text-2xl">‹</Text>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native';
|
||||||
import { Trash2 } from "lucide-react-native";
|
import { Trash2 } from 'lucide-react-native';
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from '@/api/types';
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
|
||||||
// How long to linger on a tombstone before auto-advancing. Same cadence as
|
// How long to linger on a tombstone before auto-advancing. Same cadence as
|
||||||
// desktop — a beat long enough to read "this was deleted," not so long it
|
// desktop — a beat long enough to read "this was deleted," not so long it
|
||||||
@@ -25,7 +25,9 @@ export function DeletedParticleView({
|
|||||||
}: DeletedParticleViewProps) {
|
}: DeletedParticleViewProps) {
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
const deleterId =
|
const deleterId =
|
||||||
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
'deleted_by_human_id' in particle
|
||||||
|
? particle.deleted_by_human_id
|
||||||
|
: undefined;
|
||||||
const deleter = deleterId
|
const deleter = deleterId
|
||||||
? resolveHumanDisplay(deleterId, network?.humans)
|
? resolveHumanDisplay(deleterId, network?.humans)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from 'react';
|
||||||
import { Pressable, Text, TextInput, View } from "react-native";
|
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||||
import { toast } from "sonner-native";
|
import { toast } from 'sonner-native';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import { editTextParticleContent } from "@/lib/firestore-particles";
|
import { editTextParticleContent } from '@/lib/firestore-particles';
|
||||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { BottomSheet } from "@/components/BottomSheet";
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
|
|
||||||
interface EditParticleSheetProps {
|
interface EditParticleSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,17 +25,20 @@ export function EditParticleSheet({
|
|||||||
particleId,
|
particleId,
|
||||||
currentContent,
|
currentContent,
|
||||||
}: EditParticleSheetProps) {
|
}: EditParticleSheetProps) {
|
||||||
useSuspendPlayback(open, "edit-particle");
|
useSuspendPlayback(open, 'edit-particle');
|
||||||
|
|
||||||
const [content, setContent] = useState(currentContent);
|
const [content, setContent] = useState(currentContent);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Reset the editor each time the sheet opens fresh.
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setContent(currentContent);
|
setContent(currentContent);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [open, currentContent]);
|
}
|
||||||
|
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
const canSave = !saving && trimmed.length > 0 && trimmed !== currentContent;
|
const canSave = !saving && trimmed.length > 0 && trimmed !== currentContent;
|
||||||
@@ -65,11 +68,11 @@ export function EditParticleSheet({
|
|||||||
<Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}>
|
<Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}>
|
||||||
<Text
|
<Text
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-base font-semibold",
|
'text-base font-semibold',
|
||||||
canSave ? "text-white" : "text-white/30",
|
canSave ? 'text-white' : 'text-white/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Save"}
|
{saving ? 'Saving...' : 'Save'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native';
|
||||||
import {
|
import {
|
||||||
FileIcon,
|
FileIcon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react-native";
|
} from 'lucide-react-native';
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from '@/api/types';
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
|
||||||
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
||||||
quest: { icon: ScrollText, label: "Quest" },
|
quest: { icon: ScrollText, label: 'Quest' },
|
||||||
paper: { icon: BookOpen, label: "Paper" },
|
paper: { icon: BookOpen, label: 'Paper' },
|
||||||
file: { icon: FileIcon, label: "File" },
|
file: { icon: FileIcon, label: 'File' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLACEHOLDER_DURATION_MS = 5000;
|
const PLACEHOLDER_DURATION_MS = 5000;
|
||||||
@@ -44,13 +44,13 @@ export function FallbackParticleView({
|
|||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const title = (() => {
|
const title = (() => {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case "quest":
|
case 'quest':
|
||||||
return particle.properties.title;
|
return particle.properties.title;
|
||||||
case "paper":
|
case 'paper':
|
||||||
return particle.properties.title;
|
return particle.properties.title;
|
||||||
case "file":
|
case 'file':
|
||||||
return particle.properties.filename;
|
return particle.properties.filename;
|
||||||
case "folder":
|
case 'folder':
|
||||||
return particle.properties.name;
|
return particle.properties.name;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import { ActivityIndicator, Text, View } from "react-native";
|
import { ActivityIndicator, Text, View } from 'react-native';
|
||||||
import { Mic, Video as VideoIcon } from "lucide-react-native";
|
import { Mic, Video as VideoIcon } from 'lucide-react-native';
|
||||||
import { useEventListener } from "expo";
|
import { useEventListener } from 'expo';
|
||||||
import { useVideoPlayer, VideoView, type VideoPlayerStatus } from "expo-video";
|
import { useVideoPlayer, VideoView, type VideoPlayerStatus } from 'expo-video';
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from '@/api/types';
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from '@/api/client';
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from '@/lib/errors';
|
||||||
import { useEvent } from "@/hooks/use-event";
|
import { useEvent } from '@/hooks/use-event';
|
||||||
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
|
import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
|
||||||
import { TranscriptOverlay } from "./TranscriptOverlay";
|
import { TranscriptOverlay } from './TranscriptOverlay';
|
||||||
import { useStreamSafeArea } from "./stream-safe-area";
|
import { useStreamSafeArea } from './stream-safe-area';
|
||||||
|
|
||||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
type MediaParticle = Extract<Particle, { type: 'media' }>;
|
||||||
|
|
||||||
interface MediaParticleViewProps {
|
interface MediaParticleViewProps {
|
||||||
particle: MediaParticle;
|
particle: MediaParticle;
|
||||||
@@ -19,7 +19,7 @@ interface MediaParticleViewProps {
|
|||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress: (ratio: number) => void;
|
onProgress: (ratio: number) => void;
|
||||||
/** "cover" fills the screen (may crop); "contain" fits the whole frame. */
|
/** "cover" fills the screen (may crop); "contain" fits the whole frame. */
|
||||||
contentFit?: "cover" | "contain";
|
contentFit?: 'cover' | 'contain';
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICK_MS = 150;
|
const TICK_MS = 150;
|
||||||
@@ -42,13 +42,13 @@ export function MediaParticleView({
|
|||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
contentFit = "cover",
|
contentFit = 'cover',
|
||||||
}: MediaParticleViewProps) {
|
}: MediaParticleViewProps) {
|
||||||
const activeObjectId =
|
const activeObjectId =
|
||||||
particle.properties.transcoded_object_id ?? particle.properties.object_id;
|
particle.properties.transcoded_object_id ?? particle.properties.object_id;
|
||||||
const activeMime =
|
const activeMime =
|
||||||
particle.properties.transcoded_mime_type ?? particle.properties.mime_type;
|
particle.properties.transcoded_mime_type ?? particle.properties.mime_type;
|
||||||
const isAudio = activeMime.startsWith("audio/");
|
const isAudio = activeMime.startsWith('audio/');
|
||||||
const isPlayable = isPlayableMime(activeMime);
|
const isPlayable = isPlayableMime(activeMime);
|
||||||
|
|
||||||
// Reset progress as the active particle changes — independent of playback
|
// Reset progress as the active particle changes — independent of playback
|
||||||
@@ -89,7 +89,7 @@ function PlayableMediaView({
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress: (ratio: number) => void;
|
onProgress: (ratio: number) => void;
|
||||||
contentFit: "cover" | "contain";
|
contentFit: 'cover' | 'contain';
|
||||||
}) {
|
}) {
|
||||||
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
||||||
const [resolveError, setResolveError] = useState<Error | null>(null);
|
const [resolveError, setResolveError] = useState<Error | null>(null);
|
||||||
@@ -101,24 +101,26 @@ function PlayableMediaView({
|
|||||||
// resolves a new active object id.
|
// resolves a new active object id.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSourceUri(null);
|
|
||||||
setResolveError(null);
|
|
||||||
setCurrentTime(0);
|
|
||||||
apiClient
|
apiClient
|
||||||
.getParticleDownloadUrl(activeObjectId)
|
.getParticleDownloadUrl(activeObjectId)
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
if (!cancelled) setSourceUri(url);
|
if (!cancelled) setSourceUri(url);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
logError(err, { scope: "media.download-url" });
|
logError(err, { scope: 'media.download-url' });
|
||||||
if (!cancelled) setResolveError(err as Error);
|
if (!cancelled) setResolveError(err as Error);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
// Reset on teardown so the next object starts from a clean slate while
|
||||||
|
// its signed URL resolves, rather than flashing the previous video.
|
||||||
|
setSourceUri(null);
|
||||||
|
setResolveError(null);
|
||||||
|
setCurrentTime(0);
|
||||||
};
|
};
|
||||||
}, [activeObjectId, particle.id]);
|
}, [activeObjectId, particle.id]);
|
||||||
|
|
||||||
const player = useVideoPlayer(sourceUri ?? "", (p) => {
|
const player = useVideoPlayer(sourceUri ?? '', (p) => {
|
||||||
p.loop = false;
|
p.loop = false;
|
||||||
p.muted = false;
|
p.muted = false;
|
||||||
p.timeUpdateEventInterval = 0.15;
|
p.timeUpdateEventInterval = 0.15;
|
||||||
@@ -126,7 +128,7 @@ function PlayableMediaView({
|
|||||||
// the player blocks expo-camera from acquiring the session for video
|
// the player blocks expo-camera from acquiring the session for video
|
||||||
// recording (audio works because expo-audio deactivates other sessions
|
// recording (audio works because expo-audio deactivates other sessions
|
||||||
// natively before claiming the session).
|
// natively before claiming the session).
|
||||||
p.audioMixingMode = "mixWithOthers";
|
p.audioMixingMode = 'mixWithOthers';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drive play/pause from the suspender store. The player itself is forgiving
|
// Drive play/pause from the suspender store. The player itself is forgiving
|
||||||
@@ -142,8 +144,8 @@ function PlayableMediaView({
|
|||||||
|
|
||||||
// End-of-clip → advance. We listen to status flips rather than computing
|
// End-of-clip → advance. We listen to status flips rather than computing
|
||||||
// duration ratios because video duration may be 0 for the first frame or two.
|
// duration ratios because video duration may be 0 for the first frame or two.
|
||||||
useEventListener(player, "statusChange", ({ status }) => {
|
useEventListener(player, 'statusChange', ({ status }) => {
|
||||||
if (status === ("idle" satisfies VideoPlayerStatus)) {
|
if (status === ('idle' satisfies VideoPlayerStatus)) {
|
||||||
// ignored — happens during source swap
|
// ignored — happens during source swap
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -151,7 +153,7 @@ function PlayableMediaView({
|
|||||||
// Drive caption highlighting from the player's own timeUpdate cadence
|
// Drive caption highlighting from the player's own timeUpdate cadence
|
||||||
// (timeUpdateEventInterval = 0.15s above). Pausing halts the events, which
|
// (timeUpdateEventInterval = 0.15s above). Pausing halts the events, which
|
||||||
// naturally freezes the active word/sentence — no extra plumbing needed.
|
// naturally freezes the active word/sentence — no extra plumbing needed.
|
||||||
useEventListener(player, "timeUpdate", ({ currentTime: t }) => {
|
useEventListener(player, 'timeUpdate', ({ currentTime: t }) => {
|
||||||
setCurrentTime(t);
|
setCurrentTime(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +192,7 @@ function PlayableMediaView({
|
|||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center px-8">
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
<Text className="text-white/80 text-base text-center">
|
<Text className="text-white/80 text-base text-center">
|
||||||
Couldn't load this {isAudio ? "voice message" : "video"}.
|
Couldn’t load this {isAudio ? 'voice message' : 'video'}.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-white/50 text-sm text-center mt-2">
|
<Text className="text-white/50 text-sm text-center mt-2">
|
||||||
Tap forward to continue.
|
Tap forward to continue.
|
||||||
@@ -284,12 +286,10 @@ function ProcessingForMobilePlaceholder({ isAudio }: { isAudio: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white mt-6 text-lg font-medium">
|
<Text className="text-white mt-6 text-lg font-medium">
|
||||||
{isAudio ? "Voice message" : "Video message"}
|
{isAudio ? 'Voice message' : 'Video message'}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center mt-3">
|
<View className="flex-row items-center mt-3">
|
||||||
<Text className="text-white/60 ml-3 text-sm">
|
<Text className="text-white/60 ml-3 text-sm">View on desktop</Text>
|
||||||
View on desktop
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white/40 mt-2 text-xs text-center">
|
<Text className="text-white/40 mt-2 text-xs text-center">
|
||||||
Please view this on desktop only.
|
Please view this on desktop only.
|
||||||
@@ -302,11 +302,11 @@ function isPlayableMime(mime: string): boolean {
|
|||||||
// expo-video uses AVPlayer on iOS — reliable for h264 in mp4 / mov / m4a.
|
// expo-video uses AVPlayer on iOS — reliable for h264 in mp4 / mov / m4a.
|
||||||
// WebM/VP9 (the legacy desktop format) is not decodable.
|
// WebM/VP9 (the legacy desktop format) is not decodable.
|
||||||
return (
|
return (
|
||||||
mime === "video/mp4" ||
|
mime === 'video/mp4' ||
|
||||||
mime === "video/quicktime" ||
|
mime === 'video/quicktime' ||
|
||||||
mime === "audio/mp4" ||
|
mime === 'audio/mp4' ||
|
||||||
mime === "audio/aac" ||
|
mime === 'audio/aac' ||
|
||||||
mime === "audio/x-m4a" ||
|
mime === 'audio/x-m4a' ||
|
||||||
mime === "audio/mpeg"
|
mime === 'audio/mpeg'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -7,7 +7,7 @@ import Animated, {
|
|||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
interface PlaybackPageIndicatorProps {
|
interface PlaybackPageIndicatorProps {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -78,7 +78,7 @@ export function PlaybackPageIndicator({
|
|||||||
{paginated && current >= 0 && (
|
{paginated && current >= 0 && (
|
||||||
<Text
|
<Text
|
||||||
className="pt-1 text-center font-medium text-white/40"
|
className="pt-1 text-center font-medium text-white/40"
|
||||||
style={{ fontSize: 10, fontVariant: ["tabular-nums"] }}
|
style={{ fontSize: 10, fontVariant: ['tabular-nums'] }}
|
||||||
>
|
>
|
||||||
{current + 1} / {total}
|
{current + 1} / {total}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -95,7 +95,11 @@ function GhostStub({ visible }: { visible: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="overflow-hidden rounded-full bg-white/15"
|
className="overflow-hidden rounded-full bg-white/15"
|
||||||
style={{ width: STUB_WIDTH, height: SEGMENT_HEIGHT, alignSelf: "flex-end" }}
|
style={{
|
||||||
|
width: STUB_WIDTH,
|
||||||
|
height: SEGMENT_HEIGHT,
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,7 +121,10 @@ function Segment({ isActive, isPast, progress, paused }: SegmentProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPast) {
|
if (isPast) {
|
||||||
cancelAnimation(fill);
|
cancelAnimation(fill);
|
||||||
fill.value = withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) });
|
fill.value = withTiming(1, {
|
||||||
|
duration: 120,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -8,18 +8,15 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
SafeAreaProvider,
|
SafeAreaProvider,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
} from "react-native-safe-area-context";
|
} from 'react-native-safe-area-context';
|
||||||
import { Send, X } from "lucide-react-native";
|
import { Send, X } from 'lucide-react-native';
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from 'expo-haptics';
|
||||||
import {
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
Gesture,
|
|
||||||
GestureDetector,
|
|
||||||
} from "react-native-gesture-handler";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
Extrapolation,
|
Extrapolation,
|
||||||
@@ -29,16 +26,15 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withSpring,
|
withSpring,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from 'react-native-reanimated';
|
||||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||||
import type { Human } from "@/api/types";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { sanitizeReactionText } from '@/lib/firestore-particles';
|
||||||
import { sanitizeReactionText } from "@/lib/firestore-particles";
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from "@/lib/utils";
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
|
||||||
|
|
||||||
const TEXT_REACTION_MAX = 40;
|
const TEXT_REACTION_MAX = 40;
|
||||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||||
const ANIMATION_MS = 240;
|
const ANIMATION_MS = 240;
|
||||||
|
|
||||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||||
@@ -75,7 +71,7 @@ export function ReactionSheet({
|
|||||||
}: ReactionSheetProps) {
|
}: ReactionSheetProps) {
|
||||||
// Suspend playback whenever the sheet is mounted-and-open. The Modal
|
// Suspend playback whenever the sheet is mounted-and-open. The Modal
|
||||||
// controls visibility so we tie the suspender to `open` directly.
|
// controls visibility so we tie the suspender to `open` directly.
|
||||||
useSuspendPlayback(open, "reactions-sheet");
|
useSuspendPlayback(open, 'reactions-sheet');
|
||||||
|
|
||||||
// We mount the modal slightly delayed from `open` so the slide-up animation
|
// We mount the modal slightly delayed from `open` so the slide-up animation
|
||||||
// has its starting position rendered. Using local `mounted` state lets us
|
// has its starting position rendered. Using local `mounted` state lets us
|
||||||
@@ -85,8 +81,7 @@ export function ReactionSheet({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true);
|
// Schedule the slide-in after the modal mounts (handled at render time).
|
||||||
// Schedule animation after the modal mounts
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
translateY.value = withSpring(0, {
|
translateY.value = withSpring(0, {
|
||||||
damping: 24,
|
damping: 24,
|
||||||
@@ -115,14 +110,18 @@ export function ReactionSheet({
|
|||||||
.activeOffsetY(10)
|
.activeOffsetY(10)
|
||||||
.failOffsetX([-25, 25])
|
.failOffsetX([-25, 25])
|
||||||
.onUpdate((e) => {
|
.onUpdate((e) => {
|
||||||
"worklet";
|
'worklet';
|
||||||
|
// Reanimated shared values are mutated by design; react-hooks/immutability
|
||||||
|
// doesn't model worklets, so the mutations below are flagged spuriously.
|
||||||
|
// eslint-disable-next-line react-hooks/immutability
|
||||||
translateY.value = Math.max(0, e.translationY);
|
translateY.value = Math.max(0, e.translationY);
|
||||||
})
|
})
|
||||||
.onEnd((e) => {
|
.onEnd((e) => {
|
||||||
"worklet";
|
'worklet';
|
||||||
if (e.translationY > 120 || e.velocityY > 800) {
|
if (e.translationY > 120 || e.velocityY > 800) {
|
||||||
runOnJS(dismiss)();
|
runOnJS(dismiss)();
|
||||||
} else {
|
} else {
|
||||||
|
// eslint-disable-next-line react-hooks/immutability
|
||||||
translateY.value = withSpring(0, {
|
translateY.value = withSpring(0, {
|
||||||
damping: 24,
|
damping: 24,
|
||||||
stiffness: 260,
|
stiffness: 260,
|
||||||
@@ -152,25 +151,31 @@ export function ReactionSheet({
|
|||||||
const activeTextKeys = useMemo(
|
const activeTextKeys = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.keys(reactions ?? {}).filter(
|
Object.keys(reactions ?? {}).filter(
|
||||||
(k) =>
|
(k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
||||||
!EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
|
||||||
),
|
),
|
||||||
[reactions],
|
[reactions],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Text reaction input ---
|
// --- Text reaction input ---
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
// Mount on open (staying mounted through the exit animation) and clear the
|
||||||
if (open) setText("");
|
// input. Render-time adjustment avoids a setState-in-effect cascade.
|
||||||
}, [open]);
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
|
if (open) {
|
||||||
|
setMounted(true);
|
||||||
|
setText('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submitText = () => {
|
const submitText = () => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
void Haptics.selectionAsync();
|
void Haptics.selectionAsync();
|
||||||
onToggle(trimmed.slice(0, TEXT_REACTION_MAX));
|
onToggle(trimmed.slice(0, TEXT_REACTION_MAX));
|
||||||
setText("");
|
setText('');
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,180 +195,184 @@ export function ReactionSheet({
|
|||||||
onRequestClose={dismiss}
|
onRequestClose={dismiss}
|
||||||
>
|
>
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents={open ? "auto" : "none"}
|
pointerEvents={open ? 'auto' : 'none'}
|
||||||
style={[
|
style={[
|
||||||
{ position: "absolute", inset: 0, backgroundColor: "black" },
|
{ position: 'absolute', inset: 0, backgroundColor: 'black' },
|
||||||
backdropStyle,
|
backdropStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable style={{ flex: 1 }} onPress={dismiss} />
|
<Pressable style={{ flex: 1 }} onPress={dismiss} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
style={{ flex: 1, justifyContent: "flex-end" }}
|
style={{ flex: 1, justifyContent: 'flex-end' }}
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
>
|
>
|
||||||
<GestureDetector gesture={sheetPan}>
|
<GestureDetector gesture={sheetPan}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
backgroundColor: "#1c1c1c",
|
backgroundColor: '#1c1c1c',
|
||||||
borderTopLeftRadius: 22,
|
borderTopLeftRadius: 22,
|
||||||
borderTopRightRadius: 22,
|
borderTopRightRadius: 22,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
sheetStyle,
|
sheetStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SafeAreaView edges={["bottom"]}>
|
<SafeAreaView edges={['bottom']}>
|
||||||
<View className="px-5 pt-3 pb-2 items-center">
|
<View className="px-5 pt-3 pb-2 items-center">
|
||||||
{/* Drag handle — affords downward dismissal at a glance. */}
|
{/* Drag handle — affords downward dismissal at a glance. */}
|
||||||
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
||||||
|
|
||||||
<View className="flex-row items-center justify-between w-full">
|
<View className="flex-row items-center justify-between w-full">
|
||||||
<Text className="text-white text-base font-semibold">
|
<Text className="text-white text-base font-semibold">
|
||||||
React
|
React
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={dismiss}
|
onPress={dismiss}
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
accessibilityLabel="Close reactions"
|
accessibilityLabel="Close reactions"
|
||||||
>
|
>
|
||||||
<X color="rgba(255,255,255,0.6)" size={20} />
|
<X color="rgba(255,255,255,0.6)" size={20} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Existing reactions row — tap a pill to toggle yours. */}
|
{/* Existing reactions row — tap a pill to toggle yours. */}
|
||||||
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
|
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
|
||||||
<View className="px-5 pb-3 flex-row flex-wrap gap-2">
|
<View className="px-5 pb-3 flex-row flex-wrap gap-2">
|
||||||
{activeEmojis.map((emoji) => {
|
{activeEmojis.map((emoji) => {
|
||||||
const reactors = reactions![emoji];
|
const reactors = reactions![emoji];
|
||||||
const isMine = reactors.includes(currentHumanId);
|
const isMine = reactors.includes(currentHumanId);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={emoji}
|
||||||
|
onPress={() => handleEmoji(emoji)}
|
||||||
|
className={cn(
|
||||||
|
'flex-row items-center gap-1.5 rounded-full px-3 py-1.5',
|
||||||
|
isMine
|
||||||
|
? 'bg-white/25 border border-white/40'
|
||||||
|
: 'bg-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text className="text-base">{emoji}</Text>
|
||||||
|
<Text className="text-white/85 text-xs font-semibold">
|
||||||
|
{reactors.length}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{activeTextKeys.map((key) => {
|
||||||
|
const reactors = reactions![key];
|
||||||
|
const isMine = reactors.includes(currentHumanId);
|
||||||
|
const firstReactor = resolveHumanDisplay(
|
||||||
|
reactors[0],
|
||||||
|
humans,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
onPress={() => handleEmoji(key)}
|
||||||
|
className={cn(
|
||||||
|
'flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]',
|
||||||
|
isMine
|
||||||
|
? 'bg-white/25 border border-white/40'
|
||||||
|
: 'bg-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
|
||||||
|
<Text className="text-white text-[9px] font-semibold">
|
||||||
|
{firstReactor.initials}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-white/90 text-xs"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</Text>
|
||||||
|
{reactors.length > 1 ? (
|
||||||
|
<Text className="text-white/60 text-xs font-medium">
|
||||||
|
{reactors.length}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Quick-pick emoji palette — six big tappable buttons. */}
|
||||||
|
<View className="px-3 pt-2 pb-4 flex-row justify-around">
|
||||||
|
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
|
||||||
|
const isMine =
|
||||||
|
reactions?.[emoji]?.includes(currentHumanId) ?? false;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={emoji}
|
key={emoji}
|
||||||
onPress={() => handleEmoji(emoji)}
|
onPress={() => handleEmoji(emoji)}
|
||||||
|
accessibilityLabel={`React with ${emoji}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-row items-center gap-1.5 rounded-full px-3 py-1.5",
|
'h-14 w-14 items-center justify-center rounded-full',
|
||||||
isMine
|
isMine ? 'bg-white/25' : 'bg-white/10',
|
||||||
? "bg-white/25 border border-white/40"
|
|
||||||
: "bg-white/10",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Text className="text-base">{emoji}</Text>
|
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
||||||
<Text className="text-white/85 text-xs font-semibold">
|
|
||||||
{reactors.length}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{activeTextKeys.map((key) => {
|
|
||||||
const reactors = reactions![key];
|
|
||||||
const isMine = reactors.includes(currentHumanId);
|
|
||||||
const firstReactor = resolveHumanDisplay(
|
|
||||||
reactors[0],
|
|
||||||
humans,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={key}
|
|
||||||
onPress={() => handleEmoji(key)}
|
|
||||||
className={cn(
|
|
||||||
"flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]",
|
|
||||||
isMine
|
|
||||||
? "bg-white/25 border border-white/40"
|
|
||||||
: "bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
|
|
||||||
<Text className="text-white text-[9px] font-semibold">
|
|
||||||
{firstReactor.initials}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
className="text-white/90 text-xs"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</Text>
|
|
||||||
{reactors.length > 1 ? (
|
|
||||||
<Text className="text-white/60 text-xs font-medium">
|
|
||||||
{reactors.length}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Quick-pick emoji palette — six big tappable buttons. */}
|
{/* Text reaction input — 40-char cap matches desktop. */}
|
||||||
<View className="px-3 pt-2 pb-4 flex-row justify-around">
|
<View className="px-4 pb-4 flex-row items-center gap-2">
|
||||||
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
|
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
|
||||||
const isMine =
|
<TextInput
|
||||||
reactions?.[emoji]?.includes(currentHumanId) ?? false;
|
value={text}
|
||||||
return (
|
onChangeText={(v) =>
|
||||||
<Pressable
|
setText(
|
||||||
key={emoji}
|
sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX),
|
||||||
onPress={() => handleEmoji(emoji)}
|
)
|
||||||
accessibilityLabel={`React with ${emoji}`}
|
}
|
||||||
className={cn(
|
placeholder="Send a quick reply..."
|
||||||
"h-14 w-14 items-center justify-center rounded-full",
|
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||||
isMine ? "bg-white/25" : "bg-white/10",
|
maxLength={TEXT_REACTION_MAX}
|
||||||
)}
|
autoCapitalize="none"
|
||||||
>
|
autoCorrect={false}
|
||||||
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
onSubmitEditing={submitText}
|
||||||
</Pressable>
|
returnKeyType="send"
|
||||||
);
|
className="text-white text-base"
|
||||||
})}
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<Pressable
|
||||||
{/* Text reaction input — 40-char cap matches desktop. */}
|
onPress={submitText}
|
||||||
<View className="px-4 pb-4 flex-row items-center gap-2">
|
disabled={text.trim().length === 0}
|
||||||
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
|
accessibilityLabel="Send text reaction"
|
||||||
<TextInput
|
className={cn(
|
||||||
value={text}
|
'h-11 w-11 items-center justify-center rounded-full',
|
||||||
onChangeText={(v) =>
|
text.trim().length === 0 ? 'bg-white/10' : 'bg-white',
|
||||||
setText(sanitizeReactionText(v).slice(0, TEXT_REACTION_MAX))
|
)}
|
||||||
}
|
>
|
||||||
placeholder="Send a quick reply..."
|
<Send
|
||||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
color={
|
||||||
maxLength={TEXT_REACTION_MAX}
|
text.trim().length === 0
|
||||||
autoCapitalize="none"
|
? 'rgba(255,255,255,0.3)'
|
||||||
autoCorrect={false}
|
: 'black'
|
||||||
onSubmitEditing={submitText}
|
}
|
||||||
returnKeyType="send"
|
size={18}
|
||||||
className="text-white text-base"
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
</SafeAreaView>
|
||||||
onPress={submitText}
|
</Animated.View>
|
||||||
disabled={text.trim().length === 0}
|
</GestureDetector>
|
||||||
accessibilityLabel="Send text reaction"
|
</KeyboardAvoidingView>
|
||||||
className={cn(
|
</View>
|
||||||
"h-11 w-11 items-center justify-center rounded-full",
|
|
||||||
text.trim().length === 0
|
|
||||||
? "bg-white/10"
|
|
||||||
: "bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Send
|
|
||||||
color={text.trim().length === 0 ? "rgba(255,255,255,0.3)" : "black"}
|
|
||||||
size={18}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</View>
|
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { Plus } from "lucide-react-native";
|
import { Plus } from 'lucide-react-native';
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from 'expo-haptics';
|
||||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||||
import type { Human } from "@/api/types";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||||
|
|
||||||
@@ -58,12 +57,12 @@ export function ReactionStack({
|
|||||||
key={emoji}
|
key={emoji}
|
||||||
onPress={() => handleToggle(emoji)}
|
onPress={() => handleToggle(emoji)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-row items-center gap-1 rounded-full px-2 py-1",
|
'flex-row items-center gap-1 rounded-full px-2 py-1',
|
||||||
isMine ? "bg-white/25" : "bg-black/45",
|
isMine ? 'bg-white/25' : 'bg-black/45',
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
isMine
|
isMine
|
||||||
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -84,13 +83,13 @@ export function ReactionStack({
|
|||||||
key={text}
|
key={text}
|
||||||
onPress={() => handleToggle(text)}
|
onPress={() => handleToggle(text)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5",
|
'flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5',
|
||||||
isMine ? "bg-white/25" : "bg-black/45",
|
isMine ? 'bg-white/25' : 'bg-black/45',
|
||||||
)}
|
)}
|
||||||
style={[
|
style={[
|
||||||
{ maxWidth: 200 },
|
{ maxWidth: 200 },
|
||||||
isMine
|
isMine
|
||||||
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' }
|
||||||
: null,
|
: null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -99,10 +98,7 @@ export function ReactionStack({
|
|||||||
{firstReactor.initials}
|
{firstReactor.initials}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
||||||
className="text-white/90 text-xs"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
{reactors.length > 1 ? (
|
{reactors.length > 1 ? (
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from 'react';
|
||||||
import { Pressable, Text, TextInput, View } from "react-native";
|
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||||
import { toast } from "sonner-native";
|
import { toast } from 'sonner-native';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import { updateParticleProperties } from "@/lib/firestore-particles";
|
import { updateParticleProperties } from '@/lib/firestore-particles';
|
||||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { BottomSheet } from "@/components/BottomSheet";
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
|
|
||||||
interface RenameStreamSheetProps {
|
interface RenameStreamSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -23,17 +23,20 @@ export function RenameStreamSheet({
|
|||||||
streamId,
|
streamId,
|
||||||
currentName,
|
currentName,
|
||||||
}: RenameStreamSheetProps) {
|
}: RenameStreamSheetProps) {
|
||||||
useSuspendPlayback(open, "rename-stream");
|
useSuspendPlayback(open, 'rename-stream');
|
||||||
|
|
||||||
const [name, setName] = useState(currentName);
|
const [name, setName] = useState(currentName);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Reset the field each time the sheet opens fresh.
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setName(currentName);
|
setName(currentName);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [open, currentName]);
|
}
|
||||||
|
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
const canSave = !saving && trimmed.length > 0 && trimmed !== currentName;
|
const canSave = !saving && trimmed.length > 0 && trimmed !== currentName;
|
||||||
@@ -43,7 +46,7 @@ export function RenameStreamSheet({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const docPath = toFirestoreDocPath(particlePath(networkId, [streamId]));
|
const docPath = toFirestoreDocPath(particlePath(networkId, [streamId]));
|
||||||
await updateParticleProperties<"stream">(docPath, { name: trimmed });
|
await updateParticleProperties<'stream'>(docPath, { name: trimmed });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(toUserMessage(err));
|
toast.error(toUserMessage(err));
|
||||||
@@ -58,18 +61,14 @@ export function RenameStreamSheet({
|
|||||||
<Text className="text-white/70 text-base">Cancel</Text>
|
<Text className="text-white/70 text-base">Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text className="text-white text-base font-semibold">Rename</Text>
|
<Text className="text-white text-base font-semibold">Rename</Text>
|
||||||
<Pressable
|
<Pressable onPress={handleSave} disabled={!canSave} hitSlop={12}>
|
||||||
onPress={handleSave}
|
|
||||||
disabled={!canSave}
|
|
||||||
hitSlop={12}
|
|
||||||
>
|
|
||||||
<Text
|
<Text
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-base font-semibold",
|
'text-base font-semibold',
|
||||||
canSave ? "text-white" : "text-white/30",
|
canSave ? 'text-white' : 'text-white/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Save"}
|
{saving ? 'Saving...' : 'Save'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import {
|
import {
|
||||||
CircleCheckBig,
|
CircleCheckBig,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react-native";
|
} from 'lucide-react-native';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { BottomSheet } from "@/components/BottomSheet";
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
|
|
||||||
export type StreamActionId =
|
export type StreamActionId =
|
||||||
| "toggle-status"
|
| 'toggle-status'
|
||||||
| "rename"
|
| 'rename'
|
||||||
| "members"
|
| 'members'
|
||||||
| "edit-particle"
|
| 'edit-particle'
|
||||||
| "delete-particle";
|
| 'delete-particle';
|
||||||
|
|
||||||
interface StreamActionsSheetProps {
|
interface StreamActionsSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (action: StreamActionId) => void;
|
onSelect: (action: StreamActionId) => void;
|
||||||
streamStatus: "open" | "closed";
|
streamStatus: 'open' | 'closed';
|
||||||
isCreator: boolean;
|
isCreator: boolean;
|
||||||
/** True when the *current* particle is a text particle this user authored. */
|
/** True when the *current* particle is a text particle this user authored. */
|
||||||
canEditParticle: boolean;
|
canEditParticle: boolean;
|
||||||
@@ -63,34 +63,32 @@ export function StreamActionsSheet({
|
|||||||
<View className="py-2">
|
<View className="py-2">
|
||||||
<ActionRow
|
<ActionRow
|
||||||
icon={
|
icon={
|
||||||
streamStatus === "open" ? (
|
streamStatus === 'open' ? (
|
||||||
<CircleCheckBig color="white" size={20} />
|
<CircleCheckBig color="white" size={20} />
|
||||||
) : (
|
) : (
|
||||||
<CircleDot color="#22c55e" size={20} />
|
<CircleDot color="#22c55e" size={20} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
label={
|
label={streamStatus === 'open' ? 'Close stream' : 'Reopen stream'}
|
||||||
streamStatus === "open" ? "Close stream" : "Reopen stream"
|
onPress={() => choose('toggle-status')}
|
||||||
}
|
|
||||||
onPress={() => choose("toggle-status")}
|
|
||||||
/>
|
/>
|
||||||
<ActionRow
|
<ActionRow
|
||||||
icon={<Users color="white" size={20} />}
|
icon={<Users color="white" size={20} />}
|
||||||
label="Members"
|
label="Members"
|
||||||
onPress={() => choose("members")}
|
onPress={() => choose('members')}
|
||||||
/>
|
/>
|
||||||
{isCreator ? (
|
{isCreator ? (
|
||||||
<ActionRow
|
<ActionRow
|
||||||
icon={<Pencil color="white" size={20} />}
|
icon={<Pencil color="white" size={20} />}
|
||||||
label="Rename stream"
|
label="Rename stream"
|
||||||
onPress={() => choose("rename")}
|
onPress={() => choose('rename')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{canEditParticle ? (
|
{canEditParticle ? (
|
||||||
<ActionRow
|
<ActionRow
|
||||||
icon={<Pencil color="white" size={20} />}
|
icon={<Pencil color="white" size={20} />}
|
||||||
label="Edit particle"
|
label="Edit particle"
|
||||||
onPress={() => choose("edit-particle")}
|
onPress={() => choose('edit-particle')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{canDeleteParticle ? (
|
{canDeleteParticle ? (
|
||||||
@@ -98,7 +96,7 @@ export function StreamActionsSheet({
|
|||||||
icon={<Trash2 color="#ef4444" size={20} />}
|
icon={<Trash2 color="#ef4444" size={20} />}
|
||||||
label="Delete particle"
|
label="Delete particle"
|
||||||
tone="destructive"
|
tone="destructive"
|
||||||
onPress={() => choose("delete-particle")}
|
onPress={() => choose('delete-particle')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -119,12 +117,12 @@ function ActionRow({
|
|||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
onPress,
|
onPress,
|
||||||
tone = "default",
|
tone = 'default',
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
tone?: "default" | "destructive";
|
tone?: 'default' | 'destructive';
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -134,8 +132,8 @@ function ActionRow({
|
|||||||
<View className="w-6 items-center">{icon}</View>
|
<View className="w-6 items-center">{icon}</View>
|
||||||
<Text
|
<Text
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-base",
|
'text-base',
|
||||||
tone === "destructive" ? "text-red-400" : "text-white",
|
tone === 'destructive' ? 'text-red-400' : 'text-white',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
import { Globe, Lock, X } from "lucide-react-native";
|
import { Globe, Lock, X } from 'lucide-react-native';
|
||||||
import { toast } from "sonner-native";
|
import { toast } from 'sonner-native';
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from '@/api/types';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||||
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
|
import { updateParticleVisibleTo } from '@/lib/firestore-particles';
|
||||||
import {
|
import {
|
||||||
buildCustomVisibility,
|
buildCustomVisibility,
|
||||||
buildNetworkVisibility,
|
buildNetworkVisibility,
|
||||||
parseVisibleTo,
|
parseVisibleTo,
|
||||||
} from "@/lib/stream-visibility";
|
} from '@/lib/stream-visibility';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { BottomSheet } from "@/components/BottomSheet";
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
import { Avatar } from "@/components/Avatar";
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { useStreamPresence } from "./stream-presence-context";
|
import { useStreamPresence } from './stream-presence-context';
|
||||||
|
|
||||||
interface StreamMembersSheetProps {
|
interface StreamMembersSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
networkId: string;
|
networkId: string;
|
||||||
streamParticle: Particle & { type: "stream" };
|
streamParticle: Particle & { type: 'stream' };
|
||||||
isCreator: boolean;
|
isCreator: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export function StreamMembersSheet({
|
|||||||
streamParticle,
|
streamParticle,
|
||||||
isCreator,
|
isCreator,
|
||||||
}: StreamMembersSheetProps) {
|
}: StreamMembersSheetProps) {
|
||||||
useSuspendPlayback(open, "stream-members");
|
useSuspendPlayback(open, 'stream-members');
|
||||||
|
|
||||||
const { onlineHumanIds } = useStreamPresence();
|
const { onlineHumanIds } = useStreamPresence();
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
@@ -52,7 +52,7 @@ export function StreamMembersSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const memberIds =
|
const memberIds =
|
||||||
visibility.mode === "network"
|
visibility.mode === 'network'
|
||||||
? humans.map((h) => h.id)
|
? humans.map((h) => h.id)
|
||||||
: visibility.humanIds;
|
: visibility.humanIds;
|
||||||
const memberSet = new Set(memberIds);
|
const memberSet = new Set(memberIds);
|
||||||
@@ -70,7 +70,7 @@ export function StreamMembersSheet({
|
|||||||
const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId]));
|
const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId]));
|
||||||
|
|
||||||
const removeMember = (id: string) => {
|
const removeMember = (id: string) => {
|
||||||
if (visibility.mode !== "custom") return;
|
if (visibility.mode !== 'custom') return;
|
||||||
if (id === creatorId) return;
|
if (id === creatorId) return;
|
||||||
const next = visibility.humanIds.filter((x) => x !== id);
|
const next = visibility.humanIds.filter((x) => x !== id);
|
||||||
if (next.length === 0) return;
|
if (next.length === 0) return;
|
||||||
@@ -78,7 +78,7 @@ export function StreamMembersSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addMember = (id: string) => {
|
const addMember = (id: string) => {
|
||||||
if (visibility.mode !== "custom") return;
|
if (visibility.mode !== 'custom') return;
|
||||||
void apply(buildCustomVisibility([...visibility.humanIds, id]));
|
void apply(buildCustomVisibility([...visibility.humanIds, id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,13 +99,13 @@ export function StreamMembersSheet({
|
|||||||
{isCreator ? (
|
{isCreator ? (
|
||||||
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
||||||
<ModePill
|
<ModePill
|
||||||
active={visibility.mode === "network"}
|
active={visibility.mode === 'network'}
|
||||||
icon={<Globe color="white" size={14} />}
|
icon={<Globe color="white" size={14} />}
|
||||||
label="Network-wide"
|
label="Network-wide"
|
||||||
onPress={setNetworkWide}
|
onPress={setNetworkWide}
|
||||||
/>
|
/>
|
||||||
<ModePill
|
<ModePill
|
||||||
active={visibility.mode === "custom"}
|
active={visibility.mode === 'custom'}
|
||||||
icon={<Lock color="white" size={14} />}
|
icon={<Lock color="white" size={14} />}
|
||||||
label="Specific people"
|
label="Specific people"
|
||||||
onPress={setCustomOnlyCreator}
|
onPress={setCustomOnlyCreator}
|
||||||
@@ -113,19 +113,19 @@ export function StreamMembersSheet({
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
{visibility.mode === "network" ? (
|
{visibility.mode === 'network' ? (
|
||||||
<>
|
<>
|
||||||
<Globe color="rgba(255,255,255,0.5)" size={14} />
|
<Globe color="rgba(255,255,255,0.5)" size={14} />
|
||||||
<Text className="text-white/70 text-sm">
|
<Text className="text-white/70 text-sm">
|
||||||
Everyone in {network?.name ?? "network"}
|
Everyone in {network?.name ?? 'network'}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Lock color="rgba(255,255,255,0.5)" size={14} />
|
<Lock color="rgba(255,255,255,0.5)" size={14} />
|
||||||
<Text className="text-white/70 text-sm">
|
<Text className="text-white/70 text-sm">
|
||||||
{memberIds.length} specific{" "}
|
{memberIds.length} specific{' '}
|
||||||
{memberIds.length === 1 ? "person" : "people"}
|
{memberIds.length === 1 ? 'person' : 'people'}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -136,19 +136,16 @@ export function StreamMembersSheet({
|
|||||||
<ScrollView contentContainerClassName="pb-4">
|
<ScrollView contentContainerClassName="pb-4">
|
||||||
<View className="px-5 pt-2">
|
<View className="px-5 pt-2">
|
||||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
|
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
|
||||||
{visibility.mode === "network" ? "Has access" : "People"} ·{" "}
|
{visibility.mode === 'network' ? 'Has access' : 'People'} ·{' '}
|
||||||
{memberIds.length}
|
{memberIds.length}
|
||||||
</Text>
|
</Text>
|
||||||
{memberIds.map((id) => {
|
{memberIds.map((id) => {
|
||||||
const display = resolveHumanDisplay(id, humans);
|
const display = resolveHumanDisplay(id, humans);
|
||||||
const isCreatorRow = id === creatorId;
|
const isCreatorRow = id === creatorId;
|
||||||
const canRemove =
|
const canRemove =
|
||||||
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
isCreator && visibility.mode === 'custom' && !isCreatorRow;
|
||||||
return (
|
return (
|
||||||
<View
|
<View key={id} className="flex-row items-center gap-3 py-2.5">
|
||||||
key={id}
|
|
||||||
className="flex-row items-center gap-3 py-2.5"
|
|
||||||
>
|
|
||||||
<Avatar
|
<Avatar
|
||||||
humanId={id}
|
humanId={id}
|
||||||
humans={humans}
|
humans={humans}
|
||||||
@@ -159,18 +156,15 @@ export function StreamMembersSheet({
|
|||||||
<Text
|
<Text
|
||||||
className={
|
className={
|
||||||
display.exists
|
display.exists
|
||||||
? "text-white text-sm font-medium"
|
? 'text-white text-sm font-medium'
|
||||||
: "text-white/50 italic text-sm font-medium"
|
: 'text-white/50 italic text-sm font-medium'
|
||||||
}
|
}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{display.displayName}
|
{display.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
{display.exists ? (
|
{display.exists ? (
|
||||||
<Text
|
<Text className="text-white/40 text-xs" numberOfLines={1}>
|
||||||
className="text-white/40 text-xs"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{display.email}
|
{display.email}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -194,7 +188,7 @@ export function StreamMembersSheet({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isCreator &&
|
{isCreator &&
|
||||||
visibility.mode === "custom" &&
|
visibility.mode === 'custom' &&
|
||||||
availableToAdd.length > 0 ? (
|
availableToAdd.length > 0 ? (
|
||||||
<View className="px-5 pt-4 mt-2 border-t border-white/5">
|
<View className="px-5 pt-4 mt-2 border-t border-white/5">
|
||||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mt-3 mb-2">
|
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mt-3 mb-2">
|
||||||
@@ -221,10 +215,7 @@ export function StreamMembersSheet({
|
|||||||
>
|
>
|
||||||
{display.displayName}
|
{display.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text className="text-white/40 text-xs" numberOfLines={1}>
|
||||||
className="text-white/40 text-xs"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{display.email}
|
{display.email}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -254,16 +245,14 @@ function ModePill({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={
|
className={
|
||||||
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 " +
|
'flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 ' +
|
||||||
(active ? "bg-white/15" : "")
|
(active ? 'bg-white/15' : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<Text
|
<Text
|
||||||
className={
|
className={
|
||||||
active
|
active ? 'text-white text-xs font-semibold' : 'text-white/60 text-xs'
|
||||||
? "text-white text-xs font-semibold"
|
|
||||||
: "text-white/60 text-xs"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native';
|
||||||
import type { Network, Particle } from "@/api/types";
|
import type { Network, Particle } from '@/api/types';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||||
import { Avatar } from "@/components/Avatar";
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { useStreamPresence } from "./stream-presence-context";
|
import { useStreamPresence } from './stream-presence-context';
|
||||||
|
|
||||||
interface StreamMetadataHeaderProps {
|
interface StreamMetadataHeaderProps {
|
||||||
particle: Particle | null;
|
particle: Particle | null;
|
||||||
@@ -26,7 +26,7 @@ export function StreamMetadataHeader({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const editedAt =
|
const editedAt =
|
||||||
particle.type === "text" ? particle.properties.edited_at : undefined;
|
particle.type === 'text' ? particle.properties.edited_at : undefined;
|
||||||
const isOnline = particle.created_by_human_id
|
const isOnline = particle.created_by_human_id
|
||||||
? onlineHumanIds.has(particle.created_by_human_id)
|
? onlineHumanIds.has(particle.created_by_human_id)
|
||||||
: false;
|
: false;
|
||||||
@@ -40,10 +40,7 @@ export function StreamMetadataHeader({
|
|||||||
online={isOnline}
|
online={isOnline}
|
||||||
/>
|
/>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text className="text-white text-sm font-semibold" numberOfLines={1}>
|
||||||
className="text-white text-sm font-semibold"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{display.displayName}
|
{display.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
@@ -53,7 +50,7 @@ export function StreamMetadataHeader({
|
|||||||
/>
|
/>
|
||||||
{editedAt ? (
|
{editedAt ? (
|
||||||
<Text className="text-white/40 text-xs">
|
<Text className="text-white/40 text-xs">
|
||||||
· edited{" "}
|
· edited{' '}
|
||||||
<RelativeTimestamp date={editedAt} className="text-white/40" />
|
<RelativeTimestamp date={editedAt} className="text-white/40" />
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
|
||||||
import {
|
import {
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
Globe,
|
Globe,
|
||||||
Headphones,
|
Headphones,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
} from "lucide-react-native";
|
} from 'lucide-react-native';
|
||||||
import type { Human, Particle } from "@/api/types";
|
import type { Human, Particle } from '@/api/types';
|
||||||
import { parseVisibleTo } from "@/lib/stream-visibility";
|
import { parseVisibleTo } from '@/lib/stream-visibility';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { Avatar } from "@/components/Avatar";
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { useOpenHuddle } from "@/features/huddle/use-open-huddle";
|
import { useOpenHuddle } from '@/features/huddle/use-open-huddle';
|
||||||
import { useStreamPresence } from "./stream-presence-context";
|
import { useStreamPresence } from './stream-presence-context';
|
||||||
|
|
||||||
interface StreamTopActionsProps {
|
interface StreamTopActionsProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
streamParticle: Particle & { type: "stream" };
|
streamParticle: Particle & { type: 'stream' };
|
||||||
humans: Human[];
|
humans: Human[];
|
||||||
videoFit: "cover" | "contain";
|
videoFit: 'cover' | 'contain';
|
||||||
onToggleVideoFit: () => void;
|
onToggleVideoFit: () => void;
|
||||||
onOpenMembers: () => void;
|
onOpenMembers: () => void;
|
||||||
onOpenActions: () => void;
|
onOpenActions: () => void;
|
||||||
@@ -48,7 +48,7 @@ export function StreamTopActions({
|
|||||||
const huddleActive = huddleCount > 0;
|
const huddleActive = huddleCount > 0;
|
||||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||||
const memberIds =
|
const memberIds =
|
||||||
visibility.mode === "network"
|
visibility.mode === 'network'
|
||||||
? humans.map((h) => h.id)
|
? humans.map((h) => h.id)
|
||||||
: visibility.humanIds;
|
: visibility.humanIds;
|
||||||
const shown = memberIds.slice(0, MAX_AVATARS);
|
const shown = memberIds.slice(0, MAX_AVATARS);
|
||||||
@@ -61,15 +61,12 @@ export function StreamTopActions({
|
|||||||
accessibilityLabel="Stream members"
|
accessibilityLabel="Stream members"
|
||||||
className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1"
|
className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1"
|
||||||
>
|
>
|
||||||
{visibility.mode === "network" && memberIds.length === 0 ? (
|
{visibility.mode === 'network' && memberIds.length === 0 ? (
|
||||||
<Globe color="rgba(255,255,255,0.85)" size={14} />
|
<Globe color="rgba(255,255,255,0.85)" size={14} />
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-row">
|
<View className="flex-row">
|
||||||
{shown.map((id, idx) => (
|
{shown.map((id, idx) => (
|
||||||
<View
|
<View key={id} style={{ marginLeft: idx === 0 ? 0 : -8 }}>
|
||||||
key={id}
|
|
||||||
style={{ marginLeft: idx === 0 ? 0 : -8 }}
|
|
||||||
>
|
|
||||||
{/* The stack ring matches the chrome's translucent bg so it
|
{/* The stack ring matches the chrome's translucent bg so it
|
||||||
reads as a separator without painting hard black halos. */}
|
reads as a separator without painting hard black halos. */}
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -98,12 +95,12 @@ export function StreamTopActions({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={huddleLoading}
|
disabled={huddleLoading}
|
||||||
accessibilityLabel={huddleActive ? "Join huddle" : "Start huddle"}
|
accessibilityLabel={huddleActive ? 'Join huddle' : 'Start huddle'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1",
|
'h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1',
|
||||||
huddleActive
|
huddleActive
|
||||||
? "bg-red-500/90 active:bg-red-600"
|
? 'bg-red-500/90 active:bg-red-600'
|
||||||
: "bg-white/10 active:bg-white/20",
|
: 'bg-white/10 active:bg-white/20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{huddleLoading ? (
|
{huddleLoading ? (
|
||||||
@@ -124,14 +121,16 @@ export function StreamTopActions({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={onToggleVideoFit}
|
onPress={onToggleVideoFit}
|
||||||
accessibilityLabel={
|
accessibilityLabel={
|
||||||
videoFit === "cover" ? "Fit video to screen" : "Fill screen with video"
|
videoFit === 'cover'
|
||||||
|
? 'Fit video to screen'
|
||||||
|
: 'Fill screen with video'
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 items-center justify-center rounded-full",
|
'h-8 w-8 items-center justify-center rounded-full',
|
||||||
"bg-white/10 active:bg-white/20",
|
'bg-white/10 active:bg-white/20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{videoFit === "cover" ? (
|
{videoFit === 'cover' ? (
|
||||||
<Minimize2 color="white" size={15} strokeWidth={1.8} />
|
<Minimize2 color="white" size={15} strokeWidth={1.8} />
|
||||||
) : (
|
) : (
|
||||||
<Maximize2 color="white" size={15} strokeWidth={1.8} />
|
<Maximize2 color="white" size={15} strokeWidth={1.8} />
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from 'react';
|
||||||
import { Alert, Dimensions, Pressable, Text, View } from "react-native";
|
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from '@react-navigation/native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { StatusBar } from "expo-status-bar";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { ChevronDown } from "lucide-react-native";
|
|
||||||
import {
|
import {
|
||||||
Gesture,
|
SafeAreaView,
|
||||||
GestureDetector,
|
useSafeAreaInsets,
|
||||||
} from "react-native-gesture-handler";
|
} from 'react-native-safe-area-context';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { ChevronDown } from 'lucide-react-native';
|
||||||
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Extrapolation,
|
Extrapolation,
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -17,54 +17,54 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withSpring,
|
withSpring,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from 'react-native-reanimated';
|
||||||
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
|
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
|
||||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||||
import {
|
import {
|
||||||
parseParticlePath,
|
parseParticlePath,
|
||||||
particlePath,
|
particlePath,
|
||||||
toFirestoreDocPath,
|
toFirestoreDocPath,
|
||||||
type ParticlePath,
|
type ParticlePath,
|
||||||
} from "@/lib/particle-path";
|
} from '@/lib/particle-path';
|
||||||
import {
|
import {
|
||||||
softDeleteParticle,
|
softDeleteParticle,
|
||||||
toggleParticleReaction,
|
toggleParticleReaction,
|
||||||
updateStreamStatus,
|
updateStreamStatus,
|
||||||
} from "@/lib/firestore-particles";
|
} from '@/lib/firestore-particles';
|
||||||
import { toast } from "sonner-native";
|
import { toast } from 'sonner-native';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
import { useStreamPlayback } from '@/hooks/use-stream-playback';
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import {
|
import {
|
||||||
selectIsComposing,
|
selectIsComposing,
|
||||||
selectIsPaused,
|
selectIsPaused,
|
||||||
usePlaybackPauseStore,
|
usePlaybackPauseStore,
|
||||||
} from "@/stores/playback-pause-store";
|
} from '@/stores/playback-pause-store';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { ComposeDock } from "@/features/compose/ComposeDock";
|
import { ComposeDock } from '@/features/compose/ComposeDock';
|
||||||
import { ComposingIndicator } from "@/components/ComposingIndicator";
|
import { ComposingIndicator } from '@/components/ComposingIndicator';
|
||||||
import { PlaybackPageIndicator } from "./PlaybackPageIndicator";
|
import { PlaybackPageIndicator } from './PlaybackPageIndicator';
|
||||||
import { ReactionSheet } from "./ReactionSheet";
|
import { ReactionSheet } from './ReactionSheet';
|
||||||
import { StreamMetadataHeader } from "./StreamMetadataHeader";
|
import { StreamMetadataHeader } from './StreamMetadataHeader';
|
||||||
import { StreamSafeAreaProvider } from "./stream-safe-area";
|
import { StreamSafeAreaProvider } from './stream-safe-area';
|
||||||
import {
|
import {
|
||||||
StreamPresenceProvider,
|
StreamPresenceProvider,
|
||||||
useStreamComposing,
|
useStreamComposing,
|
||||||
} from "./stream-presence-context";
|
} from './stream-presence-context';
|
||||||
import { TextParticleView } from "./TextParticleView";
|
import { TextParticleView } from './TextParticleView';
|
||||||
import { MediaParticleView } from "./MediaParticleView";
|
import { MediaParticleView } from './MediaParticleView';
|
||||||
import { DeletedParticleView } from "./DeletedParticleView";
|
import { DeletedParticleView } from './DeletedParticleView';
|
||||||
import { FallbackParticleView } from "./FallbackParticleView";
|
import { FallbackParticleView } from './FallbackParticleView';
|
||||||
import { useExitCountdown } from "./use-exit-countdown";
|
import { useExitCountdown } from './use-exit-countdown';
|
||||||
import { StreamTopActions } from "./StreamTopActions";
|
import { StreamTopActions } from './StreamTopActions';
|
||||||
import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet";
|
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
|
||||||
import { StreamMembersSheet } from "./StreamMembersSheet";
|
import { StreamMembersSheet } from './StreamMembersSheet';
|
||||||
import { RenameStreamSheet } from "./RenameStreamSheet";
|
import { RenameStreamSheet } from './RenameStreamSheet';
|
||||||
import { EditParticleSheet } from "./EditParticleSheet";
|
import { EditParticleSheet } from './EditParticleSheet';
|
||||||
import { ReactionStack } from "./ReactionStack";
|
import { ReactionStack } from './ReactionStack';
|
||||||
|
|
||||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
||||||
// Tap-zone split: left 28% goes back, right 72% goes forward — matching the
|
// Tap-zone split: left 28% goes back, right 72% goes forward — matching the
|
||||||
// asymmetric "Snapchat thumb-zone" so right-handed taps default to forward.
|
// asymmetric "Snapchat thumb-zone" so right-handed taps default to forward.
|
||||||
const PREV_ZONE_RATIO = 0.28;
|
const PREV_ZONE_RATIO = 0.28;
|
||||||
@@ -80,7 +80,7 @@ const REACTIONS_VELOCITY = 600;
|
|||||||
const COMPOSE_DOCK_HEIGHT = 50;
|
const COMPOSE_DOCK_HEIGHT = 50;
|
||||||
|
|
||||||
interface StreamViewProps {
|
interface StreamViewProps {
|
||||||
streamParticle: Particle & { type: "stream" };
|
streamParticle: Particle & { type: 'stream' };
|
||||||
path: ParticlePath;
|
path: ParticlePath;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
@@ -118,19 +118,26 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||||
const composing = usePlaybackPauseStore(selectIsComposing);
|
const composing = usePlaybackPauseStore(selectIsComposing);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
||||||
|
|
||||||
|
// Reset progress whenever the active particle changes.
|
||||||
|
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
||||||
|
if (currentParticle?.id !== prevParticleId) {
|
||||||
|
setPrevParticleId(currentParticle?.id);
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
|
||||||
// Local hold state drives the "touch-hold" pause suspender. We wrap the JS
|
// Local hold state drives the "touch-hold" pause suspender. We wrap the JS
|
||||||
// setter inside a runOnJS callback dispatched from the worklet thread.
|
// setter inside a runOnJS callback dispatched from the worklet thread.
|
||||||
const [holdActive, setHoldActive] = useState(false);
|
const [holdActive, setHoldActive] = useState(false);
|
||||||
useSuspendPlayback(holdActive, "touch-hold");
|
useSuspendPlayback(holdActive, 'touch-hold');
|
||||||
|
|
||||||
// Suspend playback whenever another screen (Huddle, NewStream, modals
|
// Suspend playback whenever another screen (Huddle, NewStream, modals
|
||||||
// routed as screens) is on top. Native stack keeps StreamView mounted, so
|
// routed as screens) is on top. Native stack keeps StreamView mounted, so
|
||||||
// without this the stream would keep advancing — and the exit countdown
|
// without this the stream would keep advancing — and the exit countdown
|
||||||
// would fire — behind the huddle.
|
// would fire — behind the huddle.
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
useSuspendPlayback(!isFocused, "screen-unfocused");
|
useSuspendPlayback(!isFocused, 'screen-unfocused');
|
||||||
|
|
||||||
// Reaction sheet — opens via swipe-up on the canvas.
|
// Reaction sheet — opens via swipe-up on the canvas.
|
||||||
const [reactionsOpen, setReactionsOpen] = useState(false);
|
const [reactionsOpen, setReactionsOpen] = useState(false);
|
||||||
@@ -142,32 +149,32 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
const [membersOpen, setMembersOpen] = useState(false);
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover");
|
const [videoFit, setVideoFit] = useState<'cover' | 'contain'>('cover');
|
||||||
|
|
||||||
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
const isCreator = !!userId && userId === streamParticle.created_by_human_id;
|
||||||
const canDeleteCurrentParticle =
|
const canDeleteCurrentParticle =
|
||||||
!!currentParticle &&
|
!!currentParticle &&
|
||||||
!!userId &&
|
!!userId &&
|
||||||
currentParticle.created_by_human_id === userId &&
|
currentParticle.created_by_human_id === userId &&
|
||||||
currentParticle.type !== "stream" &&
|
currentParticle.type !== 'stream' &&
|
||||||
currentParticle.type !== "folder" &&
|
currentParticle.type !== 'folder' &&
|
||||||
!isParticleDeleted(currentParticle);
|
!isParticleDeleted(currentParticle);
|
||||||
const canEditCurrentParticle =
|
const canEditCurrentParticle =
|
||||||
!!currentParticle &&
|
!!currentParticle &&
|
||||||
!!userId &&
|
!!userId &&
|
||||||
currentParticle.created_by_human_id === userId &&
|
currentParticle.created_by_human_id === userId &&
|
||||||
currentParticle.type === "text" &&
|
currentParticle.type === 'text' &&
|
||||||
!isParticleDeleted(currentParticle);
|
!isParticleDeleted(currentParticle);
|
||||||
const editableTextParticle =
|
const editableTextParticle =
|
||||||
canEditCurrentParticle && currentParticle && currentParticle.type === "text"
|
canEditCurrentParticle && currentParticle && currentParticle.type === 'text'
|
||||||
? currentParticle
|
? currentParticle
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const showFitToggle =
|
const showFitToggle =
|
||||||
!!currentParticle &&
|
!!currentParticle &&
|
||||||
!isParticleDeleted(currentParticle) &&
|
!isParticleDeleted(currentParticle) &&
|
||||||
currentParticle.type === "media" &&
|
currentParticle.type === 'media' &&
|
||||||
!currentParticle.properties.mime_type.startsWith("audio/");
|
!currentParticle.properties.mime_type.startsWith('audio/');
|
||||||
|
|
||||||
const handleStreamAction = useCallback(
|
const handleStreamAction = useCallback(
|
||||||
async (action: StreamActionId) => {
|
async (action: StreamActionId) => {
|
||||||
@@ -175,38 +182,38 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
particlePath(networkId, [streamParticle.id]),
|
particlePath(networkId, [streamParticle.id]),
|
||||||
);
|
);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "toggle-status": {
|
case 'toggle-status': {
|
||||||
try {
|
try {
|
||||||
await updateStreamStatus(
|
await updateStreamStatus(
|
||||||
streamDocPath,
|
streamDocPath,
|
||||||
streamParticle.status === "open" ? "closed" : "open",
|
streamParticle.status === 'open' ? 'closed' : 'open',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(toUserMessage(err));
|
toast.error(toUserMessage(err));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "rename":
|
case 'rename':
|
||||||
setRenameOpen(true);
|
setRenameOpen(true);
|
||||||
return;
|
return;
|
||||||
case "members":
|
case 'members':
|
||||||
setMembersOpen(true);
|
setMembersOpen(true);
|
||||||
return;
|
return;
|
||||||
case "edit-particle":
|
case 'edit-particle':
|
||||||
if (!canEditCurrentParticle) return;
|
if (!canEditCurrentParticle) return;
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
return;
|
return;
|
||||||
case "delete-particle": {
|
case 'delete-particle': {
|
||||||
if (!currentParticle || !userId) return;
|
if (!currentParticle || !userId) return;
|
||||||
if (!canDeleteCurrentParticle) return;
|
if (!canDeleteCurrentParticle) return;
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Delete this particle?",
|
'Delete this particle?',
|
||||||
"This cannot be undone. Other viewers will see a \"deleted\" message in its place.",
|
'This cannot be undone. Other viewers will see a "deleted" message in its place.',
|
||||||
[
|
[
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: 'Delete',
|
||||||
style: "destructive",
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
const docPath = toFirestoreDocPath(
|
const docPath = toFirestoreDocPath(
|
||||||
@@ -240,7 +247,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
|
|
||||||
const reactionsOnCurrent =
|
const reactionsOnCurrent =
|
||||||
currentParticle && !isParticleDeleted(currentParticle)
|
currentParticle && !isParticleDeleted(currentParticle)
|
||||||
? currentParticle.type === "media" || currentParticle.type === "text"
|
? currentParticle.type === 'media' || currentParticle.type === 'text'
|
||||||
? currentParticle.reactions
|
? currentParticle.reactions
|
||||||
: undefined
|
: undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -252,12 +259,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
const docPath = toFirestoreDocPath(
|
const docPath = toFirestoreDocPath(
|
||||||
particlePath(networkId, [streamParticle.id, currentParticle.id]),
|
particlePath(networkId, [streamParticle.id, currentParticle.id]),
|
||||||
);
|
);
|
||||||
void toggleParticleReaction(
|
void toggleParticleReaction(docPath, key, userId, reactionsOnCurrent);
|
||||||
docPath,
|
|
||||||
key,
|
|
||||||
userId,
|
|
||||||
reactionsOnCurrent,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent],
|
[userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent],
|
||||||
);
|
);
|
||||||
@@ -280,11 +282,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
[children.length, currentIndex, goToParticle],
|
[children.length, currentIndex, goToParticle],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset progress whenever the active particle changes.
|
|
||||||
useEffect(() => {
|
|
||||||
setProgress(0);
|
|
||||||
}, [currentParticle?.id]);
|
|
||||||
|
|
||||||
const handleTap = useCallback(
|
const handleTap = useCallback(
|
||||||
(xRatio: number) => {
|
(xRatio: number) => {
|
||||||
if (xRatio < PREV_ZONE_RATIO) {
|
if (xRatio < PREV_ZONE_RATIO) {
|
||||||
@@ -303,7 +300,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
|
|
||||||
// --- Swipe-down dismiss ---
|
// --- Swipe-down dismiss ---
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const screenWidth = Dimensions.get("window").width;
|
const screenWidth = Dimensions.get('window').width;
|
||||||
|
|
||||||
const exit = useCallback(() => {
|
const exit = useCallback(() => {
|
||||||
onExit();
|
onExit();
|
||||||
@@ -314,15 +311,12 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
.failOffsetX([-30, 30])
|
.failOffsetX([-30, 30])
|
||||||
.failOffsetY(-20)
|
.failOffsetY(-20)
|
||||||
.onUpdate((e) => {
|
.onUpdate((e) => {
|
||||||
"worklet";
|
'worklet';
|
||||||
translateY.value = Math.max(0, e.translationY);
|
translateY.value = Math.max(0, e.translationY);
|
||||||
})
|
})
|
||||||
.onEnd((e) => {
|
.onEnd((e) => {
|
||||||
"worklet";
|
'worklet';
|
||||||
if (
|
if (e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY) {
|
||||||
e.translationY > DISMISS_DISTANCE ||
|
|
||||||
e.velocityY > DISMISS_VELOCITY
|
|
||||||
) {
|
|
||||||
translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 });
|
translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 });
|
||||||
runOnJS(exit)();
|
runOnJS(exit)();
|
||||||
} else {
|
} else {
|
||||||
@@ -341,7 +335,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
.failOffsetX([-30, 30])
|
.failOffsetX([-30, 30])
|
||||||
.failOffsetY(20)
|
.failOffsetY(20)
|
||||||
.onEnd((e) => {
|
.onEnd((e) => {
|
||||||
"worklet";
|
'worklet';
|
||||||
if (
|
if (
|
||||||
e.translationY < -REACTIONS_DISTANCE ||
|
e.translationY < -REACTIONS_DISTANCE ||
|
||||||
e.velocityY < -REACTIONS_VELOCITY
|
e.velocityY < -REACTIONS_VELOCITY
|
||||||
@@ -355,7 +349,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
.maxDuration(180)
|
.maxDuration(180)
|
||||||
.maxDistance(15)
|
.maxDistance(15)
|
||||||
.onEnd((e, success) => {
|
.onEnd((e, success) => {
|
||||||
"worklet";
|
'worklet';
|
||||||
if (!success) return;
|
if (!success) return;
|
||||||
const ratio = e.x / screenWidth;
|
const ratio = e.x / screenWidth;
|
||||||
runOnJS(handleTap)(ratio);
|
runOnJS(handleTap)(ratio);
|
||||||
@@ -366,15 +360,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
.minDuration(180)
|
.minDuration(180)
|
||||||
.maxDistance(15)
|
.maxDistance(15)
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
"worklet";
|
'worklet';
|
||||||
runOnJS(setHoldActive)(true);
|
runOnJS(setHoldActive)(true);
|
||||||
})
|
})
|
||||||
.onTouchesUp(() => {
|
.onTouchesUp(() => {
|
||||||
"worklet";
|
'worklet';
|
||||||
runOnJS(setHoldActive)(false);
|
runOnJS(setHoldActive)(false);
|
||||||
})
|
})
|
||||||
.onFinalize(() => {
|
.onFinalize(() => {
|
||||||
"worklet";
|
'worklet';
|
||||||
runOnJS(setHoldActive)(false);
|
runOnJS(setHoldActive)(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,7 +435,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case "text":
|
case 'text':
|
||||||
return (
|
return (
|
||||||
<TextParticleView
|
<TextParticleView
|
||||||
key={particle.id}
|
key={particle.id}
|
||||||
@@ -451,7 +445,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "media":
|
case 'media':
|
||||||
return (
|
return (
|
||||||
<MediaParticleView
|
<MediaParticleView
|
||||||
key={particle.id}
|
key={particle.id}
|
||||||
@@ -612,7 +606,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
humans={network?.humans ?? []}
|
humans={network?.humans ?? []}
|
||||||
videoFit={videoFit}
|
videoFit={videoFit}
|
||||||
onToggleVideoFit={() =>
|
onToggleVideoFit={() =>
|
||||||
setVideoFit((v) => (v === "cover" ? "contain" : "cover"))
|
setVideoFit((v) => (v === 'cover' ? 'contain' : 'cover'))
|
||||||
}
|
}
|
||||||
onOpenMembers={() => setMembersOpen(true)}
|
onOpenMembers={() => setMembersOpen(true)}
|
||||||
onOpenActions={() => setActionsOpen(true)}
|
onOpenActions={() => setActionsOpen(true)}
|
||||||
@@ -637,15 +631,15 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
{currentParticle &&
|
{currentParticle &&
|
||||||
!composing &&
|
!composing &&
|
||||||
!isParticleDeleted(currentParticle) &&
|
!isParticleDeleted(currentParticle) &&
|
||||||
(currentParticle.type === "media" ||
|
(currentParticle.type === 'media' ||
|
||||||
currentParticle.type === "text") ? (
|
currentParticle.type === 'text') ? (
|
||||||
<View
|
<View
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
className="absolute right-3"
|
className="absolute right-3"
|
||||||
style={{
|
style={{
|
||||||
top: insets.top + 100,
|
top: insets.top + 100,
|
||||||
bottom: insets.bottom + COMPOSE_DOCK_HEIGHT + 40,
|
bottom: insets.bottom + COMPOSE_DOCK_HEIGHT + 40,
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ReactionStack
|
<ReactionStack
|
||||||
@@ -660,7 +654,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
|
|
||||||
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
||||||
iOS's status-bar tap doesn't fight our gestures. */}
|
iOS's status-bar tap doesn't fight our gestures. */}
|
||||||
<SafeAreaView edges={["top"]} pointerEvents="none" />
|
<SafeAreaView edges={['top']} pointerEvents="none" />
|
||||||
|
|
||||||
{/* Compose dock + recording overlays. Sits above the GestureDetector
|
{/* Compose dock + recording overlays. Sits above the GestureDetector
|
||||||
so its hold-FAB pan gesture isn't competed-with by the StreamView
|
so its hold-FAB pan gesture isn't competed-with by the StreamView
|
||||||
@@ -686,7 +680,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
open={actionsOpen}
|
open={actionsOpen}
|
||||||
onClose={() => setActionsOpen(false)}
|
onClose={() => setActionsOpen(false)}
|
||||||
onSelect={(action) => void handleStreamAction(action)}
|
onSelect={(action) => void handleStreamAction(action)}
|
||||||
streamStatus={streamParticle.status ?? "open"}
|
streamStatus={streamParticle.status ?? 'open'}
|
||||||
isCreator={isCreator}
|
isCreator={isCreator}
|
||||||
canEditParticle={canEditCurrentParticle}
|
canEditParticle={canEditCurrentParticle}
|
||||||
canDeleteParticle={canDeleteCurrentParticle}
|
canDeleteParticle={canDeleteCurrentParticle}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { useLiveParticle } from "@/hooks/use-particle";
|
import { useLiveParticle } from '@/hooks/use-particle';
|
||||||
import { StreamView } from "./StreamView";
|
import { StreamView } from './StreamView';
|
||||||
|
|
||||||
export function StreamViewScreen({
|
export function StreamViewScreen({
|
||||||
navigation,
|
navigation,
|
||||||
route,
|
route,
|
||||||
}: RootStackScreenProps<"StreamView">) {
|
}: RootStackScreenProps<'StreamView'>) {
|
||||||
const { networkId, streamId } = route.params;
|
const { networkId, streamId } = route.params;
|
||||||
const streamPath = particlePath(networkId, [streamId]);
|
const streamPath = particlePath(networkId, [streamId]);
|
||||||
const { particle, isLoading, error } = useLiveParticle(streamPath);
|
const { particle, isLoading, error } = useLiveParticle(streamPath);
|
||||||
@@ -22,16 +22,19 @@ export function StreamViewScreen({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !particle || particle.type !== "stream") {
|
if (error || !particle || particle.type !== 'stream') {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-black items-center justify-center px-8">
|
<View className="flex-1 bg-black items-center justify-center px-8">
|
||||||
<StatusBar style="light" hidden />
|
<StatusBar style="light" hidden />
|
||||||
<Text className="text-white/70 text-center">
|
<Text className="text-white/70 text-center">
|
||||||
{error
|
{error
|
||||||
? "Couldn't load this stream."
|
? "Couldn't load this stream."
|
||||||
: "This stream is no longer available."}
|
: 'This stream is no longer available.'}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => navigation.goBack()} className="mt-6 px-4 py-2">
|
<Pressable
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
className="mt-6 px-4 py-2"
|
||||||
|
>
|
||||||
<Text className="text-white/60">Close</Text>
|
<Text className="text-white/60">Close</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||||||
import { Platform, ScrollView, Text, View, type ViewStyle } from "react-native";
|
import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
|
||||||
import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked";
|
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from '@/api/types';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||||
import { useStreamSafeArea } from "./stream-safe-area";
|
import { useStreamSafeArea } from './stream-safe-area';
|
||||||
|
|
||||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
type TextParticle = Extract<Particle, { type: 'text' }>;
|
||||||
|
|
||||||
interface TextParticleViewProps {
|
interface TextParticleViewProps {
|
||||||
particle: TextParticle;
|
particle: TextParticle;
|
||||||
@@ -31,11 +31,9 @@ function computeReadDuration(text: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getImmersiveStyle(length: number) {
|
function getImmersiveStyle(length: number) {
|
||||||
if (length < 30)
|
if (length < 30) return { className: 'text-5xl font-semibold leading-tight' };
|
||||||
return { className: "text-5xl font-semibold leading-tight" };
|
if (length < 70) return { className: 'text-3xl font-semibold leading-snug' };
|
||||||
if (length < 70)
|
return { className: 'text-2xl font-normal leading-snug' };
|
||||||
return { className: "text-3xl font-semibold leading-snug" };
|
|
||||||
return { className: "text-2xl font-normal leading-snug" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors desktop's text-particle-view: short plain notes get the immersive
|
// Mirrors desktop's text-particle-view: short plain notes get the immersive
|
||||||
@@ -59,7 +57,7 @@ function withTaskCheckboxes(markdown: string): string {
|
|||||||
return markdown.replace(
|
return markdown.replace(
|
||||||
TASK_ITEM_RE,
|
TASK_ITEM_RE,
|
||||||
(_match, indent: string, mark: string) =>
|
(_match, indent: string, mark: string) =>
|
||||||
`${indent}${mark === " " ? "☐" : "☑"} `,
|
`${indent}${mark === ' ' ? '☐' : '☑'} `,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +70,11 @@ function withTaskCheckboxes(markdown: string): string {
|
|||||||
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
|
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
|
||||||
// uses CodeMirror; react-native-marked only exposes the language tag). They
|
// uses CodeMirror; react-native-marked only exposes the language tag). They
|
||||||
// render as plain monospace on the dark surface, which is acceptable for v1.
|
// render as plain monospace on the dark surface, which is acceptable for v1.
|
||||||
const TEXT_COLOR = "rgba(255,255,255,0.92)";
|
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
|
||||||
const ACCENT = "#60a5fa";
|
const ACCENT = '#60a5fa';
|
||||||
const SURFACE = "rgba(24,24,28,0.96)";
|
const SURFACE = 'rgba(24,24,28,0.96)';
|
||||||
const OUTLINE = "rgba(255,255,255,0.2)";
|
const OUTLINE = 'rgba(255,255,255,0.2)';
|
||||||
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
|
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
|
||||||
|
|
||||||
const MARKDOWN_THEME = {
|
const MARKDOWN_THEME = {
|
||||||
colors: {
|
colors: {
|
||||||
@@ -90,26 +88,88 @@ const MARKDOWN_THEME = {
|
|||||||
const MARKDOWN_STYLES: MarkedStyles = {
|
const MARKDOWN_STYLES: MarkedStyles = {
|
||||||
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
||||||
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
||||||
strong: { fontWeight: "700" },
|
strong: { fontWeight: '700' },
|
||||||
em: { fontStyle: "italic" },
|
em: { fontStyle: 'italic' },
|
||||||
strikethrough: {
|
strikethrough: {
|
||||||
textDecorationLine: "line-through",
|
textDecorationLine: 'line-through',
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: 'rgba(255,255,255,0.6)',
|
||||||
},
|
},
|
||||||
// fontStyle "normal" cancels react-native-marked's italic-by-default for
|
// fontStyle "normal" cancels react-native-marked's italic-by-default for
|
||||||
// links and inline code (desktop renders neither italic).
|
// links and inline code (desktop renders neither italic).
|
||||||
link: { color: ACCENT, fontStyle: "normal" },
|
link: { color: ACCENT, fontStyle: 'normal' },
|
||||||
// borderBottomWidth 0 removes the library's default heading underline rule,
|
// borderBottomWidth 0 removes the library's default heading underline rule,
|
||||||
// which desktop's headings don't have.
|
// which desktop's headings don't have.
|
||||||
h1: { color: "#ffffff", fontSize: 28, lineHeight: 34, fontWeight: "700", marginTop: 8, marginBottom: 8, borderBottomWidth: 0 },
|
h1: {
|
||||||
h2: { color: "#ffffff", fontSize: 24, lineHeight: 30, fontWeight: "700", marginTop: 8, marginBottom: 6, borderBottomWidth: 0 },
|
color: '#ffffff',
|
||||||
h3: { color: "#ffffff", fontSize: 20, lineHeight: 26, fontWeight: "600", marginTop: 6, marginBottom: 4 },
|
fontSize: 28,
|
||||||
h4: { color: "#ffffff", fontSize: 18, lineHeight: 24, fontWeight: "600", marginTop: 6, marginBottom: 4 },
|
lineHeight: 34,
|
||||||
h5: { color: "#ffffff", fontSize: 16, lineHeight: 22, fontWeight: "600", marginTop: 4, marginBottom: 2 },
|
fontWeight: '700',
|
||||||
h6: { color: "rgba(255,255,255,0.7)", fontSize: 15, lineHeight: 20, fontWeight: "600", marginTop: 4, marginBottom: 2 },
|
marginTop: 8,
|
||||||
codespan: { color: "#fca5a5", fontFamily: MONO, fontStyle: "normal", backgroundColor: "rgba(255,255,255,0.1)" },
|
marginBottom: 8,
|
||||||
code: { backgroundColor: SURFACE, borderColor: OUTLINE, borderWidth: 1, borderRadius: 8, padding: 12, marginVertical: 6 },
|
borderBottomWidth: 0,
|
||||||
blockquote: { borderLeftWidth: 3, borderLeftColor: OUTLINE, paddingLeft: 12, marginVertical: 6, opacity: 0.85 },
|
},
|
||||||
|
h2: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 24,
|
||||||
|
lineHeight: 30,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 20,
|
||||||
|
lineHeight: 26,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 22,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
codespan: {
|
||||||
|
color: '#fca5a5',
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontStyle: 'normal',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
backgroundColor: SURFACE,
|
||||||
|
borderColor: OUTLINE,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginVertical: 6,
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: OUTLINE,
|
||||||
|
paddingLeft: 12,
|
||||||
|
marginVertical: 6,
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
// hr is left to the library default, which already draws a 1px rule in the
|
// hr is left to the library default, which already draws a 1px rule in the
|
||||||
// themed border color (OUTLINE).
|
// themed border color (OUTLINE).
|
||||||
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
|
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
|
||||||
@@ -187,7 +247,10 @@ export function TextParticleView({
|
|||||||
// Immersive (short, plain): centered, large type — feels like a lock-screen
|
// Immersive (short, plain): centered, large type — feels like a lock-screen
|
||||||
// note. Short messages that contain markdown fall through to the rendered
|
// note. Short messages that contain markdown fall through to the rendered
|
||||||
// card so formatting isn't shown as raw syntax.
|
// card so formatting isn't shown as raw syntax.
|
||||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasMarkdownFormatting(content)) {
|
if (
|
||||||
|
content.length < IMMERSIVE_CHAR_LIMIT &&
|
||||||
|
!hasMarkdownFormatting(content)
|
||||||
|
) {
|
||||||
const style = getImmersiveStyle(content.length);
|
const style = getImmersiveStyle(content.length);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -198,7 +261,7 @@ export function TextParticleView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={cn("text-white text-center max-w-xl", style.className)}
|
className={cn('text-white text-center max-w-xl', style.className)}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useState } from 'react';
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from 'react-native';
|
||||||
import type { Transcript } from "@/api/types";
|
import type { Transcript } from '@/api/types';
|
||||||
|
|
||||||
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
type Sentence = Transcript['paragraphs'][number]['sentences'][number];
|
||||||
type Word = Transcript["words"][number];
|
type Word = Transcript['words'][number];
|
||||||
|
|
||||||
const CHUNK_SIZE = 9;
|
const CHUNK_SIZE = 9;
|
||||||
|
|
||||||
@@ -41,35 +41,40 @@ export function TranscriptOverlay({
|
|||||||
const activeWord =
|
const activeWord =
|
||||||
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
||||||
|
|
||||||
const lastSpokenWordRef = useRef<Word | null>(null);
|
// Remember the last spoken word so highlights hold during pauses.
|
||||||
if (activeWord) {
|
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
|
||||||
lastSpokenWordRef.current = activeWord;
|
if (activeWord && activeWord !== lastSpokenWord) {
|
||||||
|
setLastSpokenWord(activeWord);
|
||||||
}
|
}
|
||||||
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
const highlightWord = activeWord ?? lastSpokenWord;
|
||||||
|
|
||||||
const lastChunkRef = useRef<Word[] | null>(null);
|
// The chunk currently being spoken (null during a pause or if not found).
|
||||||
|
const spokenChunk = useMemo(() => {
|
||||||
const activeChunk = useMemo(() => {
|
if (!activeWord) return null;
|
||||||
if (activeWord) {
|
return (
|
||||||
for (const chunk of chunks) {
|
chunks.find((chunk) =>
|
||||||
if (
|
chunk.some(
|
||||||
chunk.some(
|
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
||||||
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
),
|
||||||
)
|
) ?? null
|
||||||
) {
|
);
|
||||||
lastChunkRef.current = chunk;
|
|
||||||
return chunk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
|
|
||||||
return lastChunkRef.current;
|
|
||||||
}
|
|
||||||
const fallback = chunks[0] ?? null;
|
|
||||||
lastChunkRef.current = fallback;
|
|
||||||
return fallback;
|
|
||||||
}, [chunks, activeWord]);
|
}, [chunks, activeWord]);
|
||||||
|
|
||||||
|
// Resolve which chunk to display: the spoken one, else hold the last one while
|
||||||
|
// it's still part of the current sentence, else fall back to the first chunk.
|
||||||
|
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
|
||||||
|
let activeChunk: Word[] | null;
|
||||||
|
if (spokenChunk) {
|
||||||
|
activeChunk = spokenChunk;
|
||||||
|
} else if (lastChunk && chunks.includes(lastChunk)) {
|
||||||
|
activeChunk = lastChunk;
|
||||||
|
} else {
|
||||||
|
activeChunk = chunks[0] ?? null;
|
||||||
|
}
|
||||||
|
if (activeChunk !== lastChunk) {
|
||||||
|
setLastChunk(activeChunk);
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,10 +92,10 @@ export function TranscriptOverlay({
|
|||||||
<Text
|
<Text
|
||||||
key={`${word.start}-${i}`}
|
key={`${word.start}-${i}`}
|
||||||
className={
|
className={
|
||||||
isSpoken ? "text-white font-medium" : "text-white/40"
|
isSpoken ? 'text-white font-medium' : 'text-white/40'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{i > 0 ? " " : ""}
|
{i > 0 ? ' ' : ''}
|
||||||
{word.word}
|
{word.word}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from 'react';
|
||||||
import { useChannel } from "@/hooks/use-channel";
|
import { useChannel } from '@/hooks/use-channel';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
export type ComposingMode = "recording" | "typing" | "screen";
|
export type ComposingMode = 'recording' | 'typing' | 'screen';
|
||||||
|
|
||||||
export interface ComposingUser {
|
export interface ComposingUser {
|
||||||
humanId: string;
|
humanId: string;
|
||||||
@@ -74,14 +74,14 @@ export function StreamPresenceProvider({
|
|||||||
if (!payload?.type) continue;
|
if (!payload?.type) continue;
|
||||||
if (msg.humanId === currentUserId) continue;
|
if (msg.humanId === currentUserId) continue;
|
||||||
|
|
||||||
if (payload.type === "composing_start" && payload.mode) {
|
if (payload.type === 'composing_start' && payload.mode) {
|
||||||
map.set(msg.humanId, {
|
map.set(msg.humanId, {
|
||||||
humanId: msg.humanId,
|
humanId: msg.humanId,
|
||||||
mode: payload.mode as ComposingMode,
|
mode: payload.mode as ComposingMode,
|
||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
});
|
});
|
||||||
changed = true;
|
changed = true;
|
||||||
} else if (payload.type === "composing_stop") {
|
} else if (payload.type === 'composing_stop') {
|
||||||
if (map.delete(msg.humanId)) changed = true;
|
if (map.delete(msg.humanId)) changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,10 +139,10 @@ export function StreamPresenceProvider({
|
|||||||
|
|
||||||
const startComposing = useCallback(
|
const startComposing = useCallback(
|
||||||
(mode: ComposingMode) => {
|
(mode: ComposingMode) => {
|
||||||
sendMessage({ type: "composing_start", mode });
|
sendMessage({ type: 'composing_start', mode });
|
||||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||||
heartbeatRef.current = setInterval(() => {
|
heartbeatRef.current = setInterval(() => {
|
||||||
sendMessage({ type: "composing_start", mode });
|
sendMessage({ type: 'composing_start', mode });
|
||||||
}, COMPOSING_HEARTBEAT_MS);
|
}, COMPOSING_HEARTBEAT_MS);
|
||||||
},
|
},
|
||||||
[sendMessage],
|
[sendMessage],
|
||||||
@@ -151,7 +151,7 @@ export function StreamPresenceProvider({
|
|||||||
const stopComposing = useCallback(() => {
|
const stopComposing = useCallback(() => {
|
||||||
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
||||||
heartbeatRef.current = undefined;
|
heartbeatRef.current = undefined;
|
||||||
sendMessage({ type: "composing_stop" });
|
sendMessage({ type: 'composing_stop' });
|
||||||
}, [sendMessage]);
|
}, [sendMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,7 +181,7 @@ function useStreamPresenceContext() {
|
|||||||
const ctx = useContext(StreamPresenceContext);
|
const ctx = useContext(StreamPresenceContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"useStreamPresence must be used within a StreamPresenceProvider",
|
'useStreamPresence must be used within a StreamPresenceProvider',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
@@ -201,3 +201,17 @@ export function useStreamComposingBroadcast() {
|
|||||||
const { startComposing, stopComposing } = useStreamPresenceContext();
|
const { startComposing, stopComposing } = useStreamPresenceContext();
|
||||||
return { startComposing, stopComposing };
|
return { startComposing, stopComposing };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link useStreamComposingBroadcast}, but returns null instead of throwing
|
||||||
|
* when rendered outside a provider — for callers (e.g. the compose dock) that
|
||||||
|
* can appear both inside and outside a stream.
|
||||||
|
*/
|
||||||
|
export function useStreamComposingBroadcastOptional() {
|
||||||
|
const ctx = useContext(StreamPresenceContext);
|
||||||
|
if (!ctx) return null;
|
||||||
|
return {
|
||||||
|
startComposing: ctx.startComposing,
|
||||||
|
stopComposing: ctx.stopComposing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, type ReactNode } from "react";
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
|
||||||
interface StreamSafeArea {
|
interface StreamSafeArea {
|
||||||
/** Pixels from the screen top reserved for the segmented bar + metadata. */
|
/** Pixels from the screen top reserved for the segmented bar + metadata. */
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import { useEvent } from "@/hooks/use-event";
|
import { useEvent } from '@/hooks/use-event';
|
||||||
|
|
||||||
export const EXIT_DELAY_MS = 5000;
|
export const EXIT_DELAY_MS = 5000;
|
||||||
export const EXIT_TICK_MS = 100;
|
export const EXIT_TICK_MS = 100;
|
||||||
|
|
||||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
type PlaybackStatus = 'idle' | 'playing' | 'ended';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the remaining ms when the stream has ended, or null otherwise.
|
* Returns the remaining ms when the stream has ended, or null otherwise.
|
||||||
@@ -16,18 +16,19 @@ export function useExitCountdown(
|
|||||||
onExit: () => void,
|
onExit: () => void,
|
||||||
): number | null {
|
): number | null {
|
||||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
||||||
|
const [prevStatus, setPrevStatus] = useState(status);
|
||||||
const handleExit = useEvent(onExit);
|
const handleExit = useEvent(onExit);
|
||||||
|
|
||||||
useEffect(() => {
|
// Start the countdown when playback ends; clear it on any other transition.
|
||||||
if (status === "ended") {
|
if (status !== prevStatus) {
|
||||||
setRemainingMs(EXIT_DELAY_MS);
|
setPrevStatus(status);
|
||||||
} else {
|
setRemainingMs(status === 'ended' ? EXIT_DELAY_MS : null);
|
||||||
setRemainingMs(null);
|
}
|
||||||
}
|
|
||||||
}, [status]);
|
const isCounting = remainingMs !== null && remainingMs > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (remainingMs === null || remainingMs <= 0 || paused) return;
|
if (!isCounting || paused) return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setRemainingMs((prev) => {
|
setRemainingMs((prev) => {
|
||||||
if (prev === null) return null;
|
if (prev === null) return null;
|
||||||
@@ -36,7 +37,7 @@ export function useExitCountdown(
|
|||||||
});
|
});
|
||||||
}, EXIT_TICK_MS);
|
}, EXIT_TICK_MS);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [remainingMs !== null && remainingMs > 0, paused, remainingMs]);
|
}, [isCounting, paused]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (remainingMs !== null && remainingMs <= 0) {
|
if (remainingMs !== null && remainingMs <= 0) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -6,24 +6,24 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from 'react-native';
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { ChevronRight, Globe, Lock, X } from "lucide-react-native";
|
import { ChevronRight, Globe, Lock, X } from 'lucide-react-native';
|
||||||
import { toast } from "sonner-native";
|
import { toast } from 'sonner-native';
|
||||||
import { ComposeDock } from "@/features/compose/ComposeDock";
|
import { ComposeDock } from '@/features/compose/ComposeDock';
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { generateRandomName } from "@/lib/random-name";
|
import { generateRandomName } from '@/lib/random-name';
|
||||||
import { createStreamWithFirstParticle } from "@/lib/upload";
|
import { createStreamWithFirstParticle } from '@/lib/upload';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import {
|
import {
|
||||||
buildNetworkVisibility,
|
buildNetworkVisibility,
|
||||||
parseVisibleTo,
|
parseVisibleTo,
|
||||||
} from "@/lib/stream-visibility";
|
} from '@/lib/stream-visibility';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
import { VisibilityPickerSheet } from "./VisibilityPickerSheet";
|
import { VisibilityPickerSheet } from './VisibilityPickerSheet';
|
||||||
|
|
||||||
const STREAM_NAME_MAX = 60;
|
const STREAM_NAME_MAX = 60;
|
||||||
|
|
||||||
@@ -35,13 +35,13 @@ const STREAM_NAME_MAX = 60;
|
|||||||
export function NewStreamScreen({
|
export function NewStreamScreen({
|
||||||
route,
|
route,
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<"NewStream">) {
|
}: RootStackScreenProps<'NewStream'>) {
|
||||||
const { networkId } = route.params;
|
const { networkId } = route.params;
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
const suggestion = useMemo(() => generateRandomName(), []);
|
const suggestion = useMemo(() => generateRandomName(), []);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState('');
|
||||||
const [visibleTo, setVisibleTo] = useState<string[]>(() =>
|
const [visibleTo, setVisibleTo] = useState<string[]>(() =>
|
||||||
buildNetworkVisibility(networkId),
|
buildNetworkVisibility(networkId),
|
||||||
);
|
);
|
||||||
@@ -50,18 +50,18 @@ export function NewStreamScreen({
|
|||||||
const effectiveName = name.trim() || suggestion;
|
const effectiveName = name.trim() || suggestion;
|
||||||
|
|
||||||
const handleStreamCreated = (streamId: string) => {
|
const handleStreamCreated = (streamId: string) => {
|
||||||
navigation.replace("StreamView", { networkId, streamId });
|
navigation.replace('StreamView', { networkId, streamId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitText = async (content: string) => {
|
const submitText = async (content: string) => {
|
||||||
if (!userId) throw new Error("Not signed in.");
|
if (!userId) throw new Error('Not signed in.');
|
||||||
try {
|
try {
|
||||||
const { streamId } = await createStreamWithFirstParticle({
|
const { streamId } = await createStreamWithFirstParticle({
|
||||||
networkId,
|
networkId,
|
||||||
name: effectiveName,
|
name: effectiveName,
|
||||||
visibleTo,
|
visibleTo,
|
||||||
createdByHumanId: userId,
|
createdByHumanId: userId,
|
||||||
firstParticle: { type: "text", content },
|
firstParticle: { type: 'text', content },
|
||||||
});
|
});
|
||||||
handleStreamCreated(streamId);
|
handleStreamCreated(streamId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -79,9 +79,9 @@ export function NewStreamScreen({
|
|||||||
fileUri: string;
|
fileUri: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
source: "camera" | "screen";
|
source: 'camera' | 'screen';
|
||||||
}) => {
|
}) => {
|
||||||
if (!userId) throw new Error("Not signed in.");
|
if (!userId) throw new Error('Not signed in.');
|
||||||
try {
|
try {
|
||||||
const { streamId } = await createStreamWithFirstParticle({
|
const { streamId } = await createStreamWithFirstParticle({
|
||||||
networkId,
|
networkId,
|
||||||
@@ -89,7 +89,7 @@ export function NewStreamScreen({
|
|||||||
visibleTo,
|
visibleTo,
|
||||||
createdByHumanId: userId,
|
createdByHumanId: userId,
|
||||||
firstParticle: {
|
firstParticle: {
|
||||||
type: "media",
|
type: 'media',
|
||||||
fileUri,
|
fileUri,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationMs,
|
durationMs,
|
||||||
@@ -107,17 +107,17 @@ export function NewStreamScreen({
|
|||||||
|
|
||||||
const visibility = parseVisibleTo(visibleTo, networkId);
|
const visibility = parseVisibleTo(visibleTo, networkId);
|
||||||
const visibleSummary =
|
const visibleSummary =
|
||||||
visibility.mode === "network"
|
visibility.mode === 'network'
|
||||||
? `Everyone in ${network?.name ?? "this network"}`
|
? `Everyone in ${network?.name ?? 'this network'}`
|
||||||
: `${visibility.humanIds.length} ${
|
: `${visibility.humanIds.length} ${
|
||||||
visibility.humanIds.length === 1 ? "person" : "people"
|
visibility.humanIds.length === 1 ? 'person' : 'people'
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-black">
|
<View className="flex-1 bg-black">
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
|
|
||||||
<SafeAreaView edges={["top"]}>
|
<SafeAreaView edges={['top']}>
|
||||||
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
|
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
@@ -126,15 +126,13 @@ export function NewStreamScreen({
|
|||||||
>
|
>
|
||||||
<X color="white" size={22} strokeWidth={1.8} />
|
<X color="white" size={22} strokeWidth={1.8} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text className="text-white text-base font-semibold">
|
<Text className="text-white text-base font-semibold">New stream</Text>
|
||||||
New stream
|
|
||||||
</Text>
|
|
||||||
<View style={{ width: 22 }} />
|
<View style={{ width: 22 }} />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<View className="flex-1 px-6 pt-4">
|
<View className="flex-1 px-6 pt-4">
|
||||||
@@ -159,8 +157,12 @@ export function NewStreamScreen({
|
|||||||
onPress={() => setPickerOpen(true)}
|
onPress={() => setPickerOpen(true)}
|
||||||
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
|
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
|
||||||
>
|
>
|
||||||
{visibility.mode === "network" ? (
|
{visibility.mode === 'network' ? (
|
||||||
<Globe color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
<Globe
|
||||||
|
color="rgba(255,255,255,0.7)"
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
|
||||||
)}
|
)}
|
||||||
@@ -176,7 +178,7 @@ export function NewStreamScreen({
|
|||||||
|
|
||||||
<View className="mt-6 px-1">
|
<View className="mt-6 px-1">
|
||||||
<Text className="text-white/50 text-sm">
|
<Text className="text-white/50 text-sm">
|
||||||
Hold the button below to record a voice or video message — that's
|
Hold the button below to record a voice or video message — that’s
|
||||||
the first particle in your new stream.
|
the first particle in your new stream.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from 'react';
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { Headphones } from "lucide-react-native";
|
import { Headphones } from 'lucide-react-native';
|
||||||
import type { Particle, StreamProperties } from "@/api/types";
|
import type { Particle, StreamProperties } from '@/api/types';
|
||||||
import { isParticleDeleted } from "@/api/types";
|
import { isParticleDeleted } from '@/api/types';
|
||||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||||
import { useLiveLatestChild } from "@/hooks/use-particle";
|
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { cn, getInitials } from "@/lib/utils";
|
import { cn, getInitials } from '@/lib/utils';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
interface StreamCardProps {
|
interface StreamCardProps {
|
||||||
particle: Particle & { type: "stream"; properties: StreamProperties };
|
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
||||||
networkId: string;
|
networkId: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,12 +28,12 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
}: StreamCardProps) {
|
}: StreamCardProps) {
|
||||||
const streamPath = particlePath(networkId, [particle.id]);
|
const streamPath = particlePath(networkId, [particle.id]);
|
||||||
const { latestChild } = useLiveLatestChild(streamPath);
|
const { latestChild } = useLiveLatestChild(streamPath);
|
||||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
|
|
||||||
const isDM =
|
const isDM =
|
||||||
particle.visible_to.length === 2 &&
|
particle.visible_to.length === 2 &&
|
||||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
const initials = useMemo(() => {
|
||||||
if (isDM) {
|
if (isDM) {
|
||||||
@@ -41,7 +41,7 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
(v) => v !== `human:${userId}`,
|
(v) => v !== `human:${userId}`,
|
||||||
);
|
);
|
||||||
if (otherEntry) {
|
if (otherEntry) {
|
||||||
const otherId = otherEntry.replace("human:", "");
|
const otherId = otherEntry.replace('human:', '');
|
||||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||||
if (otherHuman) return getInitials(otherHuman.email);
|
if (otherHuman) return getInitials(otherHuman.email);
|
||||||
}
|
}
|
||||||
@@ -73,45 +73,45 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
}, [latestChild, particle.playback_markers, userId]);
|
}, [latestChild, particle.playback_markers, userId]);
|
||||||
|
|
||||||
const previewLabel = useMemo(() => {
|
const previewLabel = useMemo(() => {
|
||||||
if (!latestChild) return "No messages yet";
|
if (!latestChild) return 'No messages yet';
|
||||||
if (isParticleDeleted(latestChild)) return "Message deleted";
|
if (isParticleDeleted(latestChild)) return 'Message deleted';
|
||||||
switch (latestChild.type) {
|
switch (latestChild.type) {
|
||||||
case "media": {
|
case 'media': {
|
||||||
const mime = latestChild.properties.mime_type;
|
const mime = latestChild.properties.mime_type;
|
||||||
if (mime.startsWith("image/")) return "Photo";
|
if (mime.startsWith('image/')) return 'Photo';
|
||||||
const transcriptText = latestChild.properties.transcript?.transcript;
|
const transcriptText = latestChild.properties.transcript?.transcript;
|
||||||
if (transcriptText) return transcriptText;
|
if (transcriptText) return transcriptText;
|
||||||
return mime.startsWith("audio/") ? "Voice note" : "Video clip";
|
return mime.startsWith('audio/') ? 'Voice note' : 'Video clip';
|
||||||
}
|
}
|
||||||
case "text":
|
case 'text':
|
||||||
return latestChild.properties.content;
|
return latestChild.properties.content;
|
||||||
case "file":
|
case 'file':
|
||||||
return latestChild.properties.filename;
|
return latestChild.properties.filename;
|
||||||
case "quest":
|
case 'quest':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
case "paper":
|
case 'paper':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
default:
|
default:
|
||||||
return "Update";
|
return 'Update';
|
||||||
}
|
}
|
||||||
}, [latestChild]);
|
}, [latestChild]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
android_ripple={{ color: "rgba(0,0,0,0.05)" }}
|
android_ripple={{ color: 'rgba(0,0,0,0.05)' }}
|
||||||
className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
|
className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-10 items-center justify-center rounded-full",
|
'h-10 w-10 items-center justify-center rounded-full',
|
||||||
isUnseen ? "bg-primary" : "bg-muted",
|
isUnseen ? 'bg-primary' : 'bg-muted',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-semibold",
|
'text-xs font-semibold',
|
||||||
isUnseen ? "text-primary-foreground" : "text-muted-foreground",
|
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
@@ -122,10 +122,10 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-base",
|
'text-base',
|
||||||
isUnseen
|
isUnseen
|
||||||
? "text-foreground font-semibold"
|
? 'text-foreground font-semibold'
|
||||||
: "text-foreground font-medium",
|
: 'text-foreground font-medium',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{particle.properties.name}
|
{particle.properties.name}
|
||||||
@@ -143,8 +143,8 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
<RelativeTimestamp
|
<RelativeTimestamp
|
||||||
date={latestChild.created_at}
|
date={latestChild.created_at}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs",
|
'text-xs',
|
||||||
isUnseen ? "text-primary" : "text-muted-foreground",
|
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,33 +4,33 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from 'react-native';
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { ListSeparator } from "@/components/ListSeparator";
|
import { ListSeparator } from '@/components/ListSeparator';
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
import { useStreamParticles } from '@/hooks/use-stream-particles';
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
import { StreamCard } from "./StreamCard";
|
import { StreamCard } from './StreamCard';
|
||||||
|
|
||||||
export function StreamListScreen({
|
export function StreamListScreen({
|
||||||
route,
|
route,
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<"StreamList">) {
|
}: RootStackScreenProps<'StreamList'>) {
|
||||||
const { networkId } = route.params;
|
const { networkId } = route.params;
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
|
|
||||||
const path = particlePath(networkId, []);
|
const path = particlePath(networkId, []);
|
||||||
|
|
||||||
const { streams, isLoading, error } = useStreamParticles(path, {
|
const { streams, isLoading, error } = useStreamParticles(path, {
|
||||||
status: "open",
|
status: 'open',
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<Header
|
<Header
|
||||||
title={network?.name ?? "Streams"}
|
title={network?.name ?? 'Streams'}
|
||||||
onBack={() => navigation.goBack()}
|
onBack={() => navigation.goBack()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export function StreamListScreen({
|
|||||||
particle={item}
|
particle={item}
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigation.navigate("StreamView", {
|
navigation.navigate('StreamView', {
|
||||||
networkId,
|
networkId,
|
||||||
streamId: item.id,
|
streamId: item.id,
|
||||||
})
|
})
|
||||||
@@ -61,19 +61,13 @@ export function StreamListScreen({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ComposeFab
|
<ComposeFab
|
||||||
onPress={() => navigation.navigate("NewStream", { networkId })}
|
onPress={() => navigation.navigate('NewStream', { networkId })}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({
|
function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
title,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from 'react';
|
||||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
import { Check, Globe, Lock, X } from "lucide-react-native";
|
import { Check, Globe, Lock, X } from 'lucide-react-native';
|
||||||
import type { Human } from "@/api/types";
|
import type { Human } from '@/api/types';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { resolveHumanDisplay } from "@/lib/humans";
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import {
|
import {
|
||||||
buildCustomVisibility,
|
buildCustomVisibility,
|
||||||
buildNetworkVisibility,
|
buildNetworkVisibility,
|
||||||
parseVisibleTo,
|
parseVisibleTo,
|
||||||
} from "@/lib/stream-visibility";
|
} from '@/lib/stream-visibility';
|
||||||
import { BottomSheet } from "@/components/BottomSheet";
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
import { Avatar } from "@/components/Avatar";
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
|
||||||
interface VisibilityPickerSheetProps {
|
interface VisibilityPickerSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -43,18 +43,20 @@ export function VisibilityPickerSheet({
|
|||||||
[visibleTo, networkId],
|
[visibleTo, networkId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [mode, setMode] = useState<"network" | "custom">(initial.mode);
|
const [mode, setMode] = useState<'network' | 'custom'>(initial.mode);
|
||||||
const [selected, setSelected] = useState<Set<string>>(
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
() => new Set(initial.mode === "custom" ? initial.humanIds : []),
|
() => new Set(initial.mode === 'custom' ? initial.humanIds : []),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// Re-seed from the committed value each time the sheet opens fresh.
|
||||||
if (!open) return;
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
setMode(initial.mode);
|
if (open !== prevOpen) {
|
||||||
setSelected(
|
setPrevOpen(open);
|
||||||
new Set(initial.mode === "custom" ? initial.humanIds : []),
|
if (open) {
|
||||||
);
|
setMode(initial.mode);
|
||||||
}, [open, initial]);
|
setSelected(new Set(initial.mode === 'custom' ? initial.humanIds : []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const others = humans.filter((h) => h.id !== selfHumanId);
|
const others = humans.filter((h) => h.id !== selfHumanId);
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ export function VisibilityPickerSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (mode === "network") {
|
if (mode === 'network') {
|
||||||
onChange(buildNetworkVisibility(networkId));
|
onChange(buildNetworkVisibility(networkId));
|
||||||
} else {
|
} else {
|
||||||
const ids = selfHumanId
|
const ids = selfHumanId
|
||||||
@@ -80,7 +82,7 @@ export function VisibilityPickerSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const customCount = selected.size + (selfHumanId ? 1 : 0);
|
const customCount = selected.size + (selfHumanId ? 1 : 0);
|
||||||
const canCommit = mode === "network" || customCount >= 2;
|
const canCommit = mode === 'network' || customCount >= 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheet open={open} onClose={onClose} maxHeight="80%">
|
<BottomSheet open={open} onClose={onClose} maxHeight="80%">
|
||||||
@@ -92,8 +94,8 @@ export function VisibilityPickerSheet({
|
|||||||
<Pressable onPress={commit} disabled={!canCommit} hitSlop={12}>
|
<Pressable onPress={commit} disabled={!canCommit} hitSlop={12}>
|
||||||
<Text
|
<Text
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-base font-semibold",
|
'text-base font-semibold',
|
||||||
canCommit ? "text-white" : "text-white/30",
|
canCommit ? 'text-white' : 'text-white/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Done
|
Done
|
||||||
@@ -104,31 +106,31 @@ export function VisibilityPickerSheet({
|
|||||||
<View className="px-5 pb-3">
|
<View className="px-5 pb-3">
|
||||||
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
||||||
<ModePill
|
<ModePill
|
||||||
active={mode === "network"}
|
active={mode === 'network'}
|
||||||
icon={<Globe color="white" size={14} />}
|
icon={<Globe color="white" size={14} />}
|
||||||
label="Everyone"
|
label="Everyone"
|
||||||
onPress={() => setMode("network")}
|
onPress={() => setMode('network')}
|
||||||
/>
|
/>
|
||||||
<ModePill
|
<ModePill
|
||||||
active={mode === "custom"}
|
active={mode === 'custom'}
|
||||||
icon={<Lock color="white" size={14} />}
|
icon={<Lock color="white" size={14} />}
|
||||||
label="Specific people"
|
label="Specific people"
|
||||||
onPress={() => setMode("custom")}
|
onPress={() => setMode('custom')}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{mode === "network" ? (
|
{mode === 'network' ? (
|
||||||
<View className="px-5 pb-6">
|
<View className="px-5 pb-6">
|
||||||
<Text className="text-white/60 text-sm">
|
<Text className="text-white/60 text-sm">
|
||||||
Everyone in {networkName ?? "this network"} can see this stream.
|
Everyone in {networkName ?? 'this network'} can see this stream.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView contentContainerClassName="px-2 pb-4">
|
<ScrollView contentContainerClassName="px-2 pb-4">
|
||||||
{others.length === 0 ? (
|
{others.length === 0 ? (
|
||||||
<Text className="text-white/50 text-sm px-3 py-4">
|
<Text className="text-white/50 text-sm px-3 py-4">
|
||||||
You're the only member of this network. Invite people on desktop,
|
You’re the only member of this network. Invite people on desktop,
|
||||||
then come back to choose specific viewers.
|
then come back to choose specific viewers.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -140,15 +142,11 @@ export function VisibilityPickerSheet({
|
|||||||
key={human.id}
|
key={human.id}
|
||||||
onPress={() => toggle(human.id)}
|
onPress={() => toggle(human.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-row items-center gap-3 px-3 py-2.5 rounded-lg",
|
'flex-row items-center gap-3 px-3 py-2.5 rounded-lg',
|
||||||
isSelected ? "bg-white/10" : "active:bg-white/5",
|
isSelected ? 'bg-white/10' : 'active:bg-white/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar humanId={human.id} humans={humans} size="sm" />
|
||||||
humanId={human.id}
|
|
||||||
humans={humans}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
className="text-white text-sm font-medium"
|
className="text-white text-sm font-medium"
|
||||||
@@ -156,19 +154,14 @@ export function VisibilityPickerSheet({
|
|||||||
>
|
>
|
||||||
{display.displayName}
|
{display.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text className="text-white/40 text-xs" numberOfLines={1}>
|
||||||
className="text-white/40 text-xs"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{display.email}
|
{display.email}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-6 items-center justify-center rounded-full border",
|
'h-6 w-6 items-center justify-center rounded-full border',
|
||||||
isSelected
|
isSelected ? 'bg-white border-white' : 'border-white/30',
|
||||||
? "bg-white border-white"
|
|
||||||
: "border-white/30",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected ? (
|
{isSelected ? (
|
||||||
@@ -200,15 +193,15 @@ function ModePill({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2",
|
'flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2',
|
||||||
active ? "bg-white/15" : "",
|
active ? 'bg-white/15' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<Text
|
<Text
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs",
|
'text-xs',
|
||||||
active ? "text-white font-semibold" : "text-white/60",
|
active ? 'text-white font-semibold' : 'text-white/60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { initializeApp } from "firebase/app";
|
import { initializeApp } from 'firebase/app';
|
||||||
import {
|
import {
|
||||||
initializeAuth,
|
initializeAuth,
|
||||||
// `getReactNativePersistence` is documented Firebase RN setup but Firebase
|
// `getReactNativePersistence` is documented Firebase RN setup but Firebase
|
||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
// platform; this is the workaround the Firebase docs themselves use.
|
// platform; this is the workaround the Firebase docs themselves use.
|
||||||
// @ts-expect-error — RN-only symbol missing from public Firebase types.
|
// @ts-expect-error — RN-only symbol missing from public Firebase types.
|
||||||
getReactNativePersistence,
|
getReactNativePersistence,
|
||||||
} from "firebase/auth";
|
} from 'firebase/auth';
|
||||||
import { initializeFirestore } from "firebase/firestore";
|
import { initializeFirestore } from 'firebase/firestore';
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { appConfig } from "@/config/env";
|
import { appConfig } from '@/config/env';
|
||||||
|
|
||||||
export const firebaseApp = initializeApp(appConfig.firebase);
|
export const firebaseApp = initializeApp(appConfig.firebase);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { usePusherClient } from "@/lib/pusher-provider";
|
import { usePusherClient } from '@/lib/pusher-provider';
|
||||||
import type { ChannelMessage } from "@/lib/pusher-client";
|
import type { ChannelMessage } from '@/lib/pusher-client';
|
||||||
|
|
||||||
interface UseChannelResult {
|
interface UseChannelResult {
|
||||||
/** Current set of humanIds present in the channel */
|
/** Current set of humanIds present in the channel */
|
||||||
@@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
|||||||
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
const [messages, setMessages] = useState<ChannelMessage[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !channelId) {
|
if (!client || !channelId) return;
|
||||||
setPresence([]);
|
|
||||||
setMessages([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.subscribe(channelId);
|
client.subscribe(channelId);
|
||||||
|
|
||||||
@@ -58,17 +54,20 @@ export function useChannel(channelId: string | null): UseChannelResult {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
client.on(channelId, "subscribed", onSubscribed);
|
client.on(channelId, 'subscribed', onSubscribed);
|
||||||
client.on(channelId, "join", onJoin);
|
client.on(channelId, 'join', onJoin);
|
||||||
client.on(channelId, "leave", onLeave);
|
client.on(channelId, 'leave', onLeave);
|
||||||
client.on(channelId, "message", onMessage);
|
client.on(channelId, 'message', onMessage);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.off(channelId, "subscribed", onSubscribed);
|
client.off(channelId, 'subscribed', onSubscribed);
|
||||||
client.off(channelId, "join", onJoin);
|
client.off(channelId, 'join', onJoin);
|
||||||
client.off(channelId, "leave", onLeave);
|
client.off(channelId, 'leave', onLeave);
|
||||||
client.off(channelId, "message", onMessage);
|
client.off(channelId, 'message', onMessage);
|
||||||
client.unsubscribe(channelId);
|
client.unsubscribe(channelId);
|
||||||
|
// Clear on teardown so a new channel doesn't briefly show stale data.
|
||||||
|
setPresence([]);
|
||||||
|
setMessages([]);
|
||||||
};
|
};
|
||||||
}, [client, channelId]);
|
}, [client, channelId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
// Polyfill for React's `useEffectEvent` (canary). The returned function has a
|
// Polyfill for React's `useEffectEvent` (canary). The returned function has a
|
||||||
// stable identity but always sees the latest closure — exactly what
|
// stable identity but always sees the latest closure — exactly what
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from '@/api/client';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
export function useNetworks() {
|
export function useNetworks() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["networks"],
|
queryKey: ['networks'],
|
||||||
queryFn: () => apiClient.listNetworks(),
|
queryFn: () => apiClient.listNetworks(),
|
||||||
meta: { toastOnError: true },
|
meta: { toastOnError: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import type { QueryFieldFilterConstraint } from "firebase/firestore";
|
import type { QueryFieldFilterConstraint } from 'firebase/firestore';
|
||||||
import {
|
import {
|
||||||
subscribeToParticle,
|
subscribeToParticle,
|
||||||
subscribeToParticleChildren,
|
subscribeToParticleChildren,
|
||||||
subscribeToLatestChild,
|
subscribeToLatestChild,
|
||||||
getParticle,
|
getParticle,
|
||||||
getParticleChildren,
|
getParticleChildren,
|
||||||
} from "@/lib/firestore-particles";
|
} from '@/lib/firestore-particles';
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from '@/api/types';
|
||||||
import {
|
import {
|
||||||
type ParticlePath,
|
type ParticlePath,
|
||||||
toFirestoreDocPath,
|
toFirestoreDocPath,
|
||||||
toFirestoreChildrenPath,
|
toFirestoreChildrenPath,
|
||||||
} from "@/lib/particle-path";
|
} from '@/lib/particle-path';
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from '@/lib/errors';
|
||||||
|
|
||||||
interface UseLiveParticleResult {
|
interface UseLiveParticleResult {
|
||||||
particle: Particle | null;
|
particle: Particle | null;
|
||||||
@@ -28,10 +28,6 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
|||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setParticle(null);
|
|
||||||
|
|
||||||
const docPath = toFirestoreDocPath(path);
|
const docPath = toFirestoreDocPath(path);
|
||||||
const unsubscribe = subscribeToParticle(
|
const unsubscribe = subscribeToParticle(
|
||||||
docPath,
|
docPath,
|
||||||
@@ -45,7 +41,13 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
// Reset on teardown so a new path doesn't flash the previous particle.
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setParticle(null);
|
||||||
|
};
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
return { particle, isLoading, error };
|
return { particle, isLoading, error };
|
||||||
@@ -59,7 +61,7 @@ interface UseLiveParticleChildrenResult {
|
|||||||
|
|
||||||
interface UseLiveParticleChildrenParams {
|
interface UseLiveParticleChildrenParams {
|
||||||
orderByField?: string;
|
orderByField?: string;
|
||||||
orderDirection?: "asc" | "desc";
|
orderDirection?: 'asc' | 'desc';
|
||||||
visibilityScopes?: string[];
|
visibilityScopes?: string[];
|
||||||
onAdded?: (child: Particle) => void;
|
onAdded?: (child: Particle) => void;
|
||||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||||
@@ -71,8 +73,8 @@ interface UseLiveParticleChildrenParams {
|
|||||||
export function useLiveParticleChildren(
|
export function useLiveParticleChildren(
|
||||||
path: ParticlePath | undefined,
|
path: ParticlePath | undefined,
|
||||||
{
|
{
|
||||||
orderByField = "created_at",
|
orderByField = 'created_at',
|
||||||
orderDirection = "desc",
|
orderDirection = 'desc',
|
||||||
visibilityScopes,
|
visibilityScopes,
|
||||||
onAdded,
|
onAdded,
|
||||||
onRemoved,
|
onRemoved,
|
||||||
@@ -85,15 +87,7 @@ export function useLiveParticleChildren(
|
|||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!path) {
|
if (!path) return;
|
||||||
setChildren([]);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setChildren([]);
|
|
||||||
|
|
||||||
const collectionPath = toFirestoreChildrenPath(path);
|
const collectionPath = toFirestoreChildrenPath(path);
|
||||||
|
|
||||||
@@ -103,7 +97,7 @@ export function useLiveParticleChildren(
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
logError(err, { scope: "firestore.particle-children", path });
|
logError(err, { scope: 'firestore.particle-children', path });
|
||||||
setError(err);
|
setError(err);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
@@ -116,13 +110,24 @@ export function useLiveParticleChildren(
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
// Reset on teardown so a new path doesn't flash the previous children.
|
||||||
|
setChildren([]);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
};
|
||||||
// The hook intentionally keys only on path/whereFilter/limit — desktop
|
// The hook intentionally keys only on path/whereFilter/limit — desktop
|
||||||
// does the same. Visibility scope changes are absorbed by the active
|
// does the same. Visibility scope changes are absorbed by the active
|
||||||
// listener; reordering causes a re-subscription.
|
// listener; reordering causes a re-subscription.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [path, whereFilter, limit]);
|
}, [path, whereFilter, limit]);
|
||||||
|
|
||||||
|
// No path: nothing to load, so report an empty non-loading state.
|
||||||
|
if (!path) {
|
||||||
|
return { children: [], isLoading: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
return { children, isLoading, error };
|
return { children, isLoading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +143,6 @@ export function useLiveLatestChild(
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
|
||||||
setLatestChild(null);
|
|
||||||
|
|
||||||
const unsubscribe = subscribeToLatestChild(
|
const unsubscribe = subscribeToLatestChild(
|
||||||
toFirestoreChildrenPath(path),
|
toFirestoreChildrenPath(path),
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -148,12 +150,17 @@ export function useLiveLatestChild(
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
logError(err, { scope: "firestore.latest-child", path });
|
logError(err, { scope: 'firestore.latest-child', path });
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
// Reset on teardown so a new path doesn't flash the previous child.
|
||||||
|
setIsLoading(true);
|
||||||
|
setLatestChild(null);
|
||||||
|
};
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
return { latestChild, isLoading };
|
return { latestChild, isLoading };
|
||||||
@@ -161,7 +168,7 @@ export function useLiveLatestChild(
|
|||||||
|
|
||||||
export function useParticle(path?: ParticlePath) {
|
export function useParticle(path?: ParticlePath) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["particle", path],
|
queryKey: ['particle', path],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
const docPath = toFirestoreDocPath(path);
|
const docPath = toFirestoreDocPath(path);
|
||||||
@@ -174,7 +181,7 @@ export function useParticle(path?: ParticlePath) {
|
|||||||
|
|
||||||
export function useParticleChildren(path?: ParticlePath) {
|
export function useParticleChildren(path?: ParticlePath) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["particle-children", path],
|
queryKey: ['particle-children', path],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!path) return [];
|
if (!path) return [];
|
||||||
const collectionPath = toFirestoreChildrenPath(path);
|
const collectionPath = toFirestoreChildrenPath(path);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { where, type QueryFieldFilterConstraint } from "firebase/firestore";
|
import { where, type QueryFieldFilterConstraint } from 'firebase/firestore';
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
import { useLiveParticleChildren } from '@/hooks/use-particle';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||||||
import type { Particle, StreamProperties } from "@/api/types";
|
import type { Particle, StreamProperties } from '@/api/types';
|
||||||
|
|
||||||
export type StreamParticle = Particle & {
|
export type StreamParticle = Particle & {
|
||||||
type: "stream";
|
type: 'stream';
|
||||||
properties: StreamProperties;
|
properties: StreamProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ const CLOSED_PAGE_INCREMENT = 50;
|
|||||||
|
|
||||||
// Stable where-constraint references so the Firestore subscription only
|
// Stable where-constraint references so the Firestore subscription only
|
||||||
// re-attaches when the tab actually changes, not on every render.
|
// re-attaches when the tab actually changes, not on every render.
|
||||||
const OPEN_STATUS_FILTER = where("status", "==", "open");
|
const OPEN_STATUS_FILTER = where('status', '==', 'open');
|
||||||
const CLOSED_STATUS_FILTER = where("status", "==", "closed");
|
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
|
||||||
|
|
||||||
function useVisibilityScopes(userId?: string, networkId?: string) {
|
function useVisibilityScopes(userId?: string, networkId?: string) {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
@@ -33,7 +33,7 @@ interface UseStreamParticlesOptions {
|
|||||||
* by active work — full realtime coverage is needed for autoplay/huddles).
|
* by active work — full realtime coverage is needed for autoplay/huddles).
|
||||||
* Closed streams are paginated via `loadMore`.
|
* Closed streams are paginated via `loadMore`.
|
||||||
*/
|
*/
|
||||||
status: "open" | "closed";
|
status: 'open' | 'closed';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseStreamParticlesResult {
|
interface UseStreamParticlesResult {
|
||||||
@@ -56,38 +56,40 @@ export function useStreamParticles(
|
|||||||
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
|
||||||
|
|
||||||
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
|
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
|
||||||
|
const [prevStatus, setPrevStatus] = useState(status);
|
||||||
|
|
||||||
// Every time the user switches back to the closed tab, start with a fresh
|
// Switching back to the closed tab starts a fresh window, avoiding an
|
||||||
// window. Avoids an ever-growing subscription across a long session.
|
// ever-growing subscription across a long session.
|
||||||
useEffect(() => {
|
if (status !== prevStatus) {
|
||||||
if (status === "closed") {
|
setPrevStatus(status);
|
||||||
|
if (status === 'closed') {
|
||||||
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
|
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}
|
||||||
|
|
||||||
const whereFilter: QueryFieldFilterConstraint =
|
const whereFilter: QueryFieldFilterConstraint =
|
||||||
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
status === 'open' ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
|
||||||
const limit = status === "closed" ? closedLimit : undefined;
|
const limit = status === 'closed' ? closedLimit : undefined;
|
||||||
|
|
||||||
const { children, isLoading, error } = useLiveParticleChildren(path, {
|
const { children, isLoading, error } = useLiveParticleChildren(path, {
|
||||||
orderByField: "last_child_created_at",
|
orderByField: 'last_child_created_at',
|
||||||
orderDirection: "desc",
|
orderDirection: 'desc',
|
||||||
visibilityScopes,
|
visibilityScopes,
|
||||||
whereFilter,
|
whereFilter,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const streams = useMemo(
|
const streams = useMemo(
|
||||||
() => children.filter((c): c is StreamParticle => c.type === "stream"),
|
() => children.filter((c): c is StreamParticle => c.type === 'stream'),
|
||||||
[children],
|
[children],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Heuristic: if we got back as many items as we asked for, assume there
|
// Heuristic: if we got back as many items as we asked for, assume there
|
||||||
// might be more. Clicking load-more when there are no more is a no-op.
|
// might be more. Clicking load-more when there are no more is a no-op.
|
||||||
const canLoadMore = status === "closed" && streams.length >= closedLimit;
|
const canLoadMore = status === 'closed' && streams.length >= closedLimit;
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (status !== "closed") return;
|
if (status !== 'closed') return;
|
||||||
setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT);
|
setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT);
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from '@/api/types';
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
import { useLiveParticleChildren } from '@/hooks/use-particle';
|
||||||
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
import { toFirestoreDocPath, type ParticlePath } from '@/lib/particle-path';
|
||||||
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
import { updateStreamPlaybackMarker } from '@/lib/firestore-particles';
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from '@/lib/errors';
|
||||||
import { useEvent } from "@/hooks/use-event";
|
import { useEvent } from '@/hooks/use-event';
|
||||||
|
|
||||||
// --- Playback reducer (ID-based) ---
|
// --- Playback reducer (ID-based) ---
|
||||||
|
|
||||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
type PlaybackStatus = 'idle' | 'playing' | 'ended';
|
||||||
|
|
||||||
interface PlaybackState {
|
interface PlaybackState {
|
||||||
currentParticleId: string | null;
|
currentParticleId: string | null;
|
||||||
@@ -18,19 +18,19 @@ interface PlaybackState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PlaybackAction =
|
type PlaybackAction =
|
||||||
| { type: "INIT"; particleId: string }
|
| { type: 'INIT'; particleId: string }
|
||||||
| { type: "SET_PARTICLE"; particleId: string }
|
| { type: 'SET_PARTICLE'; particleId: string }
|
||||||
| { type: "END" }
|
| { type: 'END' }
|
||||||
| { type: "PARTICLE_ADDED"; particleId: string }
|
| { type: 'PARTICLE_ADDED'; particleId: string }
|
||||||
| {
|
| {
|
||||||
type: "PARTICLE_REMOVED";
|
type: 'PARTICLE_REMOVED';
|
||||||
removedParticleId: string;
|
removedParticleId: string;
|
||||||
fallbackParticleId: string | null;
|
fallbackParticleId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: PlaybackState = {
|
const initialState: PlaybackState = {
|
||||||
currentParticleId: null,
|
currentParticleId: null,
|
||||||
status: "idle",
|
status: 'idle',
|
||||||
initialized: false,
|
initialized: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,39 +39,39 @@ function playbackReducer(
|
|||||||
action: PlaybackAction,
|
action: PlaybackAction,
|
||||||
): PlaybackState {
|
): PlaybackState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "INIT":
|
case 'INIT':
|
||||||
return {
|
return {
|
||||||
currentParticleId: action.particleId,
|
currentParticleId: action.particleId,
|
||||||
status: "playing",
|
status: 'playing',
|
||||||
initialized: true,
|
initialized: true,
|
||||||
};
|
};
|
||||||
case "SET_PARTICLE":
|
case 'SET_PARTICLE':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentParticleId: action.particleId,
|
currentParticleId: action.particleId,
|
||||||
status: "playing",
|
status: 'playing',
|
||||||
};
|
};
|
||||||
case "END":
|
case 'END':
|
||||||
return { ...state, status: "ended" };
|
return { ...state, status: 'ended' };
|
||||||
case "PARTICLE_ADDED":
|
case 'PARTICLE_ADDED':
|
||||||
if (state.status === "ended") {
|
if (state.status === 'ended') {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentParticleId: action.particleId,
|
currentParticleId: action.particleId,
|
||||||
status: "playing",
|
status: 'playing',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
case "PARTICLE_REMOVED":
|
case 'PARTICLE_REMOVED':
|
||||||
if (action.removedParticleId !== state.currentParticleId) return state;
|
if (action.removedParticleId !== state.currentParticleId) return state;
|
||||||
if (action.fallbackParticleId) {
|
if (action.fallbackParticleId) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentParticleId: action.fallbackParticleId,
|
currentParticleId: action.fallbackParticleId,
|
||||||
status: "playing",
|
status: 'playing',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ...state, currentParticleId: null, status: "idle" };
|
return { ...state, currentParticleId: null, status: 'idle' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,33 +90,40 @@ interface UseStreamPlaybackResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useStreamPlayback(
|
export function useStreamPlayback(
|
||||||
streamParticle: Particle & { type: "stream" },
|
streamParticle: Particle & { type: 'stream' },
|
||||||
path: ParticlePath,
|
path: ParticlePath,
|
||||||
): UseStreamPlaybackResult {
|
): UseStreamPlaybackResult {
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||||
// Track which stream we initialized for, so navigating to a sibling resets cleanly.
|
// Track which stream we initialized for, so navigating to a sibling resets cleanly.
|
||||||
const initializedForRef = useRef<string | null>(null);
|
const initializedForRef = useRef<string | null>(null);
|
||||||
|
// Latest currentIndex for onParticleRemoved, so it reads the current value
|
||||||
|
// without recreating the callback (which would re-subscribe the listener).
|
||||||
|
const currentIndexRef = useRef(0);
|
||||||
|
|
||||||
const onParticleAdded = useCallback((particle: Particle) => {
|
const onParticleAdded = useCallback((particle: Particle) => {
|
||||||
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
|
dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onParticleRemoved = useEvent(
|
const onParticleRemoved = useCallback(
|
||||||
(removed: Particle, updatedChildren: Particle[]) => {
|
(removed: Particle, updatedChildren: Particle[]) => {
|
||||||
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1);
|
const fallbackIndex = Math.min(
|
||||||
|
currentIndexRef.current,
|
||||||
|
updatedChildren.length - 1,
|
||||||
|
);
|
||||||
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
|
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "PARTICLE_REMOVED",
|
type: 'PARTICLE_REMOVED',
|
||||||
removedParticleId: removed.id,
|
removedParticleId: removed.id,
|
||||||
fallbackParticleId: fallback?.id ?? null,
|
fallbackParticleId: fallback?.id ?? null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { children } = useLiveParticleChildren(path, {
|
const { children } = useLiveParticleChildren(path, {
|
||||||
orderByField: "created_at",
|
orderByField: 'created_at',
|
||||||
orderDirection: "asc",
|
orderDirection: 'asc',
|
||||||
onAdded: onParticleAdded,
|
onAdded: onParticleAdded,
|
||||||
onRemoved: onParticleRemoved,
|
onRemoved: onParticleRemoved,
|
||||||
});
|
});
|
||||||
@@ -129,10 +136,15 @@ export function useStreamPlayback(
|
|||||||
|
|
||||||
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
|
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
|
||||||
|
|
||||||
|
// Keep the latest-index ref in sync for onParticleRemoved (above).
|
||||||
|
useEffect(() => {
|
||||||
|
currentIndexRef.current = currentIndex;
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
const initFallback = useEvent(() => {
|
const initFallback = useEvent(() => {
|
||||||
if (state.initialized || children.length === 0) return;
|
if (state.initialized || children.length === 0) return;
|
||||||
initializedForRef.current = streamParticle.id;
|
initializedForRef.current = streamParticle.id;
|
||||||
dispatch({ type: "INIT", particleId: children[0].id });
|
dispatch({ type: 'INIT', particleId: children[0].id });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Init logic: runs on every children change until initialized ---
|
// --- Init logic: runs on every children change until initialized ---
|
||||||
@@ -149,11 +161,11 @@ export function useStreamPlayback(
|
|||||||
|
|
||||||
if (children.length === 0) return;
|
if (children.length === 0) return;
|
||||||
|
|
||||||
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
|
const playbackPosition = streamParticle.playback_markers?.[userId ?? ''];
|
||||||
|
|
||||||
if (!playbackPosition) {
|
if (!playbackPosition) {
|
||||||
initializedForRef.current = streamParticle.id;
|
initializedForRef.current = streamParticle.id;
|
||||||
dispatch({ type: "INIT", particleId: children[0].id });
|
dispatch({ type: 'INIT', particleId: children[0].id });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,12 +175,12 @@ export function useStreamPlayback(
|
|||||||
|
|
||||||
if (found) {
|
if (found) {
|
||||||
initializedForRef.current = streamParticle.id;
|
initializedForRef.current = streamParticle.id;
|
||||||
dispatch({ type: "INIT", particleId: found.id });
|
dispatch({ type: 'INIT', particleId: found.id });
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
initializedForRef.current = streamParticle.id;
|
initializedForRef.current = streamParticle.id;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "INIT",
|
type: 'INIT',
|
||||||
particleId: children[children.length - 1].id,
|
particleId: children[children.length - 1].id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -201,7 +213,7 @@ export function useStreamPlayback(
|
|||||||
lastPersistedMarkerRef.current = currentTime;
|
lastPersistedMarkerRef.current = currentTime;
|
||||||
const streamDocPath = toFirestoreDocPath(path);
|
const streamDocPath = toFirestoreDocPath(path);
|
||||||
updateStreamPlaybackMarker(streamDocPath, userId, currentTime).catch(
|
updateStreamPlaybackMarker(streamDocPath, userId, currentTime).catch(
|
||||||
(err) => logError(err, { scope: "playback.marker", path }),
|
(err) => logError(err, { scope: 'playback.marker', path }),
|
||||||
);
|
);
|
||||||
// streamParticle.playback_markers is read at effect time; not in deps to
|
// streamParticle.playback_markers is read at effect time; not in deps to
|
||||||
// avoid double-writes when the snapshot we just persisted echoes back.
|
// avoid double-writes when the snapshot we just persisted echoes back.
|
||||||
@@ -213,18 +225,18 @@ export function useStreamPlayback(
|
|||||||
if (currentIndex === -1) return;
|
if (currentIndex === -1) return;
|
||||||
if (currentIndex < children.length - 1) {
|
if (currentIndex < children.length - 1) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_PARTICLE",
|
type: 'SET_PARTICLE',
|
||||||
particleId: children[currentIndex + 1].id,
|
particleId: children[currentIndex + 1].id,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: "END" });
|
dispatch({ type: 'END' });
|
||||||
}
|
}
|
||||||
}, [children, currentIndex]);
|
}, [children, currentIndex]);
|
||||||
|
|
||||||
const prev = useCallback(() => {
|
const prev = useCallback(() => {
|
||||||
if (currentIndex <= 0) return;
|
if (currentIndex <= 0) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_PARTICLE",
|
type: 'SET_PARTICLE',
|
||||||
particleId: children[currentIndex - 1].id,
|
particleId: children[currentIndex - 1].id,
|
||||||
});
|
});
|
||||||
}, [children, currentIndex]);
|
}, [children, currentIndex]);
|
||||||
@@ -232,7 +244,7 @@ export function useStreamPlayback(
|
|||||||
const goTo = useCallback(
|
const goTo = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index >= 0 && index < children.length) {
|
if (index >= 0 && index < children.length) {
|
||||||
dispatch({ type: "SET_PARTICLE", particleId: children[index].id });
|
dispatch({ type: 'SET_PARTICLE', particleId: children[index].id });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[children],
|
[children],
|
||||||
@@ -241,7 +253,7 @@ export function useStreamPlayback(
|
|||||||
// If the particle isn't in `children` yet (e.g. just-created), the live
|
// If the particle isn't in `children` yet (e.g. just-created), the live
|
||||||
// query will resolve it shortly and the derived index/particle will catch up.
|
// query will resolve it shortly and the derived index/particle will catch up.
|
||||||
const goToParticle = useCallback((particleId: string) => {
|
const goToParticle = useCallback((particleId: string) => {
|
||||||
dispatch({ type: "SET_PARTICLE", particleId });
|
dispatch({ type: 'SET_PARTICLE', particleId });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useId } from "react";
|
import { useEffect, useId } from 'react';
|
||||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspend stream playback while `active` is true. The hook owns its own
|
* Suspend stream playback while `active` is true. The hook owns its own
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from 'react';
|
||||||
import type { Transcript } from "@/api/types";
|
import type { Transcript } from '@/api/types';
|
||||||
|
|
||||||
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
type Sentence = Transcript['paragraphs'][number]['sentences'][number];
|
||||||
|
|
||||||
interface TranscriptPlaybackState {
|
interface TranscriptPlaybackState {
|
||||||
/** The sentence currently being spoken, or null if between sentences */
|
/** The sentence currently being spoken, or null if between sentences */
|
||||||
|
|||||||
+18
-20
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from 'zod';
|
||||||
import { appEnv } from "@/config/env";
|
import { appEnv } from '@/config/env';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -7,7 +7,7 @@ export class ApiError extends Error {
|
|||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiError";
|
this.name = 'ApiError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,42 +18,42 @@ export class ApiError extends Error {
|
|||||||
*/
|
*/
|
||||||
export class QuotaExceededError extends Error {
|
export class QuotaExceededError extends Error {
|
||||||
constructor(public readonly networkId: string) {
|
constructor(public readonly networkId: string) {
|
||||||
super("Daily message limit reached");
|
super('Daily message limit reached');
|
||||||
this.name = "QuotaExceededError";
|
this.name = 'QuotaExceededError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessage(message: string): string {
|
function normalizeMessage(message: string): string {
|
||||||
return message.replace(/^Error:\s*/, "").trim();
|
return message.replace(/^Error:\s*/, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toUserMessage(err: unknown): string {
|
export function toUserMessage(err: unknown): string {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
if (err.status === 401) return "Please sign in again.";
|
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 === 403) return "You don't have permission to do that.";
|
||||||
if (err.status === 404) return "Not found.";
|
if (err.status === 404) return 'Not found.';
|
||||||
if (err.status === 408 || err.status === 429) {
|
if (err.status === 408 || err.status === 429) {
|
||||||
return "Please try again in a moment.";
|
return 'Please try again in a moment.';
|
||||||
}
|
}
|
||||||
if (err.status >= 500) {
|
if (err.status >= 500) {
|
||||||
return "Something went wrong on our end. Please try again.";
|
return 'Something went wrong on our end. Please try again.';
|
||||||
}
|
}
|
||||||
return normalizeMessage(err.message) || "Request failed.";
|
return normalizeMessage(err.message) || 'Request failed.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof z.ZodError) {
|
if (err instanceof z.ZodError) {
|
||||||
return "Received unexpected data from the server.";
|
return 'Received unexpected data from the server.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
|
if (err instanceof TypeError && /fetch|network/i.test(err.message)) {
|
||||||
return "Network error. Check your connection.";
|
return 'Network error. Check your connection.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return normalizeMessage(err.message) || "Something went wrong.";
|
return normalizeMessage(err.message) || 'Something went wrong.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Something went wrong.";
|
return 'Something went wrong.';
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorContext = Record<string, unknown>;
|
type ErrorContext = Record<string, unknown>;
|
||||||
@@ -75,16 +75,14 @@ export function installErrorSinks(sinks: {
|
|||||||
|
|
||||||
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
|
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
|
||||||
export function logError(err: unknown, context?: ErrorContext): void {
|
export function logError(err: unknown, context?: ErrorContext): void {
|
||||||
if (appEnv === "dev") {
|
if (appEnv === 'dev') {
|
||||||
// eslint-disable-next-line no-console
|
console.error('[error]', err, context ?? {});
|
||||||
console.error("[error]", err, context ?? {});
|
|
||||||
}
|
}
|
||||||
breadcrumbSink?.(err, context);
|
breadcrumbSink?.(err, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unexpected failures the user may not see. Always captured. */
|
/** Unexpected failures the user may not see. Always captured. */
|
||||||
export function reportError(err: unknown, context?: ErrorContext): void {
|
export function reportError(err: unknown, context?: ErrorContext): void {
|
||||||
// eslint-disable-next-line no-console
|
console.error('[error]', err, context ?? {});
|
||||||
console.error("[error]", err, context ?? {});
|
|
||||||
captureSink?.(err, context);
|
captureSink?.(err, context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ import {
|
|||||||
type SnapshotOptions,
|
type SnapshotOptions,
|
||||||
type Unsubscribe,
|
type Unsubscribe,
|
||||||
type QueryFieldFilterConstraint,
|
type QueryFieldFilterConstraint,
|
||||||
} from "firebase/firestore";
|
} from 'firebase/firestore';
|
||||||
import { firestoreDb } from "@/firebase";
|
import { firestoreDb } from '@/firebase';
|
||||||
import { isContainerType, ParticleSchema } from "@/api/types";
|
import { isContainerType, ParticleSchema } from '@/api/types';
|
||||||
import type {
|
import type {
|
||||||
Particle,
|
Particle,
|
||||||
ParticleType,
|
ParticleType,
|
||||||
ParticlePropertiesMap,
|
ParticlePropertiesMap,
|
||||||
Reactions,
|
Reactions,
|
||||||
} from "@/api/types";
|
} from '@/api/types';
|
||||||
|
|
||||||
// --- Converter ---
|
// --- Converter ---
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
toFirestore(particle: Particle): DocumentData {
|
toFirestore(particle: Particle): DocumentData {
|
||||||
const { id: _id, created_at, updated_at, ...rest } = particle;
|
const { id: _id, created_at, updated_at, ...rest } = particle;
|
||||||
const deletedAt =
|
const deletedAt =
|
||||||
"deleted_at" in particle ? particle.deleted_at : undefined;
|
'deleted_at' in particle ? particle.deleted_at : undefined;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
created_at: Timestamp.fromDate(created_at),
|
created_at: Timestamp.fromDate(created_at),
|
||||||
@@ -50,12 +50,12 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
options?: SnapshotOptions,
|
options?: SnapshotOptions,
|
||||||
): Particle {
|
): Particle {
|
||||||
const raw = snap.data(options);
|
const raw = snap.data(options);
|
||||||
if (typeof raw.type !== "string") {
|
if (typeof raw.type !== 'string') {
|
||||||
throw new Error(`Invalid particle type: ${raw.type}`);
|
throw new Error(`Invalid particle type: ${raw.type}`);
|
||||||
}
|
}
|
||||||
const type = raw.type as ParticleType;
|
const type = raw.type as ParticleType;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "stream":
|
case 'stream':
|
||||||
return ParticleSchema.parse({
|
return ParticleSchema.parse({
|
||||||
id: snap.id,
|
id: snap.id,
|
||||||
type: raw.type,
|
type: raw.type,
|
||||||
@@ -81,7 +81,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
raw.huddle_active_participants ?? undefined,
|
raw.huddle_active_participants ?? undefined,
|
||||||
status: raw.status ?? undefined,
|
status: raw.status ?? undefined,
|
||||||
});
|
});
|
||||||
case "folder":
|
case 'folder':
|
||||||
return ParticleSchema.parse({
|
return ParticleSchema.parse({
|
||||||
id: snap.id,
|
id: snap.id,
|
||||||
type: raw.type,
|
type: raw.type,
|
||||||
@@ -93,15 +93,15 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
: undefined,
|
: undefined,
|
||||||
visible_to: raw.visible_to,
|
visible_to: raw.visible_to,
|
||||||
});
|
});
|
||||||
case "media":
|
case 'media':
|
||||||
case "file":
|
case 'file':
|
||||||
case "text":
|
case 'text':
|
||||||
case "quest":
|
case 'quest':
|
||||||
case "paper": {
|
case 'paper': {
|
||||||
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
||||||
// particles carry `properties.edited_at`, so coerce it if present.
|
// particles carry `properties.edited_at`, so coerce it if present.
|
||||||
const properties =
|
const properties =
|
||||||
type === "text" && raw.properties?.edited_at
|
type === 'text' && raw.properties?.edited_at
|
||||||
? {
|
? {
|
||||||
...raw.properties,
|
...raw.properties,
|
||||||
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
|
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
|
||||||
@@ -165,17 +165,17 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
|
|||||||
|
|
||||||
export interface GetParticleChildrenOptions {
|
export interface GetParticleChildrenOptions {
|
||||||
orderByField: string;
|
orderByField: string;
|
||||||
orderDirection: "asc" | "desc";
|
orderDirection: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getParticleChildren(
|
export async function getParticleChildren(
|
||||||
collectionPath: string,
|
collectionPath: string,
|
||||||
{
|
{
|
||||||
orderByField = "created_at",
|
orderByField = 'created_at',
|
||||||
orderDirection = "asc",
|
orderDirection = 'asc',
|
||||||
}: GetParticleChildrenOptions = {
|
}: GetParticleChildrenOptions = {
|
||||||
orderByField: "created_at",
|
orderByField: 'created_at',
|
||||||
orderDirection: "asc",
|
orderDirection: 'asc',
|
||||||
},
|
},
|
||||||
): Promise<Particle[]> {
|
): Promise<Particle[]> {
|
||||||
const q = query(
|
const q = query(
|
||||||
@@ -191,7 +191,7 @@ export interface SubscribeToParticleChildrenOptions {
|
|||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
visibilityScopes?: string[];
|
visibilityScopes?: string[];
|
||||||
orderByField?: string;
|
orderByField?: string;
|
||||||
orderDirection?: "asc" | "desc";
|
orderDirection?: 'asc' | 'desc';
|
||||||
onAdded?: (child: Particle) => void;
|
onAdded?: (child: Particle) => void;
|
||||||
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
||||||
whereFilter?: QueryFieldFilterConstraint;
|
whereFilter?: QueryFieldFilterConstraint;
|
||||||
@@ -205,8 +205,8 @@ export function subscribeToParticleChildren(
|
|||||||
onData,
|
onData,
|
||||||
onError,
|
onError,
|
||||||
visibilityScopes = [],
|
visibilityScopes = [],
|
||||||
orderByField = "created_at",
|
orderByField = 'created_at',
|
||||||
orderDirection = "desc",
|
orderDirection = 'desc',
|
||||||
onAdded,
|
onAdded,
|
||||||
onRemoved,
|
onRemoved,
|
||||||
whereFilter,
|
whereFilter,
|
||||||
@@ -218,7 +218,7 @@ export function subscribeToParticleChildren(
|
|||||||
orderBy(orderByField, orderDirection),
|
orderBy(orderByField, orderDirection),
|
||||||
);
|
);
|
||||||
if (visibilityScopes.length > 0) {
|
if (visibilityScopes.length > 0) {
|
||||||
q = query(q, where("visible_to", "array-contains-any", visibilityScopes));
|
q = query(q, where('visible_to', 'array-contains-any', visibilityScopes));
|
||||||
}
|
}
|
||||||
if (whereFilter) {
|
if (whereFilter) {
|
||||||
q = query(q, whereFilter);
|
q = query(q, whereFilter);
|
||||||
@@ -234,8 +234,8 @@ export function subscribeToParticleChildren(
|
|||||||
|
|
||||||
if (onAdded || onRemoved) {
|
if (onAdded || onRemoved) {
|
||||||
for (const change of snap.docChanges()) {
|
for (const change of snap.docChanges()) {
|
||||||
if (change.type === "added" && onAdded) onAdded(change.doc.data());
|
if (change.type === 'added' && onAdded) onAdded(change.doc.data());
|
||||||
if (change.type === "removed" && onRemoved)
|
if (change.type === 'removed' && onRemoved)
|
||||||
onRemoved(change.doc.data(), updatedChildren);
|
onRemoved(change.doc.data(), updatedChildren);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@ export function subscribeToLatestChild(
|
|||||||
): Unsubscribe {
|
): Unsubscribe {
|
||||||
const q = query(
|
const q = query(
|
||||||
typedCollection(collectionPath),
|
typedCollection(collectionPath),
|
||||||
orderBy("created_at", "desc"),
|
orderBy('created_at', 'desc'),
|
||||||
limit(1),
|
limit(1),
|
||||||
);
|
);
|
||||||
return onSnapshot(
|
return onSnapshot(
|
||||||
@@ -279,7 +279,7 @@ export async function createParticle<T extends ParticleType>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const particle: Particle = ParticleSchema.parse({
|
const particle: Particle = ParticleSchema.parse({
|
||||||
id: "", // ignored by toFirestore, but needed to satisfy the type
|
id: '', // ignored by toFirestore, but needed to satisfy the type
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
@@ -292,22 +292,22 @@ export async function createParticle<T extends ParticleType>(
|
|||||||
|
|
||||||
export async function createStreamParticle(
|
export async function createStreamParticle(
|
||||||
collectionPath: string,
|
collectionPath: string,
|
||||||
properties: ParticlePropertiesMap["stream"],
|
properties: ParticlePropertiesMap['stream'],
|
||||||
createdByHumanId: string,
|
createdByHumanId: string,
|
||||||
visibleTo?: string[],
|
visibleTo?: string[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!visibleTo || visibleTo.length === 0) {
|
if (!visibleTo || visibleTo.length === 0) {
|
||||||
throw new Error("visibleTo is required for streams and cannot be empty");
|
throw new Error('visibleTo is required for streams and cannot be empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
const particle: Particle = ParticleSchema.parse({
|
const particle: Particle = ParticleSchema.parse({
|
||||||
id: "",
|
id: '',
|
||||||
type: "stream",
|
type: 'stream',
|
||||||
properties,
|
properties,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
created_by_human_id: createdByHumanId,
|
created_by_human_id: createdByHumanId,
|
||||||
visible_to: visibleTo,
|
visible_to: visibleTo,
|
||||||
status: "open",
|
status: 'open',
|
||||||
});
|
});
|
||||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||||
return ref.id;
|
return ref.id;
|
||||||
@@ -340,8 +340,8 @@ export async function editTextParticleContent(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const particleRef = typedDoc(docPath);
|
const particleRef = typedDoc(docPath);
|
||||||
await updateDoc(particleRef, {
|
await updateDoc(particleRef, {
|
||||||
"properties.content": content,
|
'properties.content': content,
|
||||||
"properties.edited_at": serverTimestamp(),
|
'properties.edited_at': serverTimestamp(),
|
||||||
updated_at: serverTimestamp(),
|
updated_at: serverTimestamp(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -383,7 +383,7 @@ export async function updateParticle(
|
|||||||
|
|
||||||
export async function updateStreamStatus(
|
export async function updateStreamStatus(
|
||||||
docPath: string,
|
docPath: string,
|
||||||
status: "open" | "closed",
|
status: 'open' | 'closed',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const particleRef = typedDoc(docPath);
|
const particleRef = typedDoc(docPath);
|
||||||
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
|
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
|
||||||
@@ -426,7 +426,7 @@ export async function updateStreamPlaybackMarker(
|
|||||||
const RESERVED_REACTION_CHARS = /[~*/[\]]/g;
|
const RESERVED_REACTION_CHARS = /[~*/[\]]/g;
|
||||||
|
|
||||||
export function sanitizeReactionText(text: string): string {
|
export function sanitizeReactionText(text: string): string {
|
||||||
return text.replace(RESERVED_REACTION_CHARS, "");
|
return text.replace(RESERVED_REACTION_CHARS, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleParticleReaction(
|
export async function toggleParticleReaction(
|
||||||
@@ -441,9 +441,9 @@ export async function toggleParticleReaction(
|
|||||||
const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
|
const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
|
||||||
await updateDoc(
|
await updateDoc(
|
||||||
particleRef,
|
particleRef,
|
||||||
new FieldPath("reactions", key),
|
new FieldPath('reactions', key),
|
||||||
alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
|
alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
|
||||||
"updated_at",
|
'updated_at',
|
||||||
serverTimestamp(),
|
serverTimestamp(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Human } from "@/api/types";
|
import type { Human } from '@/api/types';
|
||||||
import { getInitials } from "@/lib/utils";
|
import { getInitials } from '@/lib/utils';
|
||||||
|
|
||||||
export const REMOVED_MEMBER_LABEL = "Removed member";
|
export const REMOVED_MEMBER_LABEL = 'Removed member';
|
||||||
export const REMOVED_MEMBER_INITIALS = "–";
|
export const REMOVED_MEMBER_INITIALS = '–';
|
||||||
|
|
||||||
export interface HumanDisplay {
|
export interface HumanDisplay {
|
||||||
/** True when the human was found in the provided list. */
|
/** True when the human was found in the provided list. */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createNavigationContainerRef } from "@react-navigation/native";
|
import { createNavigationContainerRef } from '@react-navigation/native';
|
||||||
import type { Notification } from "expo-notifications";
|
import type { Notification } from 'expo-notifications';
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from '@/lib/errors';
|
||||||
|
|
||||||
// Shared ref so non-component code (notification handlers, deep links) can
|
// Shared ref so non-component code (notification handlers, deep links) can
|
||||||
// drive navigation without prop-drilling. Typed via the global
|
// drive navigation without prop-drilling. Typed via the global
|
||||||
@@ -10,7 +10,7 @@ export const navigationRef = createNavigationContainerRef();
|
|||||||
// Shape the worker (go/internal/human/pushnotify/notifier.go::buildMessages)
|
// Shape the worker (go/internal/human/pushnotify/notifier.go::buildMessages)
|
||||||
// puts in `Notifications.notification.request.content.data`.
|
// puts in `Notifications.notification.request.content.data`.
|
||||||
type ParticleCreatedData = {
|
type ParticleCreatedData = {
|
||||||
kind: "particle_created";
|
kind: 'particle_created';
|
||||||
network_id: string;
|
network_id: string;
|
||||||
stream_id: string;
|
stream_id: string;
|
||||||
particle_id: string;
|
particle_id: string;
|
||||||
@@ -20,11 +20,11 @@ type ParticleCreatedData = {
|
|||||||
|
|
||||||
function isParticleCreatedData(data: unknown): data is ParticleCreatedData {
|
function isParticleCreatedData(data: unknown): data is ParticleCreatedData {
|
||||||
return (
|
return (
|
||||||
typeof data === "object" &&
|
typeof data === 'object' &&
|
||||||
data !== null &&
|
data !== null &&
|
||||||
(data as { kind?: unknown }).kind === "particle_created" &&
|
(data as { kind?: unknown }).kind === 'particle_created' &&
|
||||||
typeof (data as { network_id?: unknown }).network_id === "string" &&
|
typeof (data as { network_id?: unknown }).network_id === 'string' &&
|
||||||
typeof (data as { stream_id?: unknown }).stream_id === "string"
|
typeof (data as { stream_id?: unknown }).stream_id === 'string'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export function routeNotificationTap(notification: Notification): void {
|
|||||||
}
|
}
|
||||||
navigateToStream(data);
|
navigateToStream(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "push.route" });
|
logError(err, { scope: 'push.route' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export function flushPendingNavigation(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToStream(data: ParticleCreatedData): void {
|
function navigateToStream(data: ParticleCreatedData): void {
|
||||||
navigationRef.navigate("StreamView", {
|
navigationRef.navigate('StreamView', {
|
||||||
networkId: data.network_id,
|
networkId: data.network_id,
|
||||||
streamId: data.stream_id,
|
streamId: data.stream_id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function particlePath(
|
|||||||
networkId: string,
|
networkId: string,
|
||||||
segments: string[] = [],
|
segments: string[] = [],
|
||||||
): ParticlePath {
|
): ParticlePath {
|
||||||
return `/${[networkId, ...segments].join("/")}` as ParticlePath;
|
return `/${[networkId, ...segments].join('/')}` as ParticlePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +30,7 @@ export function parseParticlePath(path: ParticlePath): {
|
|||||||
networkId: string;
|
networkId: string;
|
||||||
segments: string[];
|
segments: string[];
|
||||||
} {
|
} {
|
||||||
const parts = path.split("/").filter(Boolean);
|
const parts = path.split('/').filter(Boolean);
|
||||||
return { networkId: parts[0], segments: parts.slice(1) };
|
return { networkId: parts[0], segments: parts.slice(1) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +49,9 @@ export function toFirestoreDocPath(path: ParticlePath): string {
|
|||||||
|
|
||||||
const parts: string[] = [base, segments[0]];
|
const parts: string[] = [base, segments[0]];
|
||||||
for (let i = 1; i < segments.length; i++) {
|
for (let i = 1; i < segments.length; i++) {
|
||||||
parts.push("children", segments[i]);
|
parts.push('children', segments[i]);
|
||||||
}
|
}
|
||||||
return parts.join("/");
|
return parts.join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Constants from "expo-constants";
|
import Constants from 'expo-constants';
|
||||||
import * as Device from "expo-device";
|
import * as Device from 'expo-device';
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from 'expo-notifications';
|
||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import { Platform } from "react-native";
|
import { Platform } from 'react-native';
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from '@/api/client';
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from '@/lib/errors';
|
||||||
import { routeNotificationTap } from "@/lib/notification-routing";
|
import { routeNotificationTap } from '@/lib/notification-routing';
|
||||||
|
|
||||||
const STORED_TOKEN_KEY = "expo_push_token";
|
const STORED_TOKEN_KEY = 'expo_push_token';
|
||||||
|
|
||||||
let configured = false;
|
let configured = false;
|
||||||
let tokenListenerSubscription: Notifications.Subscription | null = null;
|
let tokenListenerSubscription: Notifications.Subscription | null = null;
|
||||||
@@ -76,18 +76,18 @@ async function acquirePushToken(): Promise<string | null> {
|
|||||||
|
|
||||||
const existing = await Notifications.getPermissionsAsync();
|
const existing = await Notifications.getPermissionsAsync();
|
||||||
let status = existing.status;
|
let status = existing.status;
|
||||||
if (status !== "granted") {
|
if (status !== 'granted') {
|
||||||
const requested = await Notifications.requestPermissionsAsync();
|
const requested = await Notifications.requestPermissionsAsync();
|
||||||
status = requested.status;
|
status = requested.status;
|
||||||
}
|
}
|
||||||
if (status !== "granted") return null;
|
if (status !== 'granted') return null;
|
||||||
|
|
||||||
const projectId =
|
const projectId =
|
||||||
Constants.expoConfig?.extra?.eas?.projectId ??
|
Constants.expoConfig?.extra?.eas?.projectId ??
|
||||||
Constants.easConfig?.projectId;
|
Constants.easConfig?.projectId;
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
logError(new Error("EAS projectId missing — cannot fetch push token"), {
|
logError(new Error('EAS projectId missing — cannot fetch push token'), {
|
||||||
scope: "push.acquire",
|
scope: 'push.acquire',
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ async function setStoredToken(token: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await SecureStore.setItemAsync(STORED_TOKEN_KEY, token);
|
await SecureStore.setItemAsync(STORED_TOKEN_KEY, token);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "push.store" });
|
logError(err, { scope: 'push.store' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +133,8 @@ export async function syncPushToken(token?: string | null): Promise<void> {
|
|||||||
const stored = await getStoredToken();
|
const stored = await getStoredToken();
|
||||||
if (stored === next) return;
|
if (stored === next) return;
|
||||||
|
|
||||||
const platform = Platform.OS === "ios" ? "ios" : "android";
|
const platform = Platform.OS === 'ios' ? 'ios' : 'android';
|
||||||
const appVersion = Constants.expoConfig?.version ?? "";
|
const appVersion = Constants.expoConfig?.version ?? '';
|
||||||
|
|
||||||
await apiClient.registerPushToken({
|
await apiClient.registerPushToken({
|
||||||
token: next,
|
token: next,
|
||||||
@@ -143,7 +143,7 @@ export async function syncPushToken(token?: string | null): Promise<void> {
|
|||||||
});
|
});
|
||||||
await setStoredToken(next);
|
await setStoredToken(next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "push.sync" });
|
logError(err, { scope: 'push.sync' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ export async function unregisterPushToken(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await apiClient.unregisterPushToken(stored);
|
await apiClient.unregisterPushToken(stored);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "push.unregister" });
|
logError(err, { scope: 'push.unregister' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
* React Native ships a WebSocket polyfill, so this code runs unchanged.
|
* React Native ships a WebSocket polyfill, so this code runs unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { logError, reportError } from "@/lib/errors";
|
import { logError, reportError } from '@/lib/errors';
|
||||||
|
|
||||||
export type ConnectionState =
|
export type ConnectionState =
|
||||||
| "disconnected"
|
| 'disconnected'
|
||||||
| "connecting"
|
| 'connecting'
|
||||||
| "connected"
|
| 'connected'
|
||||||
| "reconnecting";
|
| 'reconnecting';
|
||||||
|
|
||||||
export interface ChannelMessage {
|
export interface ChannelMessage {
|
||||||
humanId: string;
|
humanId: string;
|
||||||
@@ -21,7 +21,7 @@ export interface ChannelMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ServerMessage {
|
interface ServerMessage {
|
||||||
type: "subscribed" | "join" | "leave" | "message" | "error";
|
type: 'subscribed' | 'join' | 'leave' | 'message' | 'error';
|
||||||
channel?: string;
|
channel?: string;
|
||||||
humanId?: string;
|
humanId?: string;
|
||||||
presence?: string[];
|
presence?: string[];
|
||||||
@@ -29,7 +29,7 @@ interface ServerMessage {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelEventType = "subscribed" | "join" | "leave" | "message";
|
type ChannelEventType = 'subscribed' | 'join' | 'leave' | 'message';
|
||||||
type ChannelEventCallback = (msg: ServerMessage) => void;
|
type ChannelEventCallback = (msg: ServerMessage) => void;
|
||||||
|
|
||||||
interface PusherClientConfig {
|
interface PusherClientConfig {
|
||||||
@@ -44,7 +44,7 @@ const PING_INTERVAL = 20000; // 20s — keeps alive through GKE gateway timeout
|
|||||||
export class PusherClient {
|
export class PusherClient {
|
||||||
private config: PusherClientConfig;
|
private config: PusherClientConfig;
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private state: ConnectionState = "disconnected";
|
private state: ConnectionState = 'disconnected';
|
||||||
private stateListeners = new Set<(state: ConnectionState) => void>();
|
private stateListeners = new Set<(state: ConnectionState) => void>();
|
||||||
|
|
||||||
private listeners = new Map<
|
private listeners = new Map<
|
||||||
@@ -73,20 +73,20 @@ export class PusherClient {
|
|||||||
|
|
||||||
const token = this.config.getToken();
|
const token = this.config.getToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.warn("[pusher] no token available, cannot connect");
|
console.warn('[pusher] no token available, cannot connect');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
this.setState(
|
this.setState(
|
||||||
this.state === "reconnecting" ? "reconnecting" : "connecting",
|
this.state === 'reconnecting' ? 'reconnecting' : 'connecting',
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = `${this.config.url}?token=${encodeURIComponent(token)}`;
|
const url = `${this.config.url}?token=${encodeURIComponent(token)}`;
|
||||||
this.ws = new WebSocket(url);
|
this.ws = new WebSocket(url);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.setState("connected");
|
this.setState('connected');
|
||||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||||
this.startPing();
|
this.startPing();
|
||||||
this.resubscribeAll();
|
this.resubscribeAll();
|
||||||
@@ -101,7 +101,7 @@ export class PusherClient {
|
|||||||
|
|
||||||
this.ws.onerror = (event) => {
|
this.ws.onerror = (event) => {
|
||||||
// onclose fires after onerror — reconnection is handled there.
|
// onclose fires after onerror — reconnection is handled there.
|
||||||
logError(event, { scope: "pusher.ws" });
|
logError(event, { scope: 'pusher.ws' });
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
@@ -114,21 +114,21 @@ export class PusherClient {
|
|||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
this.activeSubscriptions.clear();
|
this.activeSubscriptions.clear();
|
||||||
this.setState("disconnected");
|
this.setState('disconnected');
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(channelId: string): void {
|
subscribe(channelId: string): void {
|
||||||
this.activeSubscriptions.add(channelId);
|
this.activeSubscriptions.add(channelId);
|
||||||
this.send({ type: "subscribe", channel: channelId });
|
this.send({ type: 'subscribe', channel: channelId });
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribe(channelId: string): void {
|
unsubscribe(channelId: string): void {
|
||||||
this.activeSubscriptions.delete(channelId);
|
this.activeSubscriptions.delete(channelId);
|
||||||
this.send({ type: "unsubscribe", channel: channelId });
|
this.send({ type: 'unsubscribe', channel: channelId });
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(channelId: string, payload: unknown): void {
|
sendMessage(channelId: string, payload: unknown): void {
|
||||||
this.send({ type: "message", channel: channelId, payload });
|
this.send({ type: 'message', channel: channelId, payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
on(
|
on(
|
||||||
@@ -181,19 +181,19 @@ export class PusherClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(data: string): void {
|
private handleMessage(data: string): void {
|
||||||
if (data === "pong") return;
|
if (data === 'pong') return;
|
||||||
|
|
||||||
let msg: ServerMessage;
|
let msg: ServerMessage;
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(data);
|
msg = JSON.parse(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "pusher.parse", data });
|
logError(err, { scope: 'pusher.parse', data });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === "error") {
|
if (msg.type === 'error') {
|
||||||
logError(new Error(msg.message ?? "pusher server error"), {
|
logError(new Error(msg.message ?? 'pusher server error'), {
|
||||||
scope: "pusher.server",
|
scope: 'pusher.server',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -210,19 +210,19 @@ export class PusherClient {
|
|||||||
try {
|
try {
|
||||||
cb(msg);
|
cb(msg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reportError(err, { scope: "pusher.listener", channel: msg.channel });
|
reportError(err, { scope: 'pusher.listener', channel: msg.channel });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resubscribeAll(): void {
|
private resubscribeAll(): void {
|
||||||
for (const channelId of this.activeSubscriptions) {
|
for (const channelId of this.activeSubscriptions) {
|
||||||
this.send({ type: "subscribe", channel: channelId });
|
this.send({ type: 'subscribe', channel: channelId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
this.setState("reconnecting");
|
this.setState('reconnecting');
|
||||||
|
|
||||||
const jitter = Math.random() * 0.5 + 0.75;
|
const jitter = Math.random() * 0.5 + 0.75;
|
||||||
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
|
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
|
||||||
@@ -264,7 +264,7 @@ export class PusherClient {
|
|||||||
this.stopPing();
|
this.stopPing();
|
||||||
this.pingTimer = setInterval(() => {
|
this.pingTimer = setInterval(() => {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
this.ws.send("ping");
|
this.ws.send('ping');
|
||||||
}
|
}
|
||||||
}, PING_INTERVAL);
|
}, PING_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,37 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from 'react';
|
||||||
import { PusherClient, type ConnectionState } from "./pusher-client";
|
import { PusherClient, type ConnectionState } from './pusher-client';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { appConfig } from "@/config/env";
|
import { appConfig } from '@/config/env';
|
||||||
|
|
||||||
const PusherContext = createContext<PusherClient | null>(null);
|
const PusherContext = createContext<PusherClient | null>(null);
|
||||||
const PusherStateContext = createContext<ConnectionState>("disconnected");
|
const PusherStateContext = createContext<ConnectionState>('disconnected');
|
||||||
|
|
||||||
export function PusherProvider({ children }: { children: ReactNode }) {
|
export function PusherProvider({ children }: { children: ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
const clientRef = useRef<PusherClient | null>(null);
|
|
||||||
const [connectionState, setConnectionState] =
|
const [connectionState, setConnectionState] =
|
||||||
useState<ConnectionState>("disconnected");
|
useState<ConnectionState>('disconnected');
|
||||||
|
|
||||||
useEffect(() => {
|
const client = useMemo(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (clientRef.current) {
|
return null;
|
||||||
clientRef.current.disconnect();
|
|
||||||
clientRef.current = null;
|
|
||||||
setConnectionState("disconnected");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new PusherClient({
|
return new PusherClient({
|
||||||
url: appConfig.pusherUrl,
|
url: appConfig.pusherUrl,
|
||||||
getToken: () => useAuthStore.getState().token,
|
getToken: () => useAuthStore.getState().token,
|
||||||
});
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
clientRef.current = client;
|
useEffect(() => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const unsubscribeState = client.onStateChange((state) => {
|
const unsubscribeState = client.onStateChange((state) => {
|
||||||
setConnectionState(state);
|
setConnectionState(state);
|
||||||
@@ -45,12 +43,12 @@ export function PusherProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribeState();
|
unsubscribeState();
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
clientRef.current = null;
|
setConnectionState('disconnected');
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [client]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PusherContext.Provider value={clientRef.current}>
|
<PusherContext.Provider value={client}>
|
||||||
<PusherStateContext.Provider value={connectionState}>
|
<PusherStateContext.Provider value={connectionState}>
|
||||||
{children}
|
{children}
|
||||||
</PusherStateContext.Provider>
|
</PusherStateContext.Provider>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import {
|
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
|
||||||
MutationCache,
|
import { toast } from 'sonner-native';
|
||||||
QueryCache,
|
import { ApiError, logError, reportError, toUserMessage } from '@/lib/errors';
|
||||||
QueryClient,
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
|
||||||
|
|
||||||
declare module "@tanstack/react-query" {
|
declare module '@tanstack/react-query' {
|
||||||
interface Register {
|
interface Register {
|
||||||
queryMeta: { toastOnError?: boolean };
|
queryMeta: { toastOnError?: boolean };
|
||||||
mutationMeta: { suppressToast?: boolean };
|
mutationMeta: { suppressToast?: boolean };
|
||||||
@@ -48,7 +44,7 @@ export function createQueryClient(): QueryClient {
|
|||||||
queryCache: new QueryCache({
|
queryCache: new QueryCache({
|
||||||
onError: (err, query) => {
|
onError: (err, query) => {
|
||||||
handleUnauthorized(err);
|
handleUnauthorized(err);
|
||||||
logError(err, { scope: "query", queryKey: query.queryKey });
|
logError(err, { scope: 'query', queryKey: query.queryKey });
|
||||||
if (query.meta?.toastOnError) {
|
if (query.meta?.toastOnError) {
|
||||||
toast.error(toUserMessage(err));
|
toast.error(toUserMessage(err));
|
||||||
}
|
}
|
||||||
@@ -58,7 +54,7 @@ export function createQueryClient(): QueryClient {
|
|||||||
onError: (err, _variables, _context, mutation) => {
|
onError: (err, _variables, _context, mutation) => {
|
||||||
handleUnauthorized(err);
|
handleUnauthorized(err);
|
||||||
reportError(err, {
|
reportError(err, {
|
||||||
scope: "mutation",
|
scope: 'mutation',
|
||||||
mutationKey: mutation.options.mutationKey,
|
mutationKey: mutation.options.mutationKey,
|
||||||
});
|
});
|
||||||
if (mutation.meta?.suppressToast) return;
|
if (mutation.meta?.suppressToast) return;
|
||||||
|
|||||||
@@ -1,15 +1,67 @@
|
|||||||
const ADJECTIVES = [
|
const ADJECTIVES = [
|
||||||
"amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle",
|
'amber',
|
||||||
"hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal",
|
'bold',
|
||||||
"pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty",
|
'calm',
|
||||||
"bright", "clear", "deep", "fresh", "grand", "swift",
|
'crisp',
|
||||||
|
'dark',
|
||||||
|
'eager',
|
||||||
|
'faint',
|
||||||
|
'gentle',
|
||||||
|
'hasty',
|
||||||
|
'icy',
|
||||||
|
'jade',
|
||||||
|
'keen',
|
||||||
|
'lush',
|
||||||
|
'misty',
|
||||||
|
'nimble',
|
||||||
|
'opal',
|
||||||
|
'pale',
|
||||||
|
'quiet',
|
||||||
|
'rapid',
|
||||||
|
'sharp',
|
||||||
|
'taut',
|
||||||
|
'vivid',
|
||||||
|
'warm',
|
||||||
|
'zesty',
|
||||||
|
'bright',
|
||||||
|
'clear',
|
||||||
|
'deep',
|
||||||
|
'fresh',
|
||||||
|
'grand',
|
||||||
|
'swift',
|
||||||
];
|
];
|
||||||
|
|
||||||
const NOUNS = [
|
const NOUNS = [
|
||||||
"arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor",
|
'arrow',
|
||||||
"iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal",
|
'bloom',
|
||||||
"quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith",
|
'cedar',
|
||||||
"brook", "cliff", "delta", "frost", "glow", "reef",
|
'drift',
|
||||||
|
'ember',
|
||||||
|
'flint',
|
||||||
|
'grove',
|
||||||
|
'harbor',
|
||||||
|
'iris',
|
||||||
|
'jewel',
|
||||||
|
'knoll',
|
||||||
|
'lake',
|
||||||
|
'moss',
|
||||||
|
'nova',
|
||||||
|
'orbit',
|
||||||
|
'petal',
|
||||||
|
'quartz',
|
||||||
|
'ridge',
|
||||||
|
'spark',
|
||||||
|
'trail',
|
||||||
|
'vale',
|
||||||
|
'wave',
|
||||||
|
'yarn',
|
||||||
|
'zenith',
|
||||||
|
'brook',
|
||||||
|
'cliff',
|
||||||
|
'delta',
|
||||||
|
'frost',
|
||||||
|
'glow',
|
||||||
|
'reef',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function generateRandomName(): string {
|
export function generateRandomName(): string {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { setAudioModeAsync, setIsAudioActiveAsync } from "expo-audio";
|
import { setAudioModeAsync, setIsAudioActiveAsync } from 'expo-audio';
|
||||||
|
|
||||||
// Around camera/mic recording we switch the iOS audio session to playAndRecord
|
// Around camera/mic recording we switch the iOS audio session to playAndRecord
|
||||||
// with `doNotMix`, which cleanly interrupts other apps' audio (Spotify, Apple
|
// with `doNotMix`, which cleanly interrupts other apps' audio (Spotify, Apple
|
||||||
@@ -10,7 +10,7 @@ export async function acquireRecordingAudioSession() {
|
|||||||
await setAudioModeAsync({
|
await setAudioModeAsync({
|
||||||
allowsRecording: true,
|
allowsRecording: true,
|
||||||
playsInSilentMode: true,
|
playsInSilentMode: true,
|
||||||
interruptionMode: "doNotMix",
|
interruptionMode: 'doNotMix',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export async function releaseRecordingAudioSession() {
|
|||||||
await setAudioModeAsync({
|
await setAudioModeAsync({
|
||||||
allowsRecording: false,
|
allowsRecording: false,
|
||||||
playsInSilentMode: true,
|
playsInSilentMode: true,
|
||||||
interruptionMode: "mixWithOthers",
|
interruptionMode: 'mixWithOthers',
|
||||||
});
|
});
|
||||||
await setIsAudioActiveAsync(false);
|
await setIsAudioActiveAsync(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { removeDuplicates } from "@/lib/utils";
|
import { removeDuplicates } from '@/lib/utils';
|
||||||
|
|
||||||
const HUMAN_PREFIX = "human:";
|
const HUMAN_PREFIX = 'human:';
|
||||||
const NETWORK_PREFIX = "network:";
|
const NETWORK_PREFIX = 'network:';
|
||||||
|
|
||||||
export type StreamVisibility =
|
export type StreamVisibility =
|
||||||
| { mode: "network" }
|
| { mode: 'network' }
|
||||||
| { mode: "custom"; humanIds: string[] };
|
| { mode: 'custom'; humanIds: string[] };
|
||||||
|
|
||||||
export function parseVisibleTo(
|
export function parseVisibleTo(
|
||||||
visibleTo: string[],
|
visibleTo: string[],
|
||||||
networkId: string,
|
networkId: string,
|
||||||
): StreamVisibility {
|
): StreamVisibility {
|
||||||
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
|
if (visibleTo.includes(`${NETWORK_PREFIX}${networkId}`)) {
|
||||||
return { mode: "network" };
|
return { mode: 'network' };
|
||||||
}
|
}
|
||||||
const humanIds = visibleTo
|
const humanIds = visibleTo
|
||||||
.filter((v) => v.startsWith(HUMAN_PREFIX))
|
.filter((v) => v.startsWith(HUMAN_PREFIX))
|
||||||
.map((v) => v.slice(HUMAN_PREFIX.length));
|
.map((v) => v.slice(HUMAN_PREFIX.length));
|
||||||
return { mode: "custom", humanIds };
|
return { mode: 'custom', humanIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNetworkVisibility(networkId: string): string[] {
|
export function buildNetworkVisibility(networkId: string): string[] {
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ const MONTH = 2592000;
|
|||||||
const YEAR = 31536000;
|
const YEAR = 31536000;
|
||||||
|
|
||||||
export function formatDistanceToNow(date: Date | string): string {
|
export function formatDistanceToNow(date: Date | string): string {
|
||||||
const ms = typeof date === "string" ? new Date(date).getTime() : date.getTime();
|
const ms =
|
||||||
|
typeof date === 'string' ? new Date(date).getTime() : date.getTime();
|
||||||
const seconds = Math.floor((Date.now() - ms) / 1000);
|
const seconds = Math.floor((Date.now() - ms) / 1000);
|
||||||
|
|
||||||
if (seconds < 5) return "just now";
|
if (seconds < 5) return 'just now';
|
||||||
if (seconds < MINUTE) return `${seconds}s ago`;
|
if (seconds < MINUTE) return `${seconds}s ago`;
|
||||||
if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
|
if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
|
||||||
if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
|
if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
|
||||||
|
|||||||
+20
-27
@@ -2,17 +2,17 @@ import {
|
|||||||
FileSystemUploadType,
|
FileSystemUploadType,
|
||||||
getInfoAsync,
|
getInfoAsync,
|
||||||
uploadAsync,
|
uploadAsync,
|
||||||
} from "expo-file-system/legacy";
|
} from 'expo-file-system/legacy';
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from '@/api/client';
|
||||||
import {
|
import {
|
||||||
createParticle,
|
createParticle,
|
||||||
createStreamParticle,
|
createStreamParticle,
|
||||||
} from "@/lib/firestore-particles";
|
} from '@/lib/firestore-particles';
|
||||||
import {
|
import {
|
||||||
particlePath,
|
particlePath,
|
||||||
toFirestoreChildrenPath,
|
toFirestoreChildrenPath,
|
||||||
type ParticlePath,
|
type ParticlePath,
|
||||||
} from "@/lib/particle-path";
|
} from '@/lib/particle-path';
|
||||||
|
|
||||||
interface UploadMediaParticleParams {
|
interface UploadMediaParticleParams {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
@@ -21,7 +21,7 @@ interface UploadMediaParticleParams {
|
|||||||
fileUri: string;
|
fileUri: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
source: "camera" | "screen";
|
source: 'camera' | 'screen';
|
||||||
createdByHumanId: string;
|
createdByHumanId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,11 +44,11 @@ export async function uploadMediaParticle({
|
|||||||
}: UploadMediaParticleParams): Promise<string> {
|
}: UploadMediaParticleParams): Promise<string> {
|
||||||
const info = await getInfoAsync(fileUri);
|
const info = await getInfoAsync(fileUri);
|
||||||
if (!info.exists || info.size === undefined) {
|
if (!info.exists || info.size === undefined) {
|
||||||
throw new Error("Recording file disappeared before upload.");
|
throw new Error('Recording file disappeared before upload.');
|
||||||
}
|
}
|
||||||
const sizeBytes = info.size;
|
const sizeBytes = info.size;
|
||||||
|
|
||||||
const namePrefix = mimeType.startsWith("audio/") ? "voice" : "video";
|
const namePrefix = mimeType.startsWith('audio/') ? 'voice' : 'video';
|
||||||
const ext = extensionFromMime(mimeType);
|
const ext = extensionFromMime(mimeType);
|
||||||
const name = `${namePrefix}-${Date.now()}${ext}`;
|
const name = `${namePrefix}-${Date.now()}${ext}`;
|
||||||
|
|
||||||
@@ -61,15 +61,13 @@ export async function uploadMediaParticle({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const uploadResult = await uploadAsync(upload_url, fileUri, {
|
const uploadResult = await uploadAsync(upload_url, fileUri, {
|
||||||
httpMethod: "PUT",
|
httpMethod: 'PUT',
|
||||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||||
headers: upload_headers,
|
headers: upload_headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (uploadResult.status < 200 || uploadResult.status >= 300) {
|
if (uploadResult.status < 200 || uploadResult.status >= 300) {
|
||||||
throw new Error(
|
throw new Error(`Upload to depot failed (HTTP ${uploadResult.status}).`);
|
||||||
`Upload to depot failed (HTTP ${uploadResult.status}).`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.confirmUpload(object_id);
|
await apiClient.confirmUpload(object_id);
|
||||||
@@ -77,7 +75,7 @@ export async function uploadMediaParticle({
|
|||||||
const collectionPath = toFirestoreChildrenPath(targetPath);
|
const collectionPath = toFirestoreChildrenPath(targetPath);
|
||||||
return createParticle(
|
return createParticle(
|
||||||
collectionPath,
|
collectionPath,
|
||||||
"media",
|
'media',
|
||||||
{
|
{
|
||||||
object_id,
|
object_id,
|
||||||
mime_type: mimeType,
|
mime_type: mimeType,
|
||||||
@@ -102,20 +100,15 @@ export async function createTextParticle({
|
|||||||
createdByHumanId,
|
createdByHumanId,
|
||||||
}: CreateTextParticleParams): Promise<string> {
|
}: CreateTextParticleParams): Promise<string> {
|
||||||
const collectionPath = toFirestoreChildrenPath(targetPath);
|
const collectionPath = toFirestoreChildrenPath(targetPath);
|
||||||
return createParticle(
|
return createParticle(collectionPath, 'text', { content }, createdByHumanId);
|
||||||
collectionPath,
|
|
||||||
"text",
|
|
||||||
{ content },
|
|
||||||
createdByHumanId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extensionFromMime(mime: string): string {
|
function extensionFromMime(mime: string): string {
|
||||||
if (mime === "video/mp4") return ".mp4";
|
if (mime === 'video/mp4') return '.mp4';
|
||||||
if (mime === "video/quicktime") return ".mov";
|
if (mime === 'video/quicktime') return '.mov';
|
||||||
if (mime === "audio/mp4") return ".m4a";
|
if (mime === 'audio/mp4') return '.m4a';
|
||||||
if (mime === "audio/webm") return ".webm";
|
if (mime === 'audio/webm') return '.webm';
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper kept here so callers can construct a fresh stream's child-path before
|
// Helper kept here so callers can construct a fresh stream's child-path before
|
||||||
@@ -137,13 +130,13 @@ interface CreateStreamWithFirstParticleParams {
|
|||||||
createdByHumanId: string;
|
createdByHumanId: string;
|
||||||
/** First particle to write into the new stream. Required — empty streams are not useful. */
|
/** First particle to write into the new stream. Required — empty streams are not useful. */
|
||||||
firstParticle:
|
firstParticle:
|
||||||
| { type: "text"; content: string }
|
| { type: 'text'; content: string }
|
||||||
| {
|
| {
|
||||||
type: "media";
|
type: 'media';
|
||||||
fileUri: string;
|
fileUri: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
source: "camera" | "screen";
|
source: 'camera' | 'screen';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +171,7 @@ export async function createStreamWithFirstParticle({
|
|||||||
const streamPath = particlePath(networkId, [streamId]);
|
const streamPath = particlePath(networkId, [streamId]);
|
||||||
|
|
||||||
// 2. The first child goes inside the new stream.
|
// 2. The first child goes inside the new stream.
|
||||||
if (firstParticle.type === "text") {
|
if (firstParticle.type === 'text') {
|
||||||
await createTextParticle({
|
await createTextParticle({
|
||||||
networkId,
|
networkId,
|
||||||
targetPath: streamPath,
|
targetPath: streamPath,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitials(email: string): string {
|
export function getInitials(email: string): string {
|
||||||
const prefix = email.split("@")[0] ?? "";
|
const prefix = email.split('@')[0] ?? '';
|
||||||
return prefix.slice(0, 2).toUpperCase();
|
return prefix.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import { ActivityIndicator, View } from "react-native";
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { SignInScreen } from "@/features/auth/SignInScreen";
|
import { SignInScreen } from '@/features/auth/SignInScreen';
|
||||||
import { NetworkListScreen } from "@/features/networks/NetworkListScreen";
|
import { NetworkListScreen } from '@/features/networks/NetworkListScreen';
|
||||||
import { StreamListScreen } from "@/features/streams/StreamListScreen";
|
import { StreamListScreen } from '@/features/streams/StreamListScreen';
|
||||||
import { NewStreamScreen } from "@/features/streams/NewStreamScreen";
|
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
||||||
import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen";
|
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
||||||
import { HuddleScreen } from "@/features/huddle/HuddleScreen";
|
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
||||||
import { SettingsScreen } from "@/features/settings/SettingsScreen";
|
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
||||||
import { AccountScreen } from "@/features/settings/AccountScreen";
|
import { AccountScreen } from '@/features/settings/AccountScreen';
|
||||||
import type { RootStackParamList } from "./types";
|
import type { RootStackParamList } from './types';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
export function RootNavigator() {
|
export function RootNavigator() {
|
||||||
const status = useAuthStore((s) => s.status);
|
const status = useAuthStore((s) => s.status);
|
||||||
|
|
||||||
if (status === "idle" || status === "restoring") {
|
if (status === 'idle' || status === 'restoring') {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center bg-background">
|
<View className="flex-1 items-center justify-center bg-background">
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
@@ -24,7 +24,7 @@ export function RootNavigator() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "unauthenticated") {
|
if (status === 'unauthenticated') {
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="SignIn" component={SignInScreen} />
|
<Stack.Screen name="SignIn" component={SignInScreen} />
|
||||||
@@ -42,17 +42,17 @@ export function RootNavigator() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="StreamView"
|
name="StreamView"
|
||||||
component={StreamViewScreen}
|
component={StreamViewScreen}
|
||||||
options={{ animation: "fade", gestureEnabled: false }}
|
options={{ animation: 'fade', gestureEnabled: false }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Huddle"
|
name="Huddle"
|
||||||
component={HuddleScreen}
|
component={HuddleScreen}
|
||||||
options={{ animation: "slide_from_bottom", gestureEnabled: false }}
|
options={{ animation: 'slide_from_bottom', gestureEnabled: false }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="NewStream"
|
name="NewStream"
|
||||||
component={NewStreamScreen}
|
component={NewStreamScreen}
|
||||||
options={{ animation: "slide_from_bottom" }}
|
options={{ animation: 'slide_from_bottom' }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="Settings" component={SettingsScreen} />
|
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||||
<Stack.Screen name="Account" component={AccountScreen} />
|
<Stack.Screen name="Account" component={AccountScreen} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
|
||||||
// Pure stack model from PRD §5. Drawer affordance lives inside the
|
// Pure stack model from PRD §5. Drawer affordance lives inside the
|
||||||
// NetworkList screen itself, not the navigator — see Drawer.tsx.
|
// NetworkList screen itself, not the navigator — see Drawer.tsx.
|
||||||
@@ -24,6 +24,10 @@ export type RootStackScreenProps<T extends keyof RootStackParamList> =
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace ReactNavigation {
|
namespace ReactNavigation {
|
||||||
|
// React Navigation wires up typed navigation by merging into this global
|
||||||
|
// interface. It must stay an `interface` (type aliases can't be augmented)
|
||||||
|
// and is intentionally empty — it only re-exports our param list.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
interface RootParamList extends RootStackParamList {}
|
interface RootParamList extends RootStackParamList {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import { create } from "zustand";
|
import { create } from 'zustand';
|
||||||
import {
|
import {
|
||||||
signInWithCustomToken,
|
signInWithCustomToken,
|
||||||
signOut as firebaseSignOut,
|
signOut as firebaseSignOut,
|
||||||
} from "firebase/auth";
|
} from 'firebase/auth';
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from '@/api/client';
|
||||||
import type { Human } from "@/api/types";
|
import type { Human } from '@/api/types';
|
||||||
import { firebaseAuth } from "@/firebase";
|
import { firebaseAuth } from '@/firebase';
|
||||||
import { logError, ApiError } from "@/lib/errors";
|
import { logError, ApiError } from '@/lib/errors';
|
||||||
import {
|
import {
|
||||||
startPushTokenSync,
|
startPushTokenSync,
|
||||||
stopPushTokenSync,
|
stopPushTokenSync,
|
||||||
syncPushToken,
|
syncPushToken,
|
||||||
unregisterPushToken,
|
unregisterPushToken,
|
||||||
} from "@/lib/push-notifications";
|
} from '@/lib/push-notifications';
|
||||||
|
|
||||||
const AUTH_TOKEN_KEY = "auth_token";
|
const AUTH_TOKEN_KEY = 'auth_token';
|
||||||
|
|
||||||
async function readPersistedToken(): Promise<string | null> {
|
async function readPersistedToken(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -45,11 +45,11 @@ async function signInToFirebase(): Promise<void> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Firestore subscriptions will fail until the next successful sign-in; the
|
// Firestore subscriptions will fail until the next successful sign-in; the
|
||||||
// rest of the app keeps working against Orion. Sentry catches the failure.
|
// rest of the app keeps working against Orion. Sentry catches the failure.
|
||||||
logError(err, { scope: "auth.firebase" });
|
logError(err, { scope: 'auth.firebase' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated";
|
type AuthStatus = 'idle' | 'restoring' | 'unauthenticated' | 'authenticated';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
status: AuthStatus;
|
status: AuthStatus;
|
||||||
@@ -73,7 +73,7 @@ interface AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
status: "idle",
|
status: 'idle',
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
isRequestingCode: false,
|
isRequestingCode: false,
|
||||||
@@ -82,11 +82,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
restoreSession: async () => {
|
restoreSession: async () => {
|
||||||
set({ status: "restoring" });
|
set({ status: 'restoring' });
|
||||||
|
|
||||||
const token = await readPersistedToken();
|
const token = await readPersistedToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
set({ status: "unauthenticated" });
|
set({ status: 'unauthenticated' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +96,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const user = await apiClient.me();
|
const user = await apiClient.me();
|
||||||
await signInToFirebase();
|
await signInToFirebase();
|
||||||
set({ status: "authenticated", user });
|
set({ status: 'authenticated', user });
|
||||||
startPushTokenSync();
|
startPushTokenSync();
|
||||||
void syncPushToken();
|
void syncPushToken();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Expected on expired/invalid tokens — fall back to the login screen.
|
// Expected on expired/invalid tokens — fall back to the login screen.
|
||||||
logError(err, { scope: "auth.restore" });
|
logError(err, { scope: 'auth.restore' });
|
||||||
await get().invalidateSession();
|
await get().invalidateSession();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,8 +111,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
await apiClient.requestCode({ email });
|
await apiClient.requestCode({ email });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message =
|
const message = e instanceof ApiError ? e.message : 'Failed to send code';
|
||||||
e instanceof ApiError ? e.message : "Failed to send code";
|
|
||||||
set({ error: message });
|
set({ error: message });
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -128,11 +127,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
apiClient.setToken(token);
|
apiClient.setToken(token);
|
||||||
set({ token });
|
set({ token });
|
||||||
await signInToFirebase();
|
await signInToFirebase();
|
||||||
set({ status: "authenticated", user: human });
|
set({ status: 'authenticated', user: human });
|
||||||
startPushTokenSync();
|
startPushTokenSync();
|
||||||
void syncPushToken();
|
void syncPushToken();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof ApiError ? e.message : "Failed to sign in";
|
const message = e instanceof ApiError ? e.message : 'Failed to sign in';
|
||||||
set({ error: message });
|
set({ error: message });
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -150,15 +149,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
await apiClient.signOut();
|
await apiClient.signOut();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort — sign out locally regardless.
|
// Best-effort — sign out locally regardless.
|
||||||
logError(err, { scope: "auth.signOut" });
|
logError(err, { scope: 'auth.signOut' });
|
||||||
} finally {
|
} finally {
|
||||||
await firebaseSignOut(firebaseAuth).catch((err) =>
|
await firebaseSignOut(firebaseAuth).catch((err) =>
|
||||||
logError(err, { scope: "auth.firebaseSignOut" }),
|
logError(err, { scope: 'auth.firebaseSignOut' }),
|
||||||
);
|
);
|
||||||
apiClient.setToken(null);
|
apiClient.setToken(null);
|
||||||
await clearPersistedToken();
|
await clearPersistedToken();
|
||||||
set({
|
set({
|
||||||
status: "unauthenticated",
|
status: 'unauthenticated',
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
isSigningOut: false,
|
isSigningOut: false,
|
||||||
@@ -171,7 +170,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
stopPushTokenSync();
|
stopPushTokenSync();
|
||||||
apiClient.setToken(null);
|
apiClient.setToken(null);
|
||||||
await clearPersistedToken();
|
await clearPersistedToken();
|
||||||
set({ status: "unauthenticated", user: null, token: null });
|
set({ status: 'unauthenticated', user: null, token: null });
|
||||||
|
|
||||||
await unregisterPushToken(); // Best-effort
|
await unregisterPushToken(); // Best-effort
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { create } from "zustand";
|
import { create } from 'zustand';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for "is stream playback paused." Each component that
|
* Single source of truth for "is stream playback paused." Each component that
|
||||||
|
|||||||
+1293
-18
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user