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
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
|
import { useCreateNetwork } from '@/hooks/use-invitations';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CreateNetworkSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called with the new network's id once creation succeeds. */
|
||||||
|
onCreated?: (networkId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal "name your network" sheet. Mirrors desktop's create-network flow in
|
||||||
|
* `network-selector.tsx` — single text field, creator becomes admin.
|
||||||
|
*/
|
||||||
|
export function CreateNetworkSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: CreateNetworkSheetProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const createNetwork = useCreateNetwork();
|
||||||
|
|
||||||
|
// Reset the field each time the sheet opens fresh.
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
|
if (open) setName('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const canCreate = trimmed.length > 0 && !createNetwork.isPending;
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!canCreate) return;
|
||||||
|
try {
|
||||||
|
const network = await createNetwork.mutateAsync({ name: trimmed });
|
||||||
|
onClose();
|
||||||
|
onCreated?.(network.id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="50%">
|
||||||
|
<View className="px-5 pb-4">
|
||||||
|
<Text className="text-white text-lg font-semibold">New network</Text>
|
||||||
|
<Text className="text-white/50 text-sm mt-1">
|
||||||
|
You’ll be the admin and can invite people next.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="Network name"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="words"
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleCreate}
|
||||||
|
editable={!createNetwork.isPending}
|
||||||
|
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={!canCreate}
|
||||||
|
className={cn(
|
||||||
|
'mt-4 rounded-xl py-3 items-center',
|
||||||
|
canCreate ? 'bg-white' : 'bg-white/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-base font-semibold',
|
||||||
|
canCreate ? 'text-black' : 'text-white/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{createNetwork.isPending ? 'Creating…' : 'Create network'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,12 @@ interface DrawerProps {
|
|||||||
onNavigateSettings: () => void;
|
onNavigateSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
export function Drawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onNavigateAccount,
|
||||||
|
onNavigateSettings,
|
||||||
|
}: DrawerProps) {
|
||||||
// Lazy-init so each Animated.Value is created once; the setters are never
|
// Lazy-init so each Animated.Value is created once; the setters are never
|
||||||
// called — the values are mutated internally by the native driver.
|
// called — the values are mutated internally by the native driver.
|
||||||
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
|
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
|
||||||
@@ -114,6 +119,13 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
|||||||
onNavigateAccount();
|
onNavigateAccount();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<DrawerRow
|
||||||
|
label="Settings"
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
onNavigateSettings();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="border-sidebar-border border-t px-2 py-2">
|
<View className="border-sidebar-border border-t px-2 py-2">
|
||||||
|
|||||||
@@ -8,20 +8,26 @@ 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 type { Network } from '@/api/types';
|
import { Plus } from 'lucide-react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import type { Invitation, Network } from '@/api/types';
|
||||||
import { useNetworks } from '@/hooks/use-networks';
|
import { useNetworks } from '@/hooks/use-networks';
|
||||||
|
import { useAcceptInvitation, useMyInvitations } from '@/hooks/use-invitations';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { toUserMessage } from '@/lib/errors';
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import type { RootStackScreenProps } from '@/navigation/types';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
import { FlowyLogo } from '@/components/FlowyLogo';
|
import { FlowyLogo } from '@/components/FlowyLogo';
|
||||||
import { ListSeparator } from '@/components/ListSeparator';
|
import { ListSeparator } from '@/components/ListSeparator';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
|
import { CreateNetworkSheet } from './CreateNetworkSheet';
|
||||||
|
|
||||||
export function NetworkListScreen({
|
export function NetworkListScreen({
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<'NetworkList'>) {
|
}: RootStackScreenProps<'NetworkList'>) {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const { data, isLoading, refetch, error } = useNetworks();
|
const { data, isLoading, refetch, error } = useNetworks();
|
||||||
|
const { data: invitations, refetch: refetchInvitations } = useMyInvitations();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
||||||
|
|
||||||
@@ -32,11 +38,13 @@ export function NetworkListScreen({
|
|||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await refetch();
|
await Promise.all([refetch(), refetchInvitations()]);
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [refetch]);
|
}, [refetch, refetchInvitations]);
|
||||||
|
|
||||||
|
const pendingInvitations = invitations ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
@@ -51,7 +59,13 @@ export function NetworkListScreen({
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<FlowyLogo />
|
<FlowyLogo />
|
||||||
<View className="w-9" />
|
<Pressable
|
||||||
|
onPress={() => setCreateOpen(true)}
|
||||||
|
accessibilityLabel="Create network"
|
||||||
|
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Plus size={18} color="#fafafa" />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -67,16 +81,21 @@ export function NetworkListScreen({
|
|||||||
<Text className="text-foreground font-medium">Retry</Text>
|
<Text className="text-foreground font-medium">Retry</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : !data || data.length === 0 ? (
|
) : (!data || data.length === 0) && pendingInvitations.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState onCreate={() => setCreateOpen(true)} />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data}
|
data={data ?? []}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
}
|
}
|
||||||
ItemSeparatorComponent={ListSeparator}
|
ItemSeparatorComponent={ListSeparator}
|
||||||
|
ListHeaderComponent={
|
||||||
|
pendingInvitations.length > 0 ? (
|
||||||
|
<InvitationsSection invitations={pendingInvitations} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<NetworkCard
|
<NetworkCard
|
||||||
network={item}
|
network={item}
|
||||||
@@ -94,10 +113,65 @@ export function NetworkListScreen({
|
|||||||
onNavigateAccount={() => navigation.navigate('Account')}
|
onNavigateAccount={() => navigation.navigate('Account')}
|
||||||
onNavigateSettings={() => navigation.navigate('Settings')}
|
onNavigateSettings={() => navigation.navigate('Settings')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CreateNetworkSheet
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreated={(networkId) =>
|
||||||
|
navigation.navigate('StreamList', { networkId })
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InvitationsSection({ invitations }: { invitations: Invitation[] }) {
|
||||||
|
return (
|
||||||
|
<View className="border-b border-border">
|
||||||
|
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
|
||||||
|
Invitations
|
||||||
|
</Text>
|
||||||
|
{invitations.map((invitation) => (
|
||||||
|
<InvitationCard key={invitation.network_id} invitation={invitation} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvitationCard({ invitation }: { invitation: Invitation }) {
|
||||||
|
const acceptInvitation = useAcceptInvitation();
|
||||||
|
|
||||||
|
const handleAccept = async () => {
|
||||||
|
try {
|
||||||
|
await acceptInvitation.mutateAsync(invitation.network_id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-4">
|
||||||
|
<View className="flex-1 pr-3">
|
||||||
|
<Text className="text-foreground text-base font-semibold">
|
||||||
|
{invitation.network_name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-sm">
|
||||||
|
You’ve been invited to join
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleAccept}
|
||||||
|
disabled={acceptInvitation.isPending}
|
||||||
|
className="bg-primary rounded-full px-4 py-2"
|
||||||
|
>
|
||||||
|
<Text className="text-primary-foreground text-sm font-semibold">
|
||||||
|
{acceptInvitation.isPending ? 'Joining…' : 'Accept'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NetworkCard({
|
function NetworkCard({
|
||||||
network,
|
network,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -124,15 +198,23 @@ function NetworkCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center px-6">
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
<Text className="text-foreground text-lg font-medium text-center">
|
<Text className="text-foreground text-lg font-medium text-center">
|
||||||
You aren’t in any networks yet.
|
You aren’t in any networks yet.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground mt-2 text-center">
|
<Text className="text-muted-foreground mt-2 text-center">
|
||||||
Ask a friend for an invite, or create one on desktop.
|
Create one to get started, or ask a friend for an invite.
|
||||||
</Text>
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onCreate}
|
||||||
|
className="bg-primary rounded-full px-5 py-3 mt-6"
|
||||||
|
>
|
||||||
|
<Text className="text-primary-foreground font-semibold">
|
||||||
|
Create a network
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,47 @@
|
|||||||
import { Pressable, Text, View } from 'react-native';
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Switch, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { logError, toUserMessage } from '@/lib/errors';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
import type { RootStackScreenProps } from '@/navigation/types';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
export function SettingsScreen({
|
export function SettingsScreen({
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<'Settings'>) {
|
}: RootStackScreenProps<'Settings'>) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const signOut = useAuthStore((s) => s.signOut);
|
||||||
|
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||||
|
const [emailNotifications, setEmailNotifications] = useState(
|
||||||
|
user?.email_notifications_enabled ?? true,
|
||||||
|
);
|
||||||
|
const version = Constants.expoConfig?.version ?? '—';
|
||||||
|
|
||||||
|
// Optimistic toggle — flip the local + auth-store state immediately, roll
|
||||||
|
// back on failure. Mirrors desktop's settings-page handler.
|
||||||
|
const handleToggleEmailNotifications = async (checked: boolean) => {
|
||||||
|
setEmailNotifications(checked);
|
||||||
|
useAuthStore.setState((state) => ({
|
||||||
|
user: state.user
|
||||||
|
? { ...state.user, email_notifications_enabled: checked }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await apiClient.updateSettings({ email_notifications_enabled: checked });
|
||||||
|
} catch (err) {
|
||||||
|
setEmailNotifications(!checked);
|
||||||
|
useAuthStore.setState((state) => ({
|
||||||
|
user: state.user
|
||||||
|
? { ...state.user, email_notifications_enabled: !checked }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
logError(err, { scope: 'settings.emailNotifications' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<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">
|
||||||
@@ -17,11 +54,55 @@ export function SettingsScreen({
|
|||||||
<View className="w-8" />
|
<View className="w-8" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-1 items-center justify-center px-6">
|
<View className="px-4 py-4">
|
||||||
<Text className="text-muted-foreground text-center">
|
<SettingsGroup title="Notifications">
|
||||||
Theme, notifications, and account preferences land here later.
|
<View className="flex-row items-center justify-between px-4 py-3">
|
||||||
</Text>
|
<Text className="text-foreground text-base">
|
||||||
|
Email notifications
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={emailNotifications}
|
||||||
|
onValueChange={handleToggleEmailNotifications}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="About">
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-3">
|
||||||
|
<Text className="text-foreground text-base">Version</Text>
|
||||||
|
<Text className="text-muted-foreground text-base">{version}</Text>
|
||||||
|
</View>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<View className="mt-4">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void signOut()}
|
||||||
|
disabled={isSigningOut}
|
||||||
|
className="px-4 py-3 active:bg-accent rounded-xl"
|
||||||
|
>
|
||||||
|
<Text className="text-destructive text-base font-medium">
|
||||||
|
{isSigningOut ? 'Signing out…' : 'Sign out'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SettingsGroup({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="mb-2">
|
||||||
|
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View className="bg-muted/40 rounded-xl overflow-hidden">{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user