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
@@ -0,0 +1,145 @@
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { toast } from 'sonner-native';
import { BottomSheet } from '@/components/BottomSheet';
import { useAddMembers } from '@/hooks/use-member-management';
import { toUserMessage } from '@/lib/errors';
import { cn } from '@/lib/utils';
interface AddMembersSheetProps {
open: boolean;
onClose: () => void;
networkId: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Invite people to a network by email. Mirrors desktop's add-members-dialog —
* accepts one email at a time (comma/space/enter to commit), shows chips, and
* submits the batch via `addMembers`.
*/
export function AddMembersSheet({
open,
onClose,
networkId,
}: AddMembersSheetProps) {
const [draft, setDraft] = useState('');
const [emails, setEmails] = useState<string[]>([]);
const addMembers = useAddMembers(networkId);
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setDraft('');
setEmails([]);
}
}
const commitDraft = (): string[] => {
const candidate = draft.trim().toLowerCase().replace(/,$/, '');
if (!candidate) return emails;
if (!EMAIL_RE.test(candidate)) {
toast.error('Enter a valid email address');
return emails;
}
if (emails.includes(candidate)) {
setDraft('');
return emails;
}
const next = [...emails, candidate];
setEmails(next);
setDraft('');
return next;
};
const removeEmail = (email: string) =>
setEmails((prev) => prev.filter((e) => e !== email));
const handleSubmit = async () => {
const finalEmails = commitDraft();
if (finalEmails.length === 0) return;
try {
await addMembers.mutateAsync(finalEmails);
toast.success(
finalEmails.length === 1
? 'Invitation sent'
: `${finalEmails.length} invitations sent`,
);
onClose();
} catch (err) {
toast.error(toUserMessage(err));
}
};
const canSubmit =
!addMembers.isPending &&
(emails.length > 0 || EMAIL_RE.test(draft.trim().toLowerCase()));
return (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="60%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">Add members</Text>
<Text className="text-white/50 text-sm mt-1">
Existing users join right away; others get an email invite.
</Text>
{emails.length > 0 ? (
<View className="flex-row flex-wrap gap-2 mt-4">
{emails.map((email) => (
<Pressable
key={email}
onPress={() => removeEmail(email)}
className="flex-row items-center bg-white/10 rounded-full pl-3 pr-2 py-1"
>
<Text className="text-white text-sm">{email}</Text>
<Text className="text-white/50 text-base ml-1">×</Text>
</Pressable>
))}
</View>
) : null}
<TextInput
value={draft}
onChangeText={(text) => {
if (text.endsWith(',') || text.endsWith(' ')) {
setDraft(text);
commitDraft();
} else {
setDraft(text);
}
}}
placeholder="[email protected]"
placeholderTextColor="rgba(255,255,255,0.4)"
autoFocus
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="done"
onSubmitEditing={() => commitDraft()}
editable={!addMembers.isPending}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
/>
<Pressable
onPress={handleSubmit}
disabled={!canSubmit}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canSubmit ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canSubmit ? 'text-black' : 'text-white/40',
)}
>
{addMembers.isPending ? 'Sending…' : 'Send invites'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}