fix: real-time friend request cancel/decline via WebSocket
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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
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 { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
FriendRequest,
|
FriendRequest,
|
||||||
SendFriendRequest,
|
SendFriendRequest,
|
||||||
UpdateFriendRequest,
|
UpdateFriendRequest,
|
||||||
|
DiscoverUser,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
request: friendRequestPayload,
|
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
|
// PATCH /api/social/requests/:id - Accept/Decline a friend request
|
||||||
@@ -229,6 +230,13 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.set({ status })
|
.set({ status })
|
||||||
.where(eq(schema.friendRequests.id, id))
|
.where(eq(schema.friendRequests.id, id))
|
||||||
.run();
|
.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 });
|
return reply.code(200).send({ success: true });
|
||||||
@@ -259,6 +267,13 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.where(eq(schema.friendRequests.id, id))
|
.where(eq(schema.friendRequests.id, id))
|
||||||
.run();
|
.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 });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,6 +298,137 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send({ success: true });
|
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<number>`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
|
// GET /api/social/search?q=... - Search for users to add as friends
|
||||||
app.get<{ Querystring: { q: string } }>('/api/social/search', {
|
app.get<{ Querystring: { q: string } }>('/api/social/search', {
|
||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface User {
|
|||||||
customStatus: string | null;
|
customStatus: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
|
discoverable?: boolean;
|
||||||
profileUpdatedAt?: number;
|
profileUpdatedAt?: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
homeInstance: string | null;
|
homeInstance: string | null;
|
||||||
@@ -312,6 +313,8 @@ export type ServerEvent =
|
|||||||
| { type: 'dm_member_added'; dmChannelId: string; user: User }
|
| { type: 'dm_member_added'; dmChannelId: string; user: User }
|
||||||
| { type: 'dm_member_removed'; dmChannelId: string; userId: string }
|
| { type: 'dm_member_removed'; dmChannelId: string; userId: string }
|
||||||
| { type: 'friend_removed'; 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_created'; channel: Channel; spaceId: string }
|
||||||
| { type: 'channel_updated'; channel: Channel; spaceId: string }
|
| { type: 'channel_updated'; channel: Channel; spaceId: string }
|
||||||
| { type: 'channel_deleted'; channelId: string; spaceId: string }
|
| { type: 'channel_deleted'; channelId: string; spaceId: string }
|
||||||
@@ -399,6 +402,7 @@ export interface UpdateUserRequest {
|
|||||||
replicatedInstances?: ReplicatedInstance[];
|
replicatedInstances?: ReplicatedInstance[];
|
||||||
homeUserId?: string;
|
homeUserId?: string;
|
||||||
profileUpdatedAt?: number;
|
profileUpdatedAt?: number;
|
||||||
|
discoverable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemberRequest {
|
export interface UpdateMemberRequest {
|
||||||
@@ -471,6 +475,25 @@ export interface Friend {
|
|||||||
homeInstance: string | null;
|
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 type FriendRequestStatus = 'pending' | 'accepted' | 'declined';
|
||||||
|
|
||||||
export interface FriendRequest {
|
export interface FriendRequest {
|
||||||
|
|||||||
@@ -495,6 +495,24 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
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) ──────────────────────────────────────────
|
// ─── Channel ack (all origins) ──────────────────────────────────────────
|
||||||
|
|
||||||
case 'channel_ack': {
|
case 'channel_ack': {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface SocialState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
loadFriends: () => Promise<void>;
|
loadFriends: () => Promise<void>;
|
||||||
loadRequests: () => Promise<void>;
|
loadRequests: () => Promise<void>;
|
||||||
sendFriendRequest: (username: string) => Promise<void>;
|
sendFriendRequest: (username: string) => Promise<string | undefined>;
|
||||||
updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise<void>;
|
updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise<void>;
|
||||||
cancelFriendRequest: (id: string) => Promise<void>;
|
cancelFriendRequest: (id: string) => Promise<void>;
|
||||||
removeFriend: (id: string) => Promise<void>;
|
removeFriend: (id: string) => Promise<void>;
|
||||||
@@ -36,6 +36,7 @@ interface SocialState {
|
|||||||
updateFriendPresence: (userId: string, status: string) => void;
|
updateFriendPresence: (userId: string, status: string) => void;
|
||||||
updateFriendProfile: (user: User) => void;
|
updateFriendProfile: (user: User) => void;
|
||||||
removeFriendLocally: (userId: string, origin: string) => void;
|
removeFriendLocally: (userId: string, origin: string) => void;
|
||||||
|
removeRequestById: (requestId: string, origin: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +118,11 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const atIndex = username.lastIndexOf('@');
|
const atIndex = username.lastIndexOf('@');
|
||||||
|
let res: { success: boolean; requestId?: string };
|
||||||
|
|
||||||
if (atIndex === -1) {
|
if (atIndex === -1) {
|
||||||
// No @ → local user on home instance
|
// No @ → local user on home instance
|
||||||
await api.social.sendRequest(username);
|
res = await api.social.sendRequest(username);
|
||||||
} else {
|
} else {
|
||||||
const baseName = username.slice(0, atIndex);
|
const baseName = username.slice(0, atIndex);
|
||||||
const domain = username.slice(atIndex + 1);
|
const domain = username.slice(atIndex + 1);
|
||||||
@@ -128,7 +130,7 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
// Check if domain matches home instance
|
// Check if domain matches home instance
|
||||||
if (domain === window.location.host) {
|
if (domain === window.location.host) {
|
||||||
// Strip domain, send to home API
|
// Strip domain, send to home API
|
||||||
await api.social.sendRequest(baseName);
|
res = await api.social.sendRequest(baseName);
|
||||||
} else {
|
} else {
|
||||||
// Find a connected instance matching this domain
|
// Find a connected instance matching this domain
|
||||||
const instances = useInstanceStore.getState().instances;
|
const instances = useInstanceStore.getState().instances;
|
||||||
@@ -149,11 +151,12 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On the remote instance, the user is just "alice", not "alice@orbit"
|
// 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();
|
await get().loadRequests();
|
||||||
|
return res.requestId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: (err as Error).message, isLoading: false });
|
set({ error: (err as Error).message, isLoading: false });
|
||||||
throw err;
|
throw err;
|
||||||
@@ -281,6 +284,13 @@ export const useSocialStore = create<SocialState>((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
|
// Called from WS handler on presence_update to keep friend status live
|
||||||
updateFriendPresence: (userId: string, status: string) => {
|
updateFriendPresence: (userId: string, status: string) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user