From d7449bdf424ff6f04762e736639c5015dec7f399 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:56:13 +0100 Subject: [PATCH] fix: load social data on WS ready and use federation-safe canonical matching for friend button state Friends/requests were only loaded when FriendsPage or ActivityPanel rendered, so profile modals in space views always showed "Add Friend" even for existing friends. Now loadFriends/loadRequests fire on every WS ready event. Also adds canonicalUserMatch() with cascading ID/homeUserId/username+instance fallback, replacing fragile homeUserId-only matching in getFriendshipStatus. --- .../components/modals/UserProfileModal.tsx | 136 ++++++++++++++---- packages/web/src/hooks/useWebSocket.ts | 9 ++ packages/web/src/utils/identity.ts | 30 ++++ 3 files changed, 148 insertions(+), 27 deletions(-) diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index e1839a2b..2eb39426 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -6,13 +6,47 @@ import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; -import { useSocialStore } from '../../stores/socialStore'; +import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; +import { useAuthStore } from '../../stores/authStore'; import { getAvatarGradient, getSpaceGradient, adjustColor } from '../../utils/gradients'; -import { parseFederatedUsername } from '../../utils/identity'; +import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity'; import { loadFederatedMutuals, type TaggedMutualFriend, type MutualSpace } from '../../utils/mutuals'; type Tab = 'about' | 'friends' | 'spaces'; +type FriendshipStatus = + | { state: 'self' } + | { state: 'friends'; friend: TaggedFriend } + | { state: 'outbound_pending'; request: TaggedFriendRequest } + | { state: 'inbound_pending'; request: TaggedFriendRequest } + | { state: 'none' }; + +function getFriendshipStatus( + viewedUser: User, + currentUser: User | null, + friends: TaggedFriend[], + requests: TaggedFriendRequest[], +): FriendshipStatus { + if (!currentUser) return { state: 'none' }; + if (isSelf(viewedUser, currentUser)) return { state: 'self' }; + + const friend = friends.find(f => canonicalUserMatch(f, viewedUser)); + if (friend) return { state: 'friends', friend }; + + const request = requests.find(r => + r.user && canonicalUserMatch(r.user, viewedUser) + ); + if (request?.user) { + // request.user is the OTHER party. If their ID === toId, then I am fromId (outbound) + const isOutbound = request.user.id === request.toId; + return isOutbound + ? { state: 'outbound_pending', request } + : { state: 'inbound_pending', request }; + } + + return { state: 'none' }; +} + export function UserProfileModal() { const activeModal = useUIStore((s) => s.activeModal); const modalData = useUIStore((s) => s.modalData); @@ -20,8 +54,12 @@ export function UserProfileModal() { const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); const friends = useSocialStore((s) => s.friends); + const requests = useSocialStore((s) => s.requests); const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); const removeFriend = useSocialStore((s) => s.removeFriend); + const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest); + const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest); + const currentUser = useAuthStore((s) => s.user); const [user, setUser] = useState(null); const [userOrigin, setUserOrigin] = useState(''); @@ -36,8 +74,10 @@ export function UserProfileModal() { const passedUser = modalData?.user as User | undefined; const passedOrigin = (modalData?.origin as string | undefined) ?? ''; - // Determine friendship status - const isFriend = user ? friends.some((f) => f.id === user.id) : false; + // Determine friendship status (federation-safe canonical matching) + const friendship: FriendshipStatus = user + ? getFriendshipStatus(user, currentUser, friends, requests) + : { state: 'none' }; const loadUser = useCallback(async (id: string, origin: string) => { try { @@ -132,19 +172,38 @@ export function UserProfileModal() { } }; - const handleFriendAction = async () => { + const handleAddFriend = async () => { setFriendActionLoading(true); - try { - if (isFriend) { - await removeFriend(user.id); - } else { - await sendFriendRequest(user.username); - } - } catch { - // Silently fail - } finally { - setFriendActionLoading(false); - } + try { await sendFriendRequest(user.username); } catch { /* silent */ } + finally { setFriendActionLoading(false); } + }; + + const handleRemoveFriend = async () => { + if (friendship.state !== 'friends') return; + setFriendActionLoading(true); + try { await removeFriend(friendship.friend.id); } catch { /* silent */ } + finally { setFriendActionLoading(false); } + }; + + const handleCancelRequest = async () => { + if (friendship.state !== 'outbound_pending') return; + setFriendActionLoading(true); + try { await cancelFriendRequest(friendship.request.id); } catch { /* silent */ } + finally { setFriendActionLoading(false); } + }; + + const handleAcceptRequest = async () => { + if (friendship.state !== 'inbound_pending') return; + setFriendActionLoading(true); + try { await updateFriendRequest(friendship.request.id, 'accepted'); } catch { /* silent */ } + finally { setFriendActionLoading(false); } + }; + + const handleDeclineRequest = async () => { + if (friendship.state !== 'inbound_pending') return; + setFriendActionLoading(true); + try { await updateFriendRequest(friendship.request.id, 'declined'); } catch { /* silent */ } + finally { setFriendActionLoading(false); } }; const handleViewFriend = (friend: TaggedMutualFriend) => { @@ -437,17 +496,40 @@ export function UserProfileModal() { > Send Message - + + {friendship.state === 'none' && ( + + )} + + {friendship.state === 'outbound_pending' && ( + + )} + + {friendship.state === 'inbound_pending' && ( + <> + + + + )} + + {friendship.state === 'friends' && ( + + )} diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index f4744c36..fad68818 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -263,6 +263,15 @@ function handleEvent(origin: string, event: ServerEvent): void { } } } + + // Load social data so profile modals show correct friendship state. + // Runs on each ready (home + remote) — loadFriends/loadRequests fan out + // across all connected instances and replace the arrays (idempotent). + { + const { loadFriends, loadRequests } = useSocialStore.getState(); + loadFriends(); + loadRequests(); + } break; case 'message_created': diff --git a/packages/web/src/utils/identity.ts b/packages/web/src/utils/identity.ts index 05e68db7..fa556d7a 100644 --- a/packages/web/src/utils/identity.ts +++ b/packages/web/src/utils/identity.ts @@ -41,3 +41,33 @@ export function resolveDisplayIdentity(user: User, homeUser: User | null): User if (isSelf(user, homeUser)) return homeUser; return user; } + +/** + * Federation-safe check: do two user-like objects represent the same person? + * Uses cascading strategies to handle missing homeUserId on old replicated users. + */ +export function canonicalUserMatch( + a: { id: string; username: string; homeUserId?: string | null; homeInstance?: string | null }, + b: { id: string; username: string; homeUserId?: string | null; homeInstance?: string | null }, +): boolean { + // 1. Same local ID (same instance) + if (a.id === b.id) return true; + + // 2. homeUserId cross-matching + if (a.homeUserId && b.homeUserId && a.homeUserId === b.homeUserId) return true; + if (a.homeUserId && a.homeUserId === b.id) return true; + if (b.homeUserId && b.homeUserId === a.id) return true; + + // 3. Username + home instance fallback (mirrors isSelf resilience) + const aBase = parseFederatedUsername(a.username); + const bBase = parseFederatedUsername(b.username); + if (aBase.baseName !== bBase.baseName) return false; + + const aHome = a.homeInstance ?? aBase.domain ?? null; + const bHome = b.homeInstance ?? bBase.domain ?? null; + + if (!aHome && !bHome) return true; // Both native to home instance + if (!aHome) return bHome === window.location.host; // a native, b federated + if (!bHome) return aHome === window.location.host; // b native, a federated + return aHome === bHome; // Both have explicit homes +}