feat: increase feature parity between mobile and desktop (#298)

* 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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* decrease clutter in stream-view

* format

* format

* consolidate avatar

---------

Co-authored-by: Claude <[email protected]>
This commit was merged in pull request #298.
This commit is contained in:
Arjun Patel
2026-06-21 10:38:16 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 428f9ea1c9
commit 3d80ac2993
38 changed files with 2458 additions and 352 deletions
+20
View File
@@ -0,0 +1,20 @@
import { skipToken, useQuery } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
/**
* Resolve an avatar object id to a signed download URL. React Query handles
* caching and de-duping, so many avatars sharing an id make a single request.
* Mirrors desktop's use-avatar-url.
*/
export function useAvatarUrl(
objectId: string | null | undefined,
): string | undefined {
const { data } = useQuery({
queryKey: ['avatar-url', objectId],
queryFn: objectId
? () => apiClient.getAvatarDownloadUrl(objectId)
: skipToken,
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
});
return data;
}
+33
View File
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
import type { BillingCadence } from '@/api/types';
/** Plan + quota summary. Member-accessible (sourced from `/usage`). */
export function useNetworkUsage(networkId: string) {
return useQuery({
queryKey: ['network-usage', networkId],
queryFn: () => apiClient.getNetworkUsage(networkId),
});
}
/** Full billing status. Admin-gated (`/billing`). */
export function useNetworkBilling(networkId: string, enabled: boolean) {
return useQuery({
queryKey: ['network-billing', networkId],
queryFn: () => apiClient.getNetworkBilling(networkId),
enabled,
});
}
export function useCreateCheckoutSession(networkId: string) {
return useMutation({
mutationFn: (cadence: BillingCadence) =>
apiClient.createCheckoutSession(networkId, cadence),
});
}
export function useCreatePortalSession(networkId: string) {
return useMutation({
mutationFn: () => apiClient.createPortalSession(networkId),
});
}
+39
View File
@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
import type { CreateNetworkRequest } from '@/api/types';
/** Invitations addressed to the signed-in user's email. */
export function useMyInvitations() {
return useQuery({
queryKey: ['invitations'],
queryFn: () => apiClient.listMyInvitations(),
meta: { toastOnError: true },
});
}
/**
* Accept a pending invitation, then refresh both the networks list (the user
* is now a member) and the invitations list (the invite is consumed).
*/
export function useAcceptInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (networkId: string) =>
apiClient.acceptInvitation({ network_id: networkId }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['networks'] });
void queryClient.invalidateQueries({ queryKey: ['invitations'] });
},
});
}
/** Create a network; the creator becomes its admin and first member. */
export function useCreateNetwork() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateNetworkRequest) => apiClient.createNetwork(data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['networks'] });
},
});
}
@@ -0,0 +1,53 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
/** Pending invitations sent for a network (member-visible). */
export function useNetworkInvitations(networkId: string) {
return useQuery({
queryKey: ['network-invitations', networkId],
queryFn: () => apiClient.listNetworkInvitations(networkId),
});
}
/**
* Invite people by email. Existing users join directly; others get a pending
* invitation. Refreshes both the network (new members) and its invitation list.
*/
export function useAddMembers(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (emails: string[]) =>
apiClient.addMembers(networkId, { email_addresses: emails }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['networks'] });
void queryClient.invalidateQueries({
queryKey: ['network-invitations', networkId],
});
},
});
}
/** Remove a member from the network (admin only). */
export function useRemoveMember(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['networks'] });
},
});
}
/** Revoke a pending invitation by email. */
export function useRevokeInvitation(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: string) =>
apiClient.revokeInvitation(networkId, { email }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['network-invitations', networkId],
});
},
});
}