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:
co-authored by
Claude Opus 4.8
parent
428f9ea1c9
commit
3d80ac2993
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user