diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 47d30fb3..49b4137a 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -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' }, + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 071302af..2a689994 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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(), }); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 2829b821..e9d3040f 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -255,7 +255,7 @@ export async function userRoutes(app: FastifyInstance): Promise { }); 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 = {}; @@ -366,6 +366,13 @@ export async function userRoutes(app: FastifyInstance): Promise { } } + if (discoverable !== undefined) { + if (typeof discoverable !== 'boolean') { + return reply.code(400).send({ error: 'discoverable must be a boolean', statusCode: 400 }); + } + (updateData as Record).discoverable = discoverable ? 1 : 0; + } + if (Object.keys(updateData).length === 0) { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts index 01bf01f5..b64918fb 100644 --- a/packages/server/src/utils/sanitize.ts +++ b/packages/server/src/utils/sanitize.ts @@ -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, diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 1f8d8ebe..52e40843 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -26,6 +26,7 @@ import type { CreateDmMessageRequest, Friend, FriendRequest, + DiscoverUser, InstanceStreamingLimits, InstanceAdminSettings, InstanceInfoResponse, @@ -136,11 +137,12 @@ export class BackspaceApiClient { readonly social: { friends: () => Promise; requests: () => Promise; - 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; + 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('GET', '/social/friends'), requests: () => request('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('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 = { diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index fa3932ae..77bee0e3 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -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 ( -
-

Add Friend

-

You can add friends with their Backspace username.

-
- 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" - /> - -
- {addStatus && ( -
- {addStatus.message} -
- )} -
+ ); } }; @@ -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 | 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 ( +
+
+

Add Friend

+

You can add friends with their Backspace username.

+
+ 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" + /> + +
+ {addStatus && ( +
+ {addStatus.message} +
+ )} +
+ + {/* Discover People section */} +
+
+ + + + Discover People +
+ + {/* Discover search */} +
+ 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 && ( + + )} +
+ + {/* Grid */} + {discoverLoading && discoverUsers.length === 0 ? ( +
+ +
+ ) : discoverUsers.length === 0 ? ( +
+ + + +

+ {discoverQuery + ? 'No users match your search.' + : 'No discoverable users yet — invite people to join!'} +

+
+ ) : ( +
+ {discoverUsers.map((user) => ( + + ))} +
+ )} +
+
+ ); +} + +// ─── 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 ( +
+ {/* Banner area */} +
+ {bannerUrl ? ( + + ) : ( +
+ )} +
+ {originLabel && ( +
+ + + + + {originLabel} + +
+ )} +
+ + {/* Overlapping avatar */} +
+ +
+ + {/* Content */} +
+ + + {user.bio && ( +

{user.bio}

+ )} + {!user.bio &&
} + + {/* Mutuals */} + {(user.mutualFriendCount > 0 || user.mutualSpaceCount > 0) && ( +
+ {user.mutualFriendCount > 0 && ( + {user.mutualFriendCount} mutual {user.mutualFriendCount === 1 ? 'friend' : 'friends'} + )} + {user.mutualFriendCount > 0 && user.mutualSpaceCount > 0 && ( + · + )} + {user.mutualSpaceCount > 0 && ( + {user.mutualSpaceCount} mutual {user.mutualSpaceCount === 1 ? 'space' : 'spaces'} + )} +
+ )} + {user.mutualFriendCount === 0 && user.mutualSpaceCount === 0 &&
} + + {/* Error */} + {error && ( +
{error}
+ )} + + {/* Action button */} + {user.relationship === 'none' && ( + + )} + {user.relationship === 'outbound_pending' && ( + + )} + {user.relationship === 'inbound_pending' && ( +
+ + +
+ )} + {user.relationship === 'friends' && ( + + )} +
+
+ ); +} + +// ─── Shared Components ────────────────────────────────────────────────────── + function TabButton({ children, active, onClick }: { children: React.ReactNode, active: boolean, onClick: () => void }) { return ( + @@ -82,6 +86,7 @@ export function UserSettingsModal() {
{tab === 'account' && } {tab === 'voice' && } + {tab === 'privacy' && } {tab === 'connections' && } {tab === 'instance' && isAdmin && }
diff --git a/packages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx b/packages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx new file mode 100644 index 00000000..7b07fdc8 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx @@ -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 ( +
+
+
+ Discovery +
+
+
+
+
Allow others to find my profile
+
+ When enabled, your profile appears in Discover People. Others can always add you by exact username. +
+
+ +
+ {saving && ( +
Saving...
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/stores/discoverStore.ts b/packages/web/src/stores/discoverStore.ts new file mode 100644 index 00000000..958d4d0e --- /dev/null +++ b/packages/web/src/stores/discoverStore.ts @@ -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; + setSearchQuery: (q: string) => void; + updateRelationship: (userId: string, origin: string, relationship: DiscoverUser['relationship'], requestId?: string) => void; + reset: () => void; +} + +// ─── Store ────────────────────────────────────────────────────────────────── + +export const useDiscoverStore = create((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((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(); + 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, + }), +}));