feat: discover people tab, privacy settings, and friend request button fix
- Add "Discover People" section to Add Friend tab with user cards, mutual counts, and inline actions - Add discoverStore for fetching/searching discoverable users across local and federated instances - Add PrivacyPanel to user settings with discoverability toggle - Add is_discoverable column to users table with migration - Fix "Send Friend Request" button vertical alignment using transform centering
This commit is contained in:
@@ -132,6 +132,12 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ name: 'profile_updated_at', type: 'INTEGER' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
columns: [
|
||||
{ name: 'discoverable', type: 'INTEGER DEFAULT 1' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export const users = sqliteTable('users', {
|
||||
avatarColor: text('avatar_color'),
|
||||
bio: text('bio'),
|
||||
isDeleted: integer('is_deleted').default(0),
|
||||
discoverable: integer('discoverable').default(1),
|
||||
profileUpdatedAt: integer('profile_updated_at'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt } = request.body;
|
||||
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const updateData: Record<string, string | null | undefined> = {};
|
||||
@@ -366,6 +366,13 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (discoverable !== undefined) {
|
||||
if (typeof discoverable !== 'boolean') {
|
||||
return reply.code(400).send({ error: 'discoverable must be a boolean', statusCode: 400 });
|
||||
}
|
||||
(updateData as Record<string, unknown>).discoverable = discoverable ? 1 : 0;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
isDeleted: true,
|
||||
discoverable: false,
|
||||
profileUpdatedAt: 0,
|
||||
createdAt: row.createdAt,
|
||||
homeInstance: null,
|
||||
@@ -46,6 +47,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
discoverable: row.discoverable !== 0,
|
||||
profileUpdatedAt: row.profileUpdatedAt ?? row.createdAt,
|
||||
createdAt: row.createdAt,
|
||||
homeInstance: row.homeInstance ?? null,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
CreateDmMessageRequest,
|
||||
Friend,
|
||||
FriendRequest,
|
||||
DiscoverUser,
|
||||
InstanceStreamingLimits,
|
||||
InstanceAdminSettings,
|
||||
InstanceInfoResponse,
|
||||
@@ -136,11 +137,12 @@ export class BackspaceApiClient {
|
||||
readonly social: {
|
||||
friends: () => Promise<Friend[]>;
|
||||
requests: () => Promise<FriendRequest[]>;
|
||||
sendRequest: (username: string) => Promise<{ success: boolean }>;
|
||||
sendRequest: (username: string) => Promise<{ success: boolean; requestId?: string }>;
|
||||
updateRequest: (id: string, status: 'accepted' | 'declined') => Promise<{ success: boolean }>;
|
||||
removeFriend: (id: string) => Promise<{ success: boolean }>;
|
||||
cancelRequest: (id: string) => Promise<{ success: boolean }>;
|
||||
search: (q: string) => Promise<User[]>;
|
||||
discover: (q?: string, limit?: number, offset?: number) => Promise<{ users: DiscoverUser[]; total: number }>;
|
||||
};
|
||||
|
||||
readonly livekit: {
|
||||
@@ -389,12 +391,19 @@ export class BackspaceApiClient {
|
||||
this.social = {
|
||||
friends: () => request<Friend[]>('GET', '/social/friends'),
|
||||
requests: () => request<FriendRequest[]>('GET', '/social/requests'),
|
||||
sendRequest: (username: string) => request<{ success: boolean }>('POST', '/social/requests', { username }),
|
||||
sendRequest: (username: string) => request<{ success: boolean; requestId?: string }>('POST', '/social/requests', { username }),
|
||||
updateRequest: (id: string, status: 'accepted' | 'declined') =>
|
||||
request<{ success: boolean }>('PATCH', `/social/requests/${id}`, { status }),
|
||||
removeFriend: (id: string) => request<{ success: boolean }>('DELETE', `/social/friends/${id}`),
|
||||
cancelRequest: (id: string) => request<{ success: boolean }>('DELETE', `/social/requests/${id}`),
|
||||
search: (q: string) => request<User[]>('GET', `/social/search?q=${encodeURIComponent(q)}`),
|
||||
discover: (q?: string, limit = 24, offset = 0) => {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
return request<{ users: DiscoverUser[]; total: number }>('GET', `/social/discover?${params}`);
|
||||
},
|
||||
};
|
||||
|
||||
this.livekit = {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
||||
import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useInstanceStore } from '../../stores/instanceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { getAvatarGradient } from '../../utils/gradients';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
type Tab = 'online' | 'all' | 'pending' | 'add';
|
||||
@@ -154,31 +156,14 @@ export function FriendsPage() {
|
||||
);
|
||||
case 'add':
|
||||
return (
|
||||
<div className="flex-1 p-8">
|
||||
<h2 className="text-base font-bold text-txt-primary uppercase mb-2">Add Friend</h2>
|
||||
<p className="text-sm text-txt-tertiary mb-4">You can add friends with their Backspace username.</p>
|
||||
<form onSubmit={handleAddFriend} className="relative mb-8">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="You can add a friend with their username"
|
||||
value={addUsername}
|
||||
onChange={(e) => setAddUsername(e.target.value)}
|
||||
className="w-full bg-surface-base text-txt-primary px-4 py-3 rounded-lg border border-transparent focus:border-txt-link outline-none transition-all placeholder:text-txt-tertiary/50"
|
||||
<AddFriendTab
|
||||
addUsername={addUsername}
|
||||
setAddUsername={setAddUsername}
|
||||
addStatus={addStatus}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleAddFriend}
|
||||
onOpenDm={handleOpenDm}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!addUsername.trim() || isLoading}
|
||||
className="absolute right-2 top-1.5 px-4 py-1.5 bg-accent-primary hover:bg-accent-primary-hover disabled:opacity-50 disabled:bg-accent-primary text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
Send Friend Request
|
||||
</button>
|
||||
</form>
|
||||
{addStatus && (
|
||||
<div className={`text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-txt-positive border-status-online/20 bg-status-online/5' : 'text-txt-danger border-accent-rose/20 bg-accent-rose/5'}`}>
|
||||
{addStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -227,6 +212,370 @@ export function FriendsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Add Friend Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
function AddFriendTab({
|
||||
addUsername,
|
||||
setAddUsername,
|
||||
addStatus,
|
||||
isLoading,
|
||||
onSubmit,
|
||||
onOpenDm,
|
||||
}: {
|
||||
addUsername: string;
|
||||
setAddUsername: (v: string) => void;
|
||||
addStatus: { type: 'success' | 'error'; message: string } | null;
|
||||
isLoading: boolean;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onOpenDm: (userId: string, origin: string) => void;
|
||||
}) {
|
||||
const discoverUsers = useDiscoverStore((s) => s.users);
|
||||
const discoverLoading = useDiscoverStore((s) => s.isLoading);
|
||||
const discoverQuery = useDiscoverStore((s) => s.searchQuery);
|
||||
const setDiscoverQuery = useDiscoverStore((s) => s.setSearchQuery);
|
||||
const fetchUsers = useDiscoverStore((s) => s.fetchUsers);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Fetch discovery on mount
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
// Cleanup debounce timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDiscoverSearch = useCallback((value: string) => {
|
||||
setDiscoverQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchUsers(value || undefined);
|
||||
}, 300);
|
||||
}, [setDiscoverQuery, fetchUsers]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 pb-4">
|
||||
<h2 className="text-base font-bold text-txt-primary uppercase mb-2">Add Friend</h2>
|
||||
<p className="text-sm text-txt-tertiary mb-4">You can add friends with their Backspace username.</p>
|
||||
<form onSubmit={onSubmit} className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="You can add a friend with their username"
|
||||
value={addUsername}
|
||||
onChange={(e) => setAddUsername(e.target.value)}
|
||||
className="w-full bg-surface-base text-txt-primary px-4 py-3 rounded-lg border border-transparent focus:border-txt-link outline-none transition-all placeholder:text-txt-tertiary/50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!addUsername.trim() || isLoading}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-accent-primary hover:bg-accent-primary-hover disabled:opacity-50 disabled:bg-accent-primary text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
Send Friend Request
|
||||
</button>
|
||||
</form>
|
||||
{addStatus && (
|
||||
<div className={`text-sm p-3 rounded-lg border mb-4 ${addStatus.type === 'success' ? 'text-txt-positive border-status-online/20 bg-status-online/5' : 'text-txt-danger border-accent-rose/20 bg-accent-rose/5'}`}>
|
||||
{addStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Discover People section */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
|
||||
</svg>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-txt-tertiary">Discover People</span>
|
||||
</div>
|
||||
|
||||
{/* Discover search */}
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search people..."
|
||||
value={discoverQuery}
|
||||
onChange={(e) => handleDiscoverSearch(e.target.value)}
|
||||
className="w-full bg-surface-base text-txt-primary text-sm px-3 py-1.5 rounded-[4px] outline-none placeholder:text-txt-tertiary/50 focus:ring-1 focus:ring-accent-primary transition-all"
|
||||
/>
|
||||
{discoverQuery && (
|
||||
<button
|
||||
onClick={() => handleDiscoverSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-txt-tertiary hover:text-txt-secondary"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{discoverLoading && discoverUsers.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : discoverUsers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 opacity-60">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary mb-2">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<p className="text-txt-tertiary text-sm">
|
||||
{discoverQuery
|
||||
? 'No users match your search.'
|
||||
: 'No discoverable users yet — invite people to join!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{discoverUsers.map((user) => (
|
||||
<UserDiscoverCard
|
||||
key={`${user.id}:${user._instanceOrigin}`}
|
||||
user={user}
|
||||
onOpenDm={onOpenDm}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── User Discover Card ─────────────────────────────────────────────────────
|
||||
|
||||
function UserDiscoverCard({
|
||||
user,
|
||||
onOpenDm,
|
||||
}: {
|
||||
user: TaggedDiscoverUser;
|
||||
onOpenDm: (userId: string, origin: string) => void;
|
||||
}) {
|
||||
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
|
||||
const updateRelationship = useDiscoverStore((s) => s.updateRelationship);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const displayName = user.displayName ?? user.username;
|
||||
const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username;
|
||||
const gradient = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
|
||||
const originLabel = user._instanceOrigin
|
||||
? (() => { try { return new URL(user._instanceOrigin).host; } catch { return user._instanceOrigin; } })()
|
||||
: null;
|
||||
|
||||
const avatarUrl = user.avatar
|
||||
? (user.avatar.startsWith('http') ? user.avatar : `/api/uploads/${user.avatar}`)
|
||||
: null;
|
||||
const bannerUrl = user.banner
|
||||
? (user.banner.startsWith('http') ? user.banner : `/api/uploads/${user.banner}`)
|
||||
: null;
|
||||
|
||||
const handleSendRequest = async () => {
|
||||
setActionLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
// For federated users, use user@host format
|
||||
const username = user._instanceOrigin ? baseName + '@' + (originLabel ?? '') : baseName;
|
||||
const requestId = await sendFriendRequest(username);
|
||||
updateRelationship(user.id, user._instanceOrigin, 'outbound_pending', requestId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to send request');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
if (!user.requestId) return;
|
||||
setActionLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await updateFriendRequest(user.requestId, 'accepted');
|
||||
updateRelationship(user.id, user._instanceOrigin, 'friends');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to accept request');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
if (!user.requestId) return;
|
||||
setActionLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await updateFriendRequest(user.requestId, 'declined');
|
||||
updateRelationship(user.id, user._instanceOrigin, 'none');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to decline request');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRequest = async () => {
|
||||
if (!user.requestId) return;
|
||||
setActionLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const origin = user._instanceOrigin;
|
||||
const client = origin
|
||||
? (useInstanceStore.getState().instances.find(i => i.origin === origin)?.api ?? api)
|
||||
: api;
|
||||
await client.social.cancelRequest(user.requestId);
|
||||
updateRelationship(user.id, user._instanceOrigin, 'none');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel request');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenProfile = () => {
|
||||
openModal('userProfile', { userId: user.id, user, origin: user._instanceOrigin });
|
||||
};
|
||||
|
||||
const handleMessage = () => {
|
||||
onOpenDm(user.id, user._instanceOrigin);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface-channel rounded-lg border border-border-soft hover:border-border-hard overflow-hidden flex flex-col transition-colors">
|
||||
{/* Banner area */}
|
||||
<div className="h-24 relative overflow-hidden">
|
||||
{bannerUrl ? (
|
||||
<img src={bannerUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0" style={{ background: gradient.gradient }} />
|
||||
)}
|
||||
<div
|
||||
className="absolute bottom-0 inset-x-0 h-12"
|
||||
style={{ background: 'linear-gradient(to top, rgba(20,20,26,0.9), transparent)' }}
|
||||
/>
|
||||
{originLabel && (
|
||||
<div className="absolute top-2 right-2 z-[2]">
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold backdrop-blur-sm bg-black/30 text-txt-secondary">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||
</svg>
|
||||
{originLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlapping avatar */}
|
||||
<div className="relative px-4 -mt-7 z-10">
|
||||
<button onClick={handleOpenProfile} className="block">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className="w-12 h-12 rounded-full object-cover ring-[3px] ring-surface-channel shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full ring-[3px] ring-surface-channel shadow-lg flex items-center justify-center text-lg font-bold text-white/90"
|
||||
style={{ background: gradient.gradient }}
|
||||
>
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pt-1.5 pb-3.5 flex flex-col flex-1">
|
||||
<button onClick={handleOpenProfile} className="text-left group">
|
||||
<h3 className="text-[14px] font-bold text-txt-primary truncate group-hover:underline">{displayName}</h3>
|
||||
<p className="text-[12px] text-txt-tertiary truncate">@{user.username}</p>
|
||||
</button>
|
||||
|
||||
{user.bio && (
|
||||
<p className="text-[12px] text-txt-secondary line-clamp-2 mt-1.5 flex-1">{user.bio}</p>
|
||||
)}
|
||||
{!user.bio && <div className="flex-1" />}
|
||||
|
||||
{/* Mutuals */}
|
||||
{(user.mutualFriendCount > 0 || user.mutualSpaceCount > 0) && (
|
||||
<div className="flex items-center gap-2.5 text-[11px] text-txt-tertiary mt-2 mb-2.5">
|
||||
{user.mutualFriendCount > 0 && (
|
||||
<span>{user.mutualFriendCount} mutual {user.mutualFriendCount === 1 ? 'friend' : 'friends'}</span>
|
||||
)}
|
||||
{user.mutualFriendCount > 0 && user.mutualSpaceCount > 0 && (
|
||||
<span className="text-txt-tertiary/40">·</span>
|
||||
)}
|
||||
{user.mutualSpaceCount > 0 && (
|
||||
<span>{user.mutualSpaceCount} mutual {user.mutualSpaceCount === 1 ? 'space' : 'spaces'}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{user.mutualFriendCount === 0 && user.mutualSpaceCount === 0 && <div className="mt-2" />}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="text-[11px] text-txt-danger mb-1.5 truncate">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{user.relationship === 'none' && (
|
||||
<button
|
||||
onClick={handleSendRequest}
|
||||
disabled={actionLoading}
|
||||
className="w-full py-1.5 bg-accent-primary hover:bg-accent-primary-hover text-white text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Sending...' : 'Send Friend Request'}
|
||||
</button>
|
||||
)}
|
||||
{user.relationship === 'outbound_pending' && (
|
||||
<button
|
||||
onClick={handleCancelRequest}
|
||||
disabled={actionLoading || !user.requestId}
|
||||
className="w-full py-1.5 bg-accent-amber/15 hover:bg-accent-amber/25 text-accent-amber text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Cancelling...' : 'Request Pending'}
|
||||
</button>
|
||||
)}
|
||||
{user.relationship === 'inbound_pending' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
disabled={actionLoading}
|
||||
className="flex-1 py-1.5 bg-status-online/20 hover:bg-status-online/30 text-status-online text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
disabled={actionLoading}
|
||||
className="flex-1 py-1.5 bg-accent-rose/15 hover:bg-accent-rose/25 text-accent-rose text-[13px] font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{user.relationship === 'friends' && (
|
||||
<button
|
||||
onClick={handleMessage}
|
||||
className="w-full py-1.5 bg-accent-mint/15 hover:bg-accent-mint/25 text-accent-mint text-[13px] font-medium rounded transition-colors"
|
||||
>
|
||||
Message
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared Components ──────────────────────────────────────────────────────
|
||||
|
||||
function TabButton({ children, active, onClick }: { children: React.ReactNode, active: boolean, onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useUIStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { AccountPanel } from './settingsPanels/AccountPanel';
|
||||
import { VoicePanel } from './settingsPanels/VoicePanel';
|
||||
import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
|
||||
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
|
||||
import { InstancePanel } from './settingsPanels/InstancePanel';
|
||||
|
||||
type SettingsTab = 'account' | 'voice' | 'connections' | 'instance';
|
||||
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'instance';
|
||||
|
||||
export function UserSettingsModal() {
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
@@ -24,7 +25,7 @@ export function UserSettingsModal() {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const requested = modalData.tab as SettingsTab | undefined;
|
||||
if (requested && ['account', 'voice', 'connections', 'instance'].includes(requested)) {
|
||||
if (requested && ['account', 'voice', 'privacy', 'connections', 'instance'].includes(requested)) {
|
||||
// Only allow instance tab for admins
|
||||
if (requested === 'instance' && !isAdmin) {
|
||||
setTab('account');
|
||||
@@ -59,6 +60,9 @@ export function UserSettingsModal() {
|
||||
<button onClick={() => setTab('voice')} className={tabClass('voice')}>
|
||||
Voice
|
||||
</button>
|
||||
<button onClick={() => setTab('privacy')} className={tabClass('privacy')}>
|
||||
Privacy
|
||||
</button>
|
||||
<button onClick={() => setTab('connections')} className={tabClass('connections')}>
|
||||
Connections
|
||||
</button>
|
||||
@@ -82,6 +86,7 @@ export function UserSettingsModal() {
|
||||
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
|
||||
{tab === 'account' && <AccountPanel />}
|
||||
{tab === 'voice' && <VoicePanel />}
|
||||
{tab === 'privacy' && <PrivacyPanel />}
|
||||
{tab === 'connections' && <ConnectionsPanel />}
|
||||
{tab === 'instance' && isAdmin && <InstancePanel />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuthStore } from '../../../stores/authStore';
|
||||
import { api } from '../../../api/client';
|
||||
import { Toggle } from '../../ui/Toggle';
|
||||
|
||||
export function PrivacyPanel() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const [discoverable, setDiscoverable] = useState(user?.discoverable !== false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDiscoverable(user?.discoverable !== false);
|
||||
}, [user?.discoverable]);
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
setDiscoverable(enabled);
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.users.update({ discoverable: enabled });
|
||||
setUser(updated);
|
||||
} catch {
|
||||
// Revert on failure
|
||||
setDiscoverable(!enabled);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Discovery
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-sm text-txt-primary">Allow others to find my profile</div>
|
||||
<div className="text-xs text-txt-tertiary mt-0.5">
|
||||
When enabled, your profile appears in Discover People. Others can always add you by exact username.
|
||||
</div>
|
||||
</div>
|
||||
<Toggle enabled={discoverable} onChange={handleToggle} />
|
||||
</div>
|
||||
{saving && (
|
||||
<div className="text-xs text-txt-tertiary mt-2">Saving...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { create } from 'zustand';
|
||||
import type { DiscoverUser } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
import { useInstanceStore } from './instanceStore';
|
||||
import { normalizeUserAssets } from '../utils/assetUrls';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TaggedDiscoverUser extends DiscoverUser {
|
||||
_instanceOrigin: string; // '' = home instance
|
||||
}
|
||||
|
||||
interface DiscoverState {
|
||||
users: TaggedDiscoverUser[];
|
||||
searchQuery: string;
|
||||
isLoading: boolean;
|
||||
total: number;
|
||||
error: string | null;
|
||||
|
||||
fetchUsers: (query?: string) => Promise<void>;
|
||||
setSearchQuery: (q: string) => void;
|
||||
updateRelationship: (userId: string, origin: string, relationship: DiscoverUser['relationship'], requestId?: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const useDiscoverStore = create<DiscoverState>((set) => ({
|
||||
users: [],
|
||||
searchQuery: '',
|
||||
isLoading: false,
|
||||
total: 0,
|
||||
error: null,
|
||||
|
||||
fetchUsers: async (query?: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// Wait for autoConnectAll to finish (same guard as exploreStore)
|
||||
if (!useInstanceStore.getState()._autoConnectDone) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsub = useInstanceStore.subscribe((state) => {
|
||||
if (state._autoConnectDone) {
|
||||
unsub();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
if (useInstanceStore.getState()._autoConnectDone) {
|
||||
unsub();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const instances = useInstanceStore.getState().instances;
|
||||
const connectedInstances = instances.filter(i => i.status === 'connected');
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
api.social.discover(query).then(res => ({ ...res, origin: '' })),
|
||||
...connectedInstances.map(inst =>
|
||||
inst.api.social.discover(query).then(res => ({ ...res, origin: inst.origin }))
|
||||
),
|
||||
]);
|
||||
|
||||
const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<{ users: DiscoverUser[]; total: number; origin: string }>[];
|
||||
|
||||
if (fulfilled.length === 0) {
|
||||
set({ isLoading: false, error: 'Failed to reach any instance for discovery' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allUsers: TaggedDiscoverUser[] = [];
|
||||
const seen = new Set<string>();
|
||||
let totalSum = 0;
|
||||
|
||||
for (const result of fulfilled) {
|
||||
const { users, total, origin } = result.value;
|
||||
totalSum += total;
|
||||
|
||||
for (const user of users) {
|
||||
const key = `${user.id}:${origin}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (origin) normalizeUserAssets(user as unknown as { avatar?: string | null; banner?: string | null }, origin);
|
||||
allUsers.push({ ...user, _instanceOrigin: origin });
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
users: allUsers,
|
||||
total: totalSum,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to discover users',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setSearchQuery: (q: string) => set({ searchQuery: q }),
|
||||
|
||||
updateRelationship: (userId: string, origin: string, relationship: DiscoverUser['relationship'], requestId?: string) => {
|
||||
set((state) => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId && u._instanceOrigin === origin
|
||||
? { ...u, relationship, ...(requestId !== undefined ? { requestId } : {}) }
|
||||
: u
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
reset: () => set({
|
||||
users: [],
|
||||
searchQuery: '',
|
||||
isLoading: false,
|
||||
total: 0,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user