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>
146 lines
4.2 KiB
TypeScript
146 lines
4.2 KiB
TypeScript
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="name@example.com"
|
||
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>
|
||
);
|
||
}
|