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>
116 lines
3.6 KiB
TypeScript
116 lines
3.6 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Pressable, Text, View } from 'react-native';
|
|
import { Plus } from 'lucide-react-native';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
|
import { Avatar } from '@/components/Avatar';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
|
|
|
interface ReactionStackProps {
|
|
reactions: Reactions;
|
|
currentHumanId: string;
|
|
humans: Human[] | undefined;
|
|
/** Toggle a reaction (emoji or text) — same contract as ReactionSheet's onToggle. */
|
|
onToggle: (key: string) => void;
|
|
/** Open the full reaction sheet for emoji + custom-text picking. */
|
|
onOpenSheet: () => void;
|
|
}
|
|
|
|
/**
|
|
* Right-edge reaction stack — mobile counterpart of desktop's ReactionBar.
|
|
* Sits vertically centered on the right side of the canvas so the user can
|
|
* see existing reactions at a glance and tap to toggle their own. The "+"
|
|
* affordance opens the ReactionSheet for the full picker (emoji or text).
|
|
*/
|
|
export function ReactionStack({
|
|
reactions,
|
|
currentHumanId,
|
|
humans,
|
|
onToggle,
|
|
onOpenSheet,
|
|
}: ReactionStackProps) {
|
|
const activeEmojis = REACTION_EMOJIS.filter(
|
|
(emoji) => reactions?.[emoji] && (reactions[emoji]?.length ?? 0) > 0,
|
|
);
|
|
const activeTextKeys = useMemo(
|
|
() =>
|
|
Object.keys(reactions ?? {}).filter(
|
|
(k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
|
),
|
|
[reactions],
|
|
);
|
|
|
|
const handleToggle = (key: string) => {
|
|
void Haptics.selectionAsync();
|
|
onToggle(key);
|
|
};
|
|
|
|
return (
|
|
<View className="items-end gap-1.5">
|
|
{activeEmojis.map((emoji) => {
|
|
const reactors = reactions?.[emoji] ?? [];
|
|
const isMine = reactors.includes(currentHumanId);
|
|
return (
|
|
<Pressable
|
|
key={emoji}
|
|
onPress={() => handleToggle(emoji)}
|
|
className={cn(
|
|
'flex-row items-center gap-1 rounded-full px-2 py-1',
|
|
isMine ? 'bg-white/25' : 'bg-black/45',
|
|
)}
|
|
style={
|
|
isMine
|
|
? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' }
|
|
: undefined
|
|
}
|
|
>
|
|
<Text className="text-sm">{emoji}</Text>
|
|
<Text className="text-white/85 text-xs font-medium">
|
|
{reactors.length}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
|
|
{activeTextKeys.map((text) => {
|
|
const reactors = reactions?.[text] ?? [];
|
|
const isMine = reactors.includes(currentHumanId);
|
|
return (
|
|
<Pressable
|
|
key={text}
|
|
onPress={() => handleToggle(text)}
|
|
className={cn(
|
|
'flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5',
|
|
isMine ? 'bg-white/25' : 'bg-black/45',
|
|
)}
|
|
style={[
|
|
{ maxWidth: 200 },
|
|
isMine
|
|
? { borderWidth: 1, borderColor: 'rgba(255,255,255,0.4)' }
|
|
: null,
|
|
]}
|
|
>
|
|
<Avatar humanId={reactors[0]} humans={humans} size="xs" />
|
|
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
|
{text}
|
|
</Text>
|
|
{reactors.length > 1 ? (
|
|
<Text className="text-white/60 text-xs">{reactors.length}</Text>
|
|
) : null}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
|
|
<Pressable
|
|
onPress={onOpenSheet}
|
|
accessibilityLabel="Add reaction"
|
|
className="h-8 w-8 items-center justify-center rounded-full bg-black/45"
|
|
>
|
|
<Plus color="rgba(255,255,255,0.85)" size={16} strokeWidth={2} />
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|