diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index a7429694..46efe844 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -180,6 +180,50 @@ export async function userRoutes(app: FastifyInstance): Promise { }); } + // Broadcast user_updated for profile field changes + const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus']; + const hasProfileChange = profileFields.some(f => f in updateData); + + if (hasProfileChange) { + const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized }; + const targetUserIds = new Set(); + + // 1. Collect online users who share a space + const userSpaces = connectionManager.getUserSpaces(sanitized.id); + for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) { + for (const spaceId of userSpaces) { + if (spaceIds.has(spaceId)) { targetUserIds.add(uid); break; } + } + } + + // 2. Collect DM channel co-members + const dmMemberships = db.select().from(schema.dmMembers) + .where(eq(schema.dmMembers.userId, sanitized.id)).all(); + for (const dm of dmMemberships) { + const coMembers = db.select().from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dm.dmChannelId)).all(); + for (const m of coMembers) targetUserIds.add(m.userId); + } + + // 3. Collect friends + const friendRows = db.select().from(schema.friends) + .where(or( + eq(schema.friends.userId, sanitized.id), + eq(schema.friends.friendId, sanitized.id), + )).all(); + for (const fr of friendRows) { + targetUserIds.add(fr.userId === sanitized.id ? fr.friendId : fr.userId); + } + + // 4. Include self (for other tabs/connections) + targetUserIds.add(sanitized.id); + + // Send deduplicated — each user gets the event exactly once + for (const uid of targetUserIds) { + connectionManager.sendToUser(uid, userUpdatedEvent); + } + } + return reply.code(200).send(sanitized); }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 34b7ca8b..a9b99e71 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -292,6 +292,7 @@ export type ServerEvent = | { type: 'voice_permission_muted'; userId: string; spaceId: string; muted: boolean } | { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string } | { type: 'voice_disconnected'; userId: string; channelId: string } + | { type: 'user_updated'; user: User } | { type: 'member_banned'; spaceId: string; reason: string | null } | { type: 'pong' } | { type: 'error'; message: string }; diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 4d86b402..fa3932ae 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -245,7 +245,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove return (
- +
{friend.displayName ?? friend.username} @@ -297,7 +297,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: { return (
- +
{user.displayName ?? user.username} diff --git a/packages/web/src/components/chat/MentionPopover.tsx b/packages/web/src/components/chat/MentionPopover.tsx index 806c4e74..eae27b68 100644 --- a/packages/web/src/components/chat/MentionPopover.tsx +++ b/packages/web/src/components/chat/MentionPopover.tsx @@ -82,6 +82,7 @@ export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: Me size={24} status={member.user.status} userId={member.user.homeUserId ?? member.user.id} + user={member.user} />
- +
))}
) : ( - + )}
= { @@ -54,12 +55,12 @@ function getDotMetrics(avatarSize: number, ringWidth: number = 0) { return { dot, gap, inset: avatarInset + ringWidth }; } -export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring }: AvatarProps) { +export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring, avatarColor }: AvatarProps) { const openUserProfile = useUIStore((s) => s.openUserProfile); const initials = name.charAt(0).toUpperCase(); // Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18; - const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, user?.avatarColor); + const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, avatarColor ?? user?.avatarColor); const ringWidth = ring?.width ?? 0; const outerSize = size + ringWidth * 2; diff --git a/packages/web/src/components/voice/PictureInPicture.tsx b/packages/web/src/components/voice/PictureInPicture.tsx index 316a7375..9e5d4c54 100644 --- a/packages/web/src/components/voice/PictureInPicture.tsx +++ b/packages/web/src/components/voice/PictureInPicture.tsx @@ -322,7 +322,7 @@ export function PictureInPicture() { [selectedStream, fallbackParticipant], ); - const { displayName: resolvedName, avatar: resolvedAvatar } = + const { displayName: resolvedName, avatar: resolvedAvatar, user: resolvedUser } = useVoiceParticipantMeta(displayParticipant ?? EMPTY_PARTICIPANT); if (!shouldShow) return null; @@ -370,6 +370,7 @@ export function PictureInPicture() { name={resolvedName} size={64} userId={displayParticipant.homeUserId ?? displayParticipant.userId} + user={resolvedUser ?? undefined} /> {speakingParticipantIds.has(displayParticipant.identity) && (
diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index 4738daeb..e34fcc56 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -20,7 +20,7 @@ export function StreamTile({ tile, large }: StreamTileProps) { const isLocal = participant.isLocal; const userId = participant.userId; const avatarUserId = participant.homeUserId ?? userId; - const { displayName, avatar } = useVoiceParticipantMeta(participant); + const { displayName, avatar, user } = useVoiceParticipantMeta(participant); const isWatching = watchingStreams.has(userId); @@ -110,7 +110,7 @@ export function StreamTile({ tile, large }: StreamTileProps) { ) : (
- +

diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx index 74a0363e..fe5a9af3 100644 --- a/packages/web/src/components/voice/VoiceChannel.tsx +++ b/packages/web/src/components/voice/VoiceChannel.tsx @@ -113,6 +113,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC size={24} status={status} userId={member?.user.homeUserId ?? userId} + user={member?.user} /> {displayName} {/* Status badges */} diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index d8400ca4..618546bc 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -29,7 +29,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { const isLocal = participant.isLocal; const avatarUserId = participant.homeUserId ?? participant.userId; - const { displayName, avatar } = useVoiceParticipantMeta(participant); + const { displayName, avatar, user } = useVoiceParticipantMeta(participant); // --- VIDEO & UI --- @@ -98,6 +98,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { name={displayName} size={large ? 100 : 64} userId={avatarUserId} + user={user ?? undefined} /> {isSpeaking && (

diff --git a/packages/web/src/hooks/useVoiceParticipantMeta.ts b/packages/web/src/hooks/useVoiceParticipantMeta.ts index 7133a468..8ce5d699 100644 --- a/packages/web/src/hooks/useVoiceParticipantMeta.ts +++ b/packages/web/src/hooks/useVoiceParticipantMeta.ts @@ -2,9 +2,10 @@ import { useMemo } from 'react'; import { useSpaceStore } from '../stores/spaceStore'; import { parseFederatedUsername } from '../utils/identity'; import type { ParticipantInfo } from './useLiveKit'; +import type { User } from '@backspace/shared'; /** - * Resolves display metadata (displayName, avatar) for a voice participant + * Resolves display metadata (displayName, avatar, user) for a voice participant * by looking up member data from the space/DM stores. * * Reactive — re-renders when member data changes (e.g. user updates avatar mid-call). @@ -21,6 +22,7 @@ export function useVoiceParticipantMeta(participant: ParticipantInfo) { return { displayName: member.user.displayName ?? baseName, avatar: member.user.avatar ?? null, + user: member.user as User, }; } @@ -32,12 +34,13 @@ export function useVoiceParticipantMeta(participant: ParticipantInfo) { return { displayName: dmMember.displayName ?? baseName, avatar: dmMember.avatar ?? null, + user: dmMember as User, }; } } // 3. Final fallback — parse username from LiveKit identity const { baseName } = parseFederatedUsername(participant.username); - return { displayName: baseName, avatar: null }; + return { displayName: baseName, avatar: null, user: null as User | null }; }, [members, dmChannels, participant.userId, participant.username]); } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index fad68818..71d319d3 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -310,6 +310,21 @@ function handleEvent(origin: string, event: ServerEvent): void { } break; + case 'user_updated': { + if (!isHome) normalizeUserAssets(event.user, origin); + useSpaceStore.getState().updateUserEverywhere(event.user); + useSocialStore.getState().updateFriendProfile(event.user); + useChatStore.getState().updateUserInMessages(event.user); + // If this is the current user (other tab changed profile), update authStore + const myId = isHome + ? useAuthStore.getState().user?.id + : getMyUserIdForOrigin(origin); + if (event.user.id === myId && isHome) { + setUser(event.user); + } + break; + } + case 'voice_state_update': if (event.action === 'join') { addVoiceUser(event.channelId, event.userId); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 9ce462b2..c68d9d56 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -57,6 +57,7 @@ interface ChatState { markChannelUnread: (channelId: string) => void; ackChannel: (channelId: string) => void; onChannelAck: (channelId: string, messageId: string) => void; + updateUserInMessages: (user: { id: string; [key: string]: any }) => void; } /** Find which channel a message belongs to by scanning the message cache. */ @@ -593,4 +594,23 @@ export const useChatStore = create((set, get) => ({ return { readStates: newReadStates, unreadChannels: newUnread }; }); }, + + updateUserInMessages: (user: { id: string; [key: string]: any }) => { + set((state) => { + const newMessages = new Map(state.messages); + let changed = false; + for (const [channelId, msgs] of newMessages) { + let channelChanged = false; + const updated = msgs.map(m => { + if (m.userId === user.id) { + channelChanged = true; + return { ...m, user: { ...m.user, ...user } }; + } + return m; + }); + if (channelChanged) { newMessages.set(channelId, updated); changed = true; } + } + return changed ? { messages: newMessages } : {}; + }); + }, })); diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index a523cc42..9621d373 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -34,6 +34,7 @@ interface SocialState { addIncomingRequest: (request: FriendRequest, origin: string) => void; addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void; updateFriendPresence: (userId: string, status: string) => void; + updateFriendProfile: (user: User) => void; removeFriendLocally: (userId: string, origin: string) => void; reset: () => void; } @@ -289,5 +290,19 @@ export const useSocialStore = create((set, get) => ({ })); }, + // Called from WS handler on user_updated to keep friend profile data live + updateFriendProfile: (user: User) => { + set((state) => ({ + friends: state.friends.map(f => + f.id === user.id + ? { ...f, displayName: user.displayName, avatar: user.avatar, + banner: user.banner, accentColor: user.accentColor, + avatarColor: user.avatarColor, bio: user.bio, + customStatus: user.customStatus, status: user.status } + : f + ), + })); + }, + reset: () => set({ friends: [], requests: [], isLoading: false, error: null }), })); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 86fb657d..06ada068 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -60,6 +60,7 @@ interface SpaceState { addSpace: (space: Space) => void; removeSpace: (spaceId: string) => void; updateMemberPresence: (userId: string, status: string) => void; + updateUserEverywhere: (user: User) => void; addMember: (member: MemberWithUser) => void; removeMember: (userId: string) => void; populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => void; @@ -304,6 +305,20 @@ export const useSpaceStore = create((set, get) => ({ })); }, + updateUserEverywhere: (user: User) => { + set((state) => ({ + members: state.members.map(m => + m.userId === user.id ? { ...m, user: { ...m.user, ...user } } : m + ), + dmChannels: state.dmChannels.map(dm => ({ + ...dm, + members: dm.members.map(m => + m.id === user.id ? { ...m, ...user } : m + ), + })), + })); + }, + addMember: (member: MemberWithUser) => { set((state) => ({ members: [...state.members.filter(m => m.userId !== member.userId), member],