From 9382477e338349f5fe68d86f1f6eeb7333dbb8bd Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:42:25 +0100 Subject: [PATCH] fix: real-time friend request cancel/decline via WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelled requests now disappear from receiver's UI instantly, and declined requests revert the sender's discover card from "Request Pending" to "Send Friend Request" — no page refresh needed. Also includes the discover endpoint and sendFriendRequest return type changes from the prior session. --- packages/server/src/routes/social.ts | 150 ++++++++++++++++++++++++- packages/shared/src/types.ts | 23 ++++ packages/web/src/hooks/useWebSocket.ts | 18 +++ packages/web/src/stores/socialStore.ts | 18 ++- 4 files changed, 203 insertions(+), 6 deletions(-) diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 8ec77c09..e13ae3f2 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { eq, and, or, ne, like } from 'drizzle-orm'; +import { eq, and, or, ne, like, sql } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; @@ -9,6 +9,7 @@ import type { FriendRequest, SendFriendRequest, UpdateFriendRequest, + DiscoverUser, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; @@ -170,7 +171,7 @@ export async function socialRoutes(app: FastifyInstance): Promise { request: friendRequestPayload, }); - return reply.code(201).send({ success: true }); + return reply.code(201).send({ success: true, requestId: id }); }); // PATCH /api/social/requests/:id - Accept/Decline a friend request @@ -229,6 +230,13 @@ export async function socialRoutes(app: FastifyInstance): Promise { .set({ status }) .where(eq(schema.friendRequests.id, id)) .run(); + + // Broadcast to the original sender so their UI updates in real-time + connectionManager.sendToUser(friendRequest.fromId, { + type: 'friend_request_declined', + requestId: id, + userId: request.userId, + }); } return reply.code(200).send({ success: true }); @@ -259,6 +267,13 @@ export async function socialRoutes(app: FastifyInstance): Promise { .where(eq(schema.friendRequests.id, id)) .run(); + // Broadcast to the recipient so their UI updates in real-time + connectionManager.sendToUser(friendRequest.toId, { + type: 'friend_request_cancelled', + requestId: id, + userId: request.userId, + }); + return reply.code(200).send({ success: true }); }); @@ -283,6 +298,137 @@ export async function socialRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ success: true }); }); + // GET /api/social/discover - Discover users on this instance + app.get<{ Querystring: { q?: string; limit?: string; offset?: string } }>('/api/social/discover', { + preHandler: authenticate, + }, async (request, reply) => { + const db = getDb(); + const q = request.query.q?.trim() || ''; + const limit = Math.min(Math.max(parseInt(request.query.limit || '24', 10) || 24, 1), 100); + const offset = Math.max(parseInt(request.query.offset || '0', 10) || 0, 0); + const myId = request.userId; + + // Build WHERE clause + const conditions = [ + eq(schema.users.discoverable, 1), + eq(schema.users.isDeleted, 0), + ne(schema.users.id, myId), + // Exclude replicated federated users — each instance only surfaces its own native users. + // Federated users are discovered through the parallel fetch across connected instances. + sql`(${schema.users.homeInstance} IS NULL OR ${schema.users.homeInstance} = '')`, + ]; + + if (q) { + const pattern = `%${q}%`; + conditions.push(or( + like(schema.users.username, pattern), + like(schema.users.displayName, pattern), + )!); + } + + // Get total count + const countResult = db.select({ count: sql`count(*)` }) + .from(schema.users) + .where(and(...conditions)) + .get(); + const total = countResult?.count ?? 0; + + if (total === 0) { + return reply.code(200).send({ users: [], total: 0 }); + } + + // Pre-load my social graph + const myFriendRows = db.select().from(schema.friends).where( + or(eq(schema.friends.userId, myId), eq(schema.friends.friendId, myId)) + ).all(); + const myFriendIds = new Set(myFriendRows.map(f => f.userId === myId ? f.friendId : f.userId)); + + const mySpaceRows = db.select({ spaceId: schema.spaceMembers.spaceId }) + .from(schema.spaceMembers) + .where(eq(schema.spaceMembers.userId, myId)) + .all(); + const mySpaceIds = new Set(mySpaceRows.map(s => s.spaceId)); + + const outboundRequests = db.select().from(schema.friendRequests).where( + and(eq(schema.friendRequests.fromId, myId), eq(schema.friendRequests.status, 'pending')) + ).all(); + const outboundMap = new Map(outboundRequests.map(r => [r.toId, r.id])); + + const inboundRequests = db.select().from(schema.friendRequests).where( + and(eq(schema.friendRequests.toId, myId), eq(schema.friendRequests.status, 'pending')) + ).all(); + const inboundMap = new Map(inboundRequests.map(r => [r.fromId, r.id])); + + // Fetch page of users + const userRows = db.select() + .from(schema.users) + .where(and(...conditions)) + .orderBy(sql`created_at DESC`) + .limit(limit) + .offset(offset) + .all(); + + // Compute mutual counts + relationship for each user + const discoverUsers: DiscoverUser[] = userRows.map(row => { + const u = sanitizeUser(row); + + // Mutual friends + const theirFriendRows = db.select().from(schema.friends).where( + or(eq(schema.friends.userId, row.id), eq(schema.friends.friendId, row.id)) + ).all(); + const theirFriendIds = new Set(theirFriendRows.map(f => f.userId === row.id ? f.friendId : f.userId)); + const mutualFriendCount = [...myFriendIds].filter(id => theirFriendIds.has(id)).length; + + // Mutual spaces + const theirSpaceRows = db.select({ spaceId: schema.spaceMembers.spaceId }) + .from(schema.spaceMembers) + .where(eq(schema.spaceMembers.userId, row.id)) + .all(); + const theirSpaceIds = new Set(theirSpaceRows.map(s => s.spaceId)); + const mutualSpaceCount = [...mySpaceIds].filter(id => theirSpaceIds.has(id)).length; + + // Relationship + let relationship: DiscoverUser['relationship'] = 'none'; + let requestId: string | undefined; + if (myFriendIds.has(row.id)) { + relationship = 'friends'; + } else if (outboundMap.has(row.id)) { + relationship = 'outbound_pending'; + requestId = outboundMap.get(row.id); + } else if (inboundMap.has(row.id)) { + relationship = 'inbound_pending'; + requestId = inboundMap.get(row.id); + } + + return { + id: u.id, + username: u.username, + displayName: u.displayName, + avatar: u.avatar, + banner: u.banner, + avatarColor: u.avatarColor, + bio: u.bio, + status: u.status, + customStatus: u.customStatus, + createdAt: u.createdAt, + homeInstance: u.homeInstance, + homeUserId: u.homeUserId, + mutualFriendCount, + mutualSpaceCount, + relationship, + ...(requestId ? { requestId } : {}), + }; + }); + + // Sort: mutual friends DESC, then created_at DESC + discoverUsers.sort((a, b) => { + if (b.mutualFriendCount !== a.mutualFriendCount) return b.mutualFriendCount - a.mutualFriendCount; + return b.createdAt - a.createdAt; + }); + + return reply.code(200).send({ users: discoverUsers, total }); + }); + // GET /api/social/search?q=... - Search for users to add as friends app.get<{ Querystring: { q: string } }>('/api/social/search', { preHandler: authenticate, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 09751ccd..ced7e902 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -16,6 +16,7 @@ export interface User { customStatus: string | null; isAdmin: boolean; isDeleted?: boolean; + discoverable?: boolean; profileUpdatedAt?: number; createdAt: number; homeInstance: string | null; @@ -312,6 +313,8 @@ export type ServerEvent = | { type: 'dm_member_added'; dmChannelId: string; user: User } | { type: 'dm_member_removed'; dmChannelId: string; userId: string } | { type: 'friend_removed'; userId: string } + | { type: 'friend_request_cancelled'; requestId: string; userId: string } + | { type: 'friend_request_declined'; requestId: string; userId: string } | { type: 'channel_created'; channel: Channel; spaceId: string } | { type: 'channel_updated'; channel: Channel; spaceId: string } | { type: 'channel_deleted'; channelId: string; spaceId: string } @@ -399,6 +402,7 @@ export interface UpdateUserRequest { replicatedInstances?: ReplicatedInstance[]; homeUserId?: string; profileUpdatedAt?: number; + discoverable?: boolean; } export interface UpdateMemberRequest { @@ -471,6 +475,25 @@ export interface Friend { homeInstance: string | null; } +export interface DiscoverUser { + id: string; + username: string; + displayName: string | null; + avatar: string | null; + banner: string | null; + avatarColor: AvatarColor | null; + bio: string | null; + status: UserStatus; + customStatus: string | null; + createdAt: number; + homeInstance: string | null; + homeUserId: string | null; + mutualFriendCount: number; + mutualSpaceCount: number; + relationship: 'none' | 'friends' | 'outbound_pending' | 'inbound_pending'; + requestId?: string; +} + export type FriendRequestStatus = 'pending' | 'accepted' | 'declined'; export interface FriendRequest { diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 0a034ec1..bf081961 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -495,6 +495,24 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + case 'friend_request_cancelled': { + const { removeRequestById } = useSocialStore.getState(); + removeRequestById(event.requestId, origin); + import('../stores/discoverStore').then(({ useDiscoverStore }) => { + useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none'); + }); + break; + } + + case 'friend_request_declined': { + const { removeRequestById } = useSocialStore.getState(); + removeRequestById(event.requestId, origin); + import('../stores/discoverStore').then(({ useDiscoverStore }) => { + useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none'); + }); + break; + } + // ─── Channel ack (all origins) ────────────────────────────────────────── case 'channel_ack': { diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 9621d373..0ddbc94a 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -26,7 +26,7 @@ interface SocialState { error: string | null; loadFriends: () => Promise; loadRequests: () => Promise; - sendFriendRequest: (username: string) => Promise; + sendFriendRequest: (username: string) => Promise; updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise; cancelFriendRequest: (id: string) => Promise; removeFriend: (id: string) => Promise; @@ -36,6 +36,7 @@ interface SocialState { updateFriendPresence: (userId: string, status: string) => void; updateFriendProfile: (user: User) => void; removeFriendLocally: (userId: string, origin: string) => void; + removeRequestById: (requestId: string, origin: string) => void; reset: () => void; } @@ -117,10 +118,11 @@ export const useSocialStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const atIndex = username.lastIndexOf('@'); + let res: { success: boolean; requestId?: string }; if (atIndex === -1) { // No @ → local user on home instance - await api.social.sendRequest(username); + res = await api.social.sendRequest(username); } else { const baseName = username.slice(0, atIndex); const domain = username.slice(atIndex + 1); @@ -128,7 +130,7 @@ export const useSocialStore = create((set, get) => ({ // Check if domain matches home instance if (domain === window.location.host) { // Strip domain, send to home API - await api.social.sendRequest(baseName); + res = await api.social.sendRequest(baseName); } else { // Find a connected instance matching this domain const instances = useInstanceStore.getState().instances; @@ -149,11 +151,12 @@ export const useSocialStore = create((set, get) => ({ } // On the remote instance, the user is just "alice", not "alice@orbit" - await match.api.social.sendRequest(baseName); + res = await match.api.social.sendRequest(baseName); } } await get().loadRequests(); + return res.requestId; } catch (err) { set({ error: (err as Error).message, isLoading: false }); throw err; @@ -281,6 +284,13 @@ export const useSocialStore = create((set, get) => ({ })); }, + // Called from WS handler when a friend request is cancelled or declined + removeRequestById: (requestId: string, origin: string) => { + set((state) => ({ + requests: state.requests.filter(r => !(r.id === requestId && r._instanceOrigin === origin)), + })); + }, + // Called from WS handler on presence_update to keep friend status live updateFriendPresence: (userId: string, status: string) => { set((state) => ({