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.
This commit is contained in:
@@ -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<User | null>(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
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFriendAction}
|
||||
disabled={friendActionLoading}
|
||||
className={`flex-1 py-2 rounded-lg text-[13px] font-medium border transition-colors disabled:opacity-50 ${
|
||||
isFriend
|
||||
? 'text-txt-danger border-txt-danger/30 hover:bg-txt-danger/10'
|
||||
: 'text-txt-primary border-white/[0.08] bg-white/[0.06] hover:bg-white/[0.10]'
|
||||
}`}
|
||||
>
|
||||
{friendActionLoading ? '...' : isFriend ? 'Remove Friend' : 'Add Friend'}
|
||||
</button>
|
||||
|
||||
{friendship.state === 'none' && (
|
||||
<button onClick={handleAddFriend} disabled={friendActionLoading}
|
||||
className="flex-1 py-2 rounded-lg text-[13px] font-medium text-txt-primary border border-white/[0.08] bg-white/[0.06] hover:bg-white/[0.10] transition-colors disabled:opacity-50">
|
||||
{friendActionLoading ? '...' : 'Add Friend'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{friendship.state === 'outbound_pending' && (
|
||||
<button onClick={handleCancelRequest} disabled={friendActionLoading}
|
||||
className="flex-1 py-2 rounded-lg text-[13px] font-medium text-amber-400 border border-amber-400/30 hover:bg-amber-400/10 transition-colors disabled:opacity-50">
|
||||
{friendActionLoading ? '...' : 'Cancel Request'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{friendship.state === 'inbound_pending' && (
|
||||
<>
|
||||
<button onClick={handleAcceptRequest} disabled={friendActionLoading}
|
||||
className="flex-1 py-2 rounded-lg text-[13px] font-medium text-white bg-accent-primary hover:bg-accent-primary/80 transition-colors disabled:opacity-50">
|
||||
{friendActionLoading ? '...' : 'Accept'}
|
||||
</button>
|
||||
<button onClick={handleDeclineRequest} disabled={friendActionLoading}
|
||||
className="py-2 px-3 rounded-lg text-[13px] font-medium text-txt-tertiary border border-white/[0.06] hover:bg-white/[0.06] transition-colors disabled:opacity-50">
|
||||
{friendActionLoading ? '...' : 'Ignore'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{friendship.state === 'friends' && (
|
||||
<button onClick={handleRemoveFriend} disabled={friendActionLoading}
|
||||
className="flex-1 py-2 rounded-lg text-[13px] font-medium text-txt-danger border border-txt-danger/30 hover:bg-txt-danger/10 transition-colors disabled:opacity-50">
|
||||
{friendActionLoading ? '...' : 'Remove Friend'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user