3d80ac2993
* mobile: add invitations, network creation, and settings (parity phase 1) Surface backend capabilities that already existed in the mobile API client but had no UI: - NetworkListScreen now lists pending invitations with an Accept action and a header "+" to create a network; empty state offers creation instead of pointing users to desktop. - New CreateNetworkSheet and use-invitations hooks (accept invite, create network) following the existing react-query patterns. - SettingsScreen replaces its placeholder with an email-notifications toggle (optimistic, mirrors desktop), app version, and sign out. - Wire the previously-unreachable Settings row into the Drawer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV * mobile: network member management and avatars (parity phase 2) - New NetworkSettingsScreen (reachable from the stream-list header) lists members with admin remove, an invite-by-email sheet, and pending invitations with revoke — backed by new use-member-management hooks. - Avatars: add avatar_object_id to HumanSchema, uploadAvatar/deleteAvatar/ getAvatarDownloadUrl client methods (raw PUT via expo-file-system), a use-avatar-url hook, and image rendering in the shared Avatar component. AccountScreen gains a tap-to-change profile picture via expo-image-picker. - auth-store gains refreshUser to pick up avatar changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV * mobile: task particles — view, edit, and compose (parity phase 3) Bring the task particle to parity with desktop's richer model: - Replace the thin `quest` schema with desktop's `task` model (ChecklistItem, TaskProperties: title/notes/checklist/assigned_to/done) in the discriminated union, the Firestore converter, and consumers (StreamCard, FallbackParticleView). - New TaskParticleView renders an editable card (round done checkbox, title, notes, checklist with add/toggle/edit/remove, assignee chips) persisting each edit to Firestore; an 8s dwell auto-advances and field focus suspends playback. Wired into StreamView's render switch. - Compose: a task button in the ComposeDock opens a TaskComposeSheet (createTaskParticle helper). Gated off in the new-stream flow, where a stream's first particle must be text or media. Note: particles are written client-side to Firestore, matching desktop; Orion's REST validator still only accepts `quest`, which is a pre-existing inconsistency to reconcile backend-side separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV * mobile: paper and file particle views (parity phase 4) - Extract the shared markdown renderer/theme out of TextParticleView into a reusable MarkdownBody component (DRY). - PaperParticleView renders desktop-authored documents (title + markdown) with a length-based dwell. - FileParticleView shows name/size and a Download action that opens a signed URL via the OS. - Both wired into StreamView's render switch; FallbackParticleView is now a true catch-all for unknown/folder types only. Deferred (documented for a follow-up phase): composing papers/files from mobile, particle attachments + lightbox, and link previews in text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV * mobile: billing & usage in network settings (parity phase 5) Surface the network plan, daily usage, and Stripe management — all backed by client methods that already existed. New use-billing hooks and a BillingSection (mirroring desktop): every member sees the plan + usage summary; admins get cadence selection + "Upgrade to Pro" (checkout) and "Manage subscription" (portal), opening Stripe in the system browser. Added to NetworkSettingsScreen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV * mobile: let users dismiss the keyboard from a task card Focusing a task field opened the keyboard with no way out — it covered the card and the stream's tap-to-advance zones. Now: - A "Done" pill appears at the card's top-right while editing (reusing the existing `editing` flag) and calls Keyboard.dismiss(); the title row reserves space so the pill never overlaps a long title. - The card ScrollView gains keyboardDismissMode (interactive on iOS, on-drag on Android) so dragging the card also dismisses the keyboard. Dismissing blurs the active field, which flips `editing` off and resumes the dwell timer and tap navigation automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV * decrease clutter in stream-view * format * format * consolidate avatar --------- Co-authored-by: Claude <noreply@anthropic.com>
469 lines
14 KiB
TypeScript
469 lines
14 KiB
TypeScript
import {
|
|
collection,
|
|
doc,
|
|
onSnapshot,
|
|
addDoc,
|
|
getDoc,
|
|
getDocs,
|
|
updateDoc,
|
|
query,
|
|
orderBy,
|
|
limit,
|
|
serverTimestamp,
|
|
where,
|
|
Timestamp,
|
|
arrayUnion,
|
|
arrayRemove,
|
|
FieldPath,
|
|
type DocumentData,
|
|
type DocumentSnapshot,
|
|
type FirestoreDataConverter,
|
|
type QueryDocumentSnapshot,
|
|
type SnapshotOptions,
|
|
type Unsubscribe,
|
|
type QueryFieldFilterConstraint,
|
|
} from 'firebase/firestore';
|
|
import { firestoreDb } from '@/firebase';
|
|
import { isContainerType, ParticleSchema } from '@/api/types';
|
|
import type {
|
|
Particle,
|
|
ParticleType,
|
|
ParticlePropertiesMap,
|
|
Reactions,
|
|
} from '@/api/types';
|
|
|
|
// --- Converter ---
|
|
|
|
const particleConverter: FirestoreDataConverter<Particle> = {
|
|
toFirestore(particle: Particle): DocumentData {
|
|
const { id: _id, created_at, updated_at, ...rest } = particle;
|
|
const deletedAt =
|
|
'deleted_at' in particle ? particle.deleted_at : undefined;
|
|
return {
|
|
...rest,
|
|
created_at: Timestamp.fromDate(created_at),
|
|
...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
|
|
...(deletedAt && { deleted_at: Timestamp.fromDate(deletedAt) }),
|
|
};
|
|
},
|
|
fromFirestore(
|
|
snap: QueryDocumentSnapshot,
|
|
options?: SnapshotOptions,
|
|
): Particle {
|
|
const raw = snap.data(options);
|
|
if (typeof raw.type !== 'string') {
|
|
throw new Error(`Invalid particle type: ${raw.type}`);
|
|
}
|
|
const type = raw.type as ParticleType;
|
|
switch (type) {
|
|
case 'stream':
|
|
return ParticleSchema.parse({
|
|
id: snap.id,
|
|
type: raw.type,
|
|
properties: raw.properties,
|
|
created_at: (raw.created_at as Timestamp).toDate(),
|
|
created_by_human_id: raw.created_by_human_id,
|
|
updated_at: raw.updated_at
|
|
? (raw.updated_at as Timestamp).toDate()
|
|
: undefined,
|
|
visible_to: raw.visible_to,
|
|
playback_markers: raw.playback_markers
|
|
? Object.fromEntries(
|
|
Object.entries(raw.playback_markers).map(([key, value]) => [
|
|
key,
|
|
(value as Timestamp).toDate(),
|
|
]),
|
|
)
|
|
: undefined,
|
|
last_child_created_at: raw.last_child_created_at
|
|
? (raw.last_child_created_at as Timestamp).toDate()
|
|
: undefined,
|
|
huddle_active_participants:
|
|
raw.huddle_active_participants ?? undefined,
|
|
status: raw.status ?? undefined,
|
|
});
|
|
case 'folder':
|
|
return ParticleSchema.parse({
|
|
id: snap.id,
|
|
type: raw.type,
|
|
properties: raw.properties,
|
|
created_at: (raw.created_at as Timestamp).toDate(),
|
|
created_by_human_id: raw.created_by_human_id,
|
|
updated_at: raw.updated_at
|
|
? (raw.updated_at as Timestamp).toDate()
|
|
: undefined,
|
|
visible_to: raw.visible_to,
|
|
});
|
|
case 'media':
|
|
case 'file':
|
|
case 'text':
|
|
case 'task':
|
|
case 'paper': {
|
|
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
|
// particles carry `properties.edited_at`, so coerce it if present.
|
|
const properties =
|
|
type === 'text' && raw.properties?.edited_at
|
|
? {
|
|
...raw.properties,
|
|
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
|
|
}
|
|
: raw.properties;
|
|
return ParticleSchema.parse({
|
|
id: snap.id,
|
|
type: raw.type,
|
|
properties,
|
|
created_at: (raw.created_at as Timestamp).toDate(),
|
|
created_by_human_id: raw.created_by_human_id,
|
|
updated_at: raw.updated_at
|
|
? (raw.updated_at as Timestamp).toDate()
|
|
: undefined,
|
|
reactions: raw.reactions ?? undefined,
|
|
deleted_at: raw.deleted_at
|
|
? (raw.deleted_at as Timestamp).toDate()
|
|
: undefined,
|
|
deleted_by_human_id: raw.deleted_by_human_id ?? undefined,
|
|
});
|
|
}
|
|
default:
|
|
throw new Error(`Unknown particle type: ${type}`);
|
|
}
|
|
},
|
|
};
|
|
|
|
// Parse a snapshot defensively: newer clients may write particle types this
|
|
// app version doesn't know yet. A single unparseable doc must not break the
|
|
// whole subscription, so skip it instead of throwing inside onSnapshot.
|
|
function safeData(snap: DocumentSnapshot<Particle>): Particle | null {
|
|
try {
|
|
return snap.data() ?? null;
|
|
} catch (error) {
|
|
console.warn(`Skipping unparseable particle ${snap.ref.path}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- Typed reference helpers ---
|
|
|
|
function typedDoc(path: string) {
|
|
return doc(firestoreDb, path).withConverter(particleConverter);
|
|
}
|
|
|
|
function typedCollection(path: string) {
|
|
return collection(firestoreDb, path).withConverter(particleConverter);
|
|
}
|
|
|
|
// --- Exported operations ---
|
|
|
|
export function subscribeToParticle(
|
|
docPath: string,
|
|
onData: (particle: Particle | null) => void,
|
|
onError: (error: Error) => void,
|
|
): Unsubscribe {
|
|
return onSnapshot(
|
|
typedDoc(docPath),
|
|
(snap) => {
|
|
onData(safeData(snap));
|
|
},
|
|
onError,
|
|
);
|
|
}
|
|
|
|
export async function getParticle(docPath: string): Promise<Particle | null> {
|
|
const docSnap = await getDoc(typedDoc(docPath));
|
|
if (!docSnap.exists()) {
|
|
return null;
|
|
}
|
|
return safeData(docSnap);
|
|
}
|
|
|
|
export interface GetParticleChildrenOptions {
|
|
orderByField: string;
|
|
orderDirection: 'asc' | 'desc';
|
|
}
|
|
|
|
export async function getParticleChildren(
|
|
collectionPath: string,
|
|
{
|
|
orderByField = 'created_at',
|
|
orderDirection = 'asc',
|
|
}: GetParticleChildrenOptions = {
|
|
orderByField: 'created_at',
|
|
orderDirection: 'asc',
|
|
},
|
|
): Promise<Particle[]> {
|
|
const q = query(
|
|
typedCollection(collectionPath),
|
|
orderBy(orderByField, orderDirection),
|
|
);
|
|
const snap = await getDocs(q);
|
|
return snap.docs
|
|
.map((d) => safeData(d))
|
|
.filter((p): p is Particle => p !== null);
|
|
}
|
|
|
|
export interface SubscribeToParticleChildrenOptions {
|
|
onData: (children: Particle[]) => void;
|
|
onError: (error: Error) => void;
|
|
visibilityScopes?: string[];
|
|
orderByField?: string;
|
|
orderDirection?: 'asc' | 'desc';
|
|
onAdded?: (child: Particle) => void;
|
|
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
|
|
whereFilter?: QueryFieldFilterConstraint;
|
|
/** Optional cap on results. Applied after order/where constraints. */
|
|
limit?: number;
|
|
}
|
|
|
|
export function subscribeToParticleChildren(
|
|
collectionPath: string,
|
|
{
|
|
onData,
|
|
onError,
|
|
visibilityScopes = [],
|
|
orderByField = 'created_at',
|
|
orderDirection = 'desc',
|
|
onAdded,
|
|
onRemoved,
|
|
whereFilter,
|
|
limit: limitValue,
|
|
}: SubscribeToParticleChildrenOptions,
|
|
): Unsubscribe {
|
|
let q = query(
|
|
typedCollection(collectionPath),
|
|
orderBy(orderByField, orderDirection),
|
|
);
|
|
if (visibilityScopes.length > 0) {
|
|
q = query(q, where('visible_to', 'array-contains-any', visibilityScopes));
|
|
}
|
|
if (whereFilter) {
|
|
q = query(q, whereFilter);
|
|
}
|
|
if (limitValue !== undefined) {
|
|
q = query(q, limit(limitValue));
|
|
}
|
|
return onSnapshot(
|
|
q,
|
|
(snap) => {
|
|
const updatedChildren = snap.docs
|
|
.map((d) => safeData(d))
|
|
.filter((p): p is Particle => p !== null);
|
|
onData(updatedChildren);
|
|
|
|
if (onAdded || onRemoved) {
|
|
for (const change of snap.docChanges()) {
|
|
const changed = safeData(change.doc);
|
|
if (!changed) continue;
|
|
if (change.type === 'added' && onAdded) onAdded(changed);
|
|
if (change.type === 'removed' && onRemoved)
|
|
onRemoved(changed, updatedChildren);
|
|
}
|
|
}
|
|
},
|
|
onError,
|
|
);
|
|
}
|
|
|
|
export function subscribeToLatestChild(
|
|
collectionPath: string,
|
|
onData: (child: Particle | null) => void,
|
|
onError: (error: Error) => void,
|
|
): Unsubscribe {
|
|
const q = query(
|
|
typedCollection(collectionPath),
|
|
orderBy('created_at', 'desc'),
|
|
limit(1),
|
|
);
|
|
return onSnapshot(
|
|
q,
|
|
(snap) => {
|
|
onData(snap.empty ? null : safeData(snap.docs[0]));
|
|
},
|
|
onError,
|
|
);
|
|
}
|
|
|
|
// This creates a new particle document with the given properties and returns its ID.
|
|
export async function createParticle<T extends ParticleType>(
|
|
collectionPath: string,
|
|
type: T,
|
|
properties: ParticlePropertiesMap[T],
|
|
createdByHumanId: string,
|
|
// Must be passed for container types
|
|
visibleTo?: string[],
|
|
): Promise<string> {
|
|
if (isContainerType(type) && (!visibleTo || visibleTo.length === 0)) {
|
|
throw new Error(
|
|
`visibleTo is required for container type ${type} and cannot be empty`,
|
|
);
|
|
}
|
|
|
|
const particle: Particle = ParticleSchema.parse({
|
|
id: '', // ignored by toFirestore, but needed to satisfy the type
|
|
type,
|
|
properties,
|
|
created_at: new Date(),
|
|
created_by_human_id: createdByHumanId,
|
|
...(visibleTo ? { visible_to: visibleTo } : {}),
|
|
});
|
|
const ref = await addDoc(typedCollection(collectionPath), particle);
|
|
return ref.id;
|
|
}
|
|
|
|
export async function createStreamParticle(
|
|
collectionPath: string,
|
|
properties: ParticlePropertiesMap['stream'],
|
|
createdByHumanId: string,
|
|
visibleTo?: string[],
|
|
): Promise<string> {
|
|
if (!visibleTo || visibleTo.length === 0) {
|
|
throw new Error('visibleTo is required for streams and cannot be empty');
|
|
}
|
|
|
|
const particle: Particle = ParticleSchema.parse({
|
|
id: '',
|
|
type: 'stream',
|
|
properties,
|
|
created_at: new Date(),
|
|
created_by_human_id: createdByHumanId,
|
|
visible_to: visibleTo,
|
|
status: 'open',
|
|
});
|
|
const ref = await addDoc(typedCollection(collectionPath), particle);
|
|
return ref.id;
|
|
}
|
|
|
|
// This allows updating properties without overwriting the entire properties object
|
|
export async function updateParticleProperties<T extends ParticleType>(
|
|
docPath: string,
|
|
properties: Partial<ParticlePropertiesMap[T]>,
|
|
): Promise<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
// Take the partial and create a new object with dot notation
|
|
// e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
|
|
const updatedProperties: Record<string, unknown> = {};
|
|
for (const key in properties) {
|
|
updatedProperties[`properties.${key}`] = properties[key];
|
|
}
|
|
await updateDoc(particleRef, {
|
|
...updatedProperties,
|
|
updated_at: serverTimestamp(),
|
|
});
|
|
}
|
|
|
|
// Edits the body of a text particle and stamps `properties.edited_at` so
|
|
// readers can see that the message was edited (distinct from `updated_at`,
|
|
// which is bumped by any write — visibility, reactions, etc.).
|
|
export async function editTextParticleContent(
|
|
docPath: string,
|
|
content: string,
|
|
): Promise<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
await updateDoc(particleRef, {
|
|
'properties.content': content,
|
|
'properties.edited_at': serverTimestamp(),
|
|
updated_at: serverTimestamp(),
|
|
});
|
|
}
|
|
|
|
export async function updateParticleVisibleTo(
|
|
docPath: string,
|
|
visibleTo: string[],
|
|
): Promise<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
const particle = await getParticle(docPath);
|
|
if (!particle) {
|
|
throw new Error(`Particle not found at path: ${docPath}`);
|
|
}
|
|
if (!isContainerType(particle.type)) {
|
|
throw new Error(
|
|
`Only container particles can have visible_to field. Particle at ${docPath} is of type ${particle.type}`,
|
|
);
|
|
}
|
|
|
|
await updateDoc(particleRef, {
|
|
visible_to: visibleTo,
|
|
updated_at: serverTimestamp(),
|
|
});
|
|
}
|
|
|
|
// CAUTION: use the other type safe update functions in most cases
|
|
// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly
|
|
export async function updateParticle(
|
|
docPath: string,
|
|
fieldName: string,
|
|
value: unknown,
|
|
): Promise<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
await updateDoc(particleRef, {
|
|
[fieldName]: value,
|
|
updated_at: serverTimestamp(),
|
|
});
|
|
}
|
|
|
|
export async function updateStreamStatus(
|
|
docPath: string,
|
|
status: 'open' | 'closed',
|
|
): Promise<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
|
|
}
|
|
|
|
/**
|
|
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
|
|
* in place so concurrent viewers see the deletion inline rather than being
|
|
* bumped to an adjacent particle. Idempotent — re-calling on an already
|
|
* tombstoned doc just refreshes the timestamp.
|
|
*/
|
|
export async function softDeleteParticle(
|
|
docPath: string,
|
|
humanId: string,
|
|
): Promise<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
await updateDoc(particleRef, {
|
|
deleted_at: serverTimestamp(),
|
|
deleted_by_human_id: humanId,
|
|
updated_at: serverTimestamp(),
|
|
});
|
|
}
|
|
|
|
export async function updateStreamPlaybackMarker(
|
|
docPath: string,
|
|
humanId: string,
|
|
playbackPositionAt: Date,
|
|
): Promise<void> {
|
|
const particleRef = typedDoc(docPath);
|
|
const markerField = `playback_markers.${humanId}`;
|
|
await updateDoc(particleRef, {
|
|
[markerField]: Timestamp.fromDate(playbackPositionAt),
|
|
updated_at: serverTimestamp(),
|
|
});
|
|
}
|
|
|
|
// Strip characters that Firestore's dotted field-path syntax treats specially.
|
|
// Using FieldPath bypasses the parser, but we also forbid these in stored keys
|
|
// so reaction maps stay portable across any future read path.
|
|
const RESERVED_REACTION_CHARS = /[~*/[\]]/g;
|
|
|
|
export function sanitizeReactionText(text: string): string {
|
|
return text.replace(RESERVED_REACTION_CHARS, '');
|
|
}
|
|
|
|
export async function toggleParticleReaction(
|
|
docPath: string,
|
|
emoji: string,
|
|
humanId: string,
|
|
currentReactions?: Reactions,
|
|
): Promise<void> {
|
|
const key = sanitizeReactionText(emoji);
|
|
if (!key) return;
|
|
const particleRef = typedDoc(docPath);
|
|
const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
|
|
await updateDoc(
|
|
particleRef,
|
|
new FieldPath('reactions', key),
|
|
alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
|
|
'updated_at',
|
|
serverTimestamp(),
|
|
);
|
|
}
|