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
This commit is contained in:
@@ -55,6 +55,13 @@ const config: ExpoConfig = {
|
|||||||
'Flowy uses your microphone to record voice messages.',
|
'Flowy uses your microphone to record voice messages.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'expo-image-picker',
|
||||||
|
{
|
||||||
|
photosPermission:
|
||||||
|
'Flowy uses your photo library to set your profile picture.',
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'expo-notifications',
|
'expo-notifications',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"expo-device": "~8.0.10",
|
"expo-device": "~8.0.10",
|
||||||
"expo-file-system": "~19.0.16",
|
"expo-file-system": "~19.0.16",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
|
"expo-image-picker": "~17.0.8",
|
||||||
"expo-notifications": "~0.32.17",
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
|
||||||
import { appConfig } from '@/config/env';
|
import { appConfig } from '@/config/env';
|
||||||
import { ApiError } from '@/lib/errors';
|
import { ApiError } from '@/lib/errors';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@@ -136,6 +137,45 @@ class ApiClient {
|
|||||||
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Avatar ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a new profile picture. The endpoint takes the raw image bytes as
|
||||||
|
* the request body (not multipart), so we stream the file directly via
|
||||||
|
* expo-file-system rather than the JSON `fetch` helper.
|
||||||
|
*/
|
||||||
|
async uploadAvatar(fileUri: string, mimeType: string): Promise<void> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': mimeType };
|
||||||
|
if (this.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
const result = await uploadAsync(
|
||||||
|
`${this.baseUrl}/humans/me/avatar`,
|
||||||
|
fileUri,
|
||||||
|
{
|
||||||
|
httpMethod: 'PUT',
|
||||||
|
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.status === 401) {
|
||||||
|
throw new ApiError(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
if (result.status < 200 || result.status >= 300) {
|
||||||
|
throw new ApiError(result.status, result.body || 'Avatar upload failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAvatar(): Promise<void> {
|
||||||
|
await this.requestVoid('DELETE', '/humans/me/avatar');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvatarDownloadUrl(objectId: string): Promise<string> {
|
||||||
|
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Push notification tokens ---
|
// --- Push notification tokens ---
|
||||||
|
|
||||||
async registerPushToken(data: {
|
async registerPushToken(data: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
email_prefix: z.string(),
|
email_prefix: z.string(),
|
||||||
email_notifications_enabled: z.boolean(),
|
email_notifications_enabled: z.boolean(),
|
||||||
|
avatar_object_id: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Human = z.infer<typeof HumanSchema>;
|
export type Human = z.infer<typeof HumanSchema>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Text, View } from 'react-native';
|
import { Image, Text, View } from 'react-native';
|
||||||
import type { Human } from '@/api/types';
|
import type { Human } from '@/api/types';
|
||||||
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -23,9 +24,10 @@ const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initials avatar with optional online ring (green) and an optional outer
|
* Human avatar: renders the profile picture when one is set (resolved to a
|
||||||
* stack ring used to visually separate overlapping avatars on a busy chrome.
|
* signed URL via React Query), otherwise initials. Supports an optional online
|
||||||
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`).
|
* ring (green) and an outer stack separator ring used to keep overlapping
|
||||||
|
* avatars distinct on busy chrome. Matches desktop's avatar + presence pattern.
|
||||||
*/
|
*/
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
humanId,
|
humanId,
|
||||||
@@ -36,12 +38,14 @@ export function Avatar({
|
|||||||
className,
|
className,
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
const { initials } = resolveHumanDisplay(humanId, humans);
|
const { initials } = resolveHumanDisplay(humanId, humans);
|
||||||
|
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
|
||||||
|
const avatarUrl = useAvatarUrl(human?.avatar_object_id);
|
||||||
const dims = sizeMap[size];
|
const dims = sizeMap[size];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-black/15 items-center justify-center rounded-full',
|
'bg-black/15 items-center justify-center overflow-hidden rounded-full',
|
||||||
dims.box,
|
dims.box,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -52,9 +56,13 @@ export function Avatar({
|
|||||||
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className={cn('text-white font-semibold', dims.text)}>
|
{avatarUrl ? (
|
||||||
{initials}
|
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
|
||||||
</Text>
|
) : (
|
||||||
|
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Mail, Shield, UserPlus, X } from 'lucide-react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import type { Human } from '@/api/types';
|
||||||
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
|
import {
|
||||||
|
useNetworkInvitations,
|
||||||
|
useRemoveMember,
|
||||||
|
useRevokeInvitation,
|
||||||
|
} from '@/hooks/use-member-management';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
import { AddMembersSheet } from './AddMembersSheet';
|
||||||
|
|
||||||
|
export function NetworkSettingsScreen({
|
||||||
|
route,
|
||||||
|
navigation,
|
||||||
|
}: RootStackScreenProps<'NetworkSettings'>) {
|
||||||
|
const { networkId } = route.params;
|
||||||
|
const network = useNetwork(networkId);
|
||||||
|
const { data: invitations, error: invitationsError } =
|
||||||
|
useNetworkInvitations(networkId);
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
const isAdmin = !!currentUserId && network?.admin_human.id === currentUserId;
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
|
||||||
|
const removeMember = useRemoveMember(networkId);
|
||||||
|
const members = network?.humans ?? [];
|
||||||
|
const pending = invitations ?? [];
|
||||||
|
|
||||||
|
const handleRemove = (human: Human) => {
|
||||||
|
Alert.alert(
|
||||||
|
`Remove ${human.email_prefix}?`,
|
||||||
|
"They'll lose access to this network's streams and files. Content they posted stays in the network.",
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await removeMember.mutateAsync(human.id);
|
||||||
|
toast.success(`Removed ${human.email}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
|
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||||
|
<Text className="text-foreground text-2xl">‹</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text className="flex-1 text-center text-foreground text-base font-semibold">
|
||||||
|
{network?.name ?? 'Network'}
|
||||||
|
</Text>
|
||||||
|
<View className="w-8" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView className="flex-1">
|
||||||
|
<View className="flex-row items-center justify-between px-4 pt-5 pb-2">
|
||||||
|
<View>
|
||||||
|
<Text className="text-foreground text-base font-semibold">
|
||||||
|
Members
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-xs">
|
||||||
|
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isAdmin ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setAddOpen(true)}
|
||||||
|
className="flex-row items-center bg-primary rounded-full px-3 py-2"
|
||||||
|
>
|
||||||
|
<UserPlus size={14} color="#000000" />
|
||||||
|
<Text className="text-primary-foreground text-sm font-semibold ml-1">
|
||||||
|
Add
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-2">
|
||||||
|
{members.map((human) => {
|
||||||
|
const isRowAdmin = human.id === network?.admin_human.id;
|
||||||
|
const canRemove =
|
||||||
|
isAdmin && !isRowAdmin && human.id !== currentUserId;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={human.id}
|
||||||
|
className="flex-row items-center gap-3 px-2 py-3"
|
||||||
|
>
|
||||||
|
<Avatar humanId={human.id} humans={members} size="md" />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground text-sm font-medium">
|
||||||
|
{human.email_prefix}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-xs">
|
||||||
|
{human.email}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isRowAdmin ? (
|
||||||
|
<View className="flex-row items-center bg-muted rounded-full px-2 py-1">
|
||||||
|
<Shield size={12} color="#a6a6a6" />
|
||||||
|
<Text className="text-muted-foreground text-xs ml-1">
|
||||||
|
Admin
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{canRemove ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleRemove(human)}
|
||||||
|
hitSlop={8}
|
||||||
|
className="p-1"
|
||||||
|
accessibilityLabel={`Remove ${human.email}`}
|
||||||
|
>
|
||||||
|
<X size={16} color="#a6a6a6" />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<View className="mt-4">
|
||||||
|
<Text className="text-foreground text-base font-semibold px-4 pt-2 pb-2">
|
||||||
|
Pending invitations
|
||||||
|
</Text>
|
||||||
|
{invitationsError ? (
|
||||||
|
<Text className="text-muted-foreground text-xs px-4 py-2">
|
||||||
|
Couldn’t load pending invitations.
|
||||||
|
</Text>
|
||||||
|
) : pending.length === 0 ? (
|
||||||
|
<Text className="text-muted-foreground text-xs px-4 py-2">
|
||||||
|
No pending invitations.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View className="px-2">
|
||||||
|
{pending.map((inv) => (
|
||||||
|
<PendingInvitationRow
|
||||||
|
key={inv.email}
|
||||||
|
email={inv.email}
|
||||||
|
networkId={networkId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<AddMembersSheet
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
networkId={networkId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingInvitationRow({
|
||||||
|
email,
|
||||||
|
networkId,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
networkId: string;
|
||||||
|
}) {
|
||||||
|
const revokeInvitation = useRevokeInvitation(networkId);
|
||||||
|
|
||||||
|
const handleRevoke = async () => {
|
||||||
|
try {
|
||||||
|
await revokeInvitation.mutateAsync(email);
|
||||||
|
toast.success(`Invitation to ${email} revoked`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-3 px-2 py-3">
|
||||||
|
<View className="h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Mail size={16} color="#a6a6a6" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground text-sm" numberOfLines={1}>
|
||||||
|
{email}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-xs">Pending</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleRevoke}
|
||||||
|
disabled={revokeInvitation.isPending}
|
||||||
|
hitSlop={8}
|
||||||
|
className="p-1"
|
||||||
|
accessibilityLabel={`Revoke invitation to ${email}`}
|
||||||
|
>
|
||||||
|
<X size={16} color="#a6a6a6" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,77 @@
|
|||||||
import { Pressable, Text, View } from 'react-native';
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { Camera } from 'lucide-react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import type { RootStackScreenProps } from '@/navigation/types';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||||
|
const avatarUrl = useAvatarUrl(user?.avatar_object_id);
|
||||||
|
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?';
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const pickAndUpload = async () => {
|
||||||
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
toast.error('Photo library access is needed to set an avatar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
if (result.canceled) return;
|
||||||
|
const asset = result.assets[0];
|
||||||
|
if (!asset) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiClient.uploadAvatar(asset.uri, asset.mimeType ?? 'image/jpeg');
|
||||||
|
await refreshUser();
|
||||||
|
toast.success('Avatar updated');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAvatar = () => {
|
||||||
|
Alert.alert('Remove avatar?', 'Your initials will be shown instead.', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiClient.deleteAvatar();
|
||||||
|
await refreshUser();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
@@ -18,7 +85,37 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
|||||||
<View className="w-8" />
|
<View className="w-8" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="px-6 py-6 gap-4">
|
<View className="items-center px-6 py-8">
|
||||||
|
<Pressable
|
||||||
|
onPress={pickAndUpload}
|
||||||
|
disabled={busy}
|
||||||
|
accessibilityLabel="Change profile picture"
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<View className="h-24 w-24 items-center justify-center overflow-hidden rounded-full bg-muted">
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : avatarUrl ? (
|
||||||
|
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-foreground text-3xl font-medium">
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="absolute bottom-0 right-0 h-7 w-7 items-center justify-center rounded-full bg-primary border-2 border-background">
|
||||||
|
<Camera size={13} color="#000000" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{user?.avatar_object_id ? (
|
||||||
|
<Pressable onPress={removeAvatar} disabled={busy} className="mt-3">
|
||||||
|
<Text className="text-destructive text-sm">Remove photo</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-6 gap-4">
|
||||||
<Field label="Email" value={user?.email ?? '—'} />
|
<Field label="Email" value={user?.email ?? '—'} />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Settings as SettingsIcon } from 'lucide-react-native';
|
||||||
import { ListSeparator } from '@/components/ListSeparator';
|
import { ListSeparator } from '@/components/ListSeparator';
|
||||||
import { toUserMessage } from '@/lib/errors';
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
@@ -32,6 +33,9 @@ export function StreamListScreen({
|
|||||||
<Header
|
<Header
|
||||||
title={network?.name ?? 'Streams'}
|
title={network?.name ?? 'Streams'}
|
||||||
onBack={() => navigation.goBack()}
|
onBack={() => navigation.goBack()}
|
||||||
|
onOpenSettings={() =>
|
||||||
|
navigation.navigate('NetworkSettings', { networkId })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -67,7 +71,15 @@ export function StreamListScreen({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
function Header({
|
||||||
|
title,
|
||||||
|
onBack,
|
||||||
|
onOpenSettings,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -83,7 +95,14 @@ function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="w-8" />
|
<Pressable
|
||||||
|
onPress={onOpenSettings}
|
||||||
|
className="px-2 py-1"
|
||||||
|
accessibilityLabel="Network settings"
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={20} color="#fafafa" />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { StreamListScreen } from '@/features/streams/StreamListScreen';
|
|||||||
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
||||||
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
||||||
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
||||||
|
import { NetworkSettingsScreen } from '@/features/network-settings/NetworkSettingsScreen';
|
||||||
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
||||||
import { AccountScreen } from '@/features/settings/AccountScreen';
|
import { AccountScreen } from '@/features/settings/AccountScreen';
|
||||||
import type { RootStackParamList } from './types';
|
import type { RootStackParamList } from './types';
|
||||||
@@ -54,6 +55,11 @@ export function RootNavigator() {
|
|||||||
component={NewStreamScreen}
|
component={NewStreamScreen}
|
||||||
options={{ animation: 'slide_from_bottom' }}
|
options={{ animation: 'slide_from_bottom' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="NetworkSettings"
|
||||||
|
component={NetworkSettingsScreen}
|
||||||
|
options={{ animation: 'slide_from_right' }}
|
||||||
|
/>
|
||||||
<Stack.Screen name="Settings" component={SettingsScreen} />
|
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||||
<Stack.Screen name="Account" component={AccountScreen} />
|
<Stack.Screen name="Account" component={AccountScreen} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type RootStackParamList = {
|
|||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
};
|
};
|
||||||
NewStream: { networkId: string };
|
NewStream: { networkId: string };
|
||||||
|
NetworkSettings: { networkId: string };
|
||||||
Settings: undefined;
|
Settings: undefined;
|
||||||
Account: undefined;
|
Account: undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface AuthState {
|
|||||||
requestCode: (email: string) => Promise<void>;
|
requestCode: (email: string) => Promise<void>;
|
||||||
signIn: (email: string, code: string) => Promise<void>;
|
signIn: (email: string, code: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
/** Re-fetch the current user from Orion (e.g. after an avatar change). */
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Wipes the session in response to a server-detected auth failure (e.g. a
|
* Wipes the session in response to a server-detected auth failure (e.g. a
|
||||||
* 401 surfaced through react-query). Does not call `/auth/sign-out`; the
|
* 401 surfaced through react-query). Does not call `/auth/sign-out`; the
|
||||||
@@ -166,6 +168,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshUser: async () => {
|
||||||
|
try {
|
||||||
|
const user = await apiClient.me();
|
||||||
|
set({ user });
|
||||||
|
} catch (err) {
|
||||||
|
logError(err, { scope: 'auth.refreshUser' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
invalidateSession: async () => {
|
invalidateSession: async () => {
|
||||||
stopPushTokenSync();
|
stopPushTokenSync();
|
||||||
apiClient.setToken(null);
|
apiClient.setToken(null);
|
||||||
|
|||||||
@@ -4278,6 +4278,18 @@ expo-haptics@~15.0.7:
|
|||||||
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
||||||
integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==
|
integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==
|
||||||
|
|
||||||
|
expo-image-loader@~6.0.0:
|
||||||
|
version "6.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072"
|
||||||
|
integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==
|
||||||
|
|
||||||
|
expo-image-picker@~17.0.8:
|
||||||
|
version "17.0.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc"
|
||||||
|
integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==
|
||||||
|
dependencies:
|
||||||
|
expo-image-loader "~6.0.0"
|
||||||
|
|
||||||
expo-keep-awake@~15.0.8:
|
expo-keep-awake@~15.0.8:
|
||||||
version "15.0.8"
|
version "15.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
||||||
|
|||||||
Reference in New Issue
Block a user