fix: DM avatar color and reactions in federation + explore/server discovery

- Fix DM welcome header avatar using home identity for correct gradient color
- Register DM channel IDs in channelOriginMap so federated DM operations
  (reactions, messages, typing) route to the correct instance
- Pass origin when creating DM channels from friends list and WS events
- Add server discovery/explore page with public server listings
- Add server visibility and description fields
This commit is contained in:
Jannis Braun
2026-03-04 18:02:43 +01:00
parent 72d1fab930
commit 6e44a4ef2f
29 changed files with 1614 additions and 108 deletions
@@ -2,7 +2,9 @@ import React, { useEffect, useMemo } from 'react';
import { useSocialStore } from '../../stores/socialStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import type { Friend } from '@backspace/shared';
import { parseFederatedUsername } from '../../utils/identity';
export function ActivityPanel() {
const friends = useSocialStore((s) => s.friends);
@@ -34,7 +36,11 @@ export function ActivityPanel() {
status: friend.status,
customStatus: friend.customStatus,
createdAt: friend.createdAt,
} as any,
homeUserId: friend.homeUserId,
homeInstance: friend.homeInstance,
isAdmin: false,
replicatedInstances: [],
},
{
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.left - 316,
@@ -42,30 +48,38 @@ export function ActivityPanel() {
);
};
const renderFriend = (friend: Friend, isOffline = false) => (
<div
key={friend.id}
onClick={(e) => handleFriendClick(e, friend)}
className="flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer group transition-colors"
>
<Avatar
src={friend.avatar}
name={friend.displayName ?? friend.username}
size={32}
status={isOffline ? 'offline' : friend.status}
className={isOffline ? 'opacity-60' : undefined}
userId={friend.id}
/>
<div className="flex-1 min-w-0">
<div className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : 'text-txt-primary'}`}>
{friend.displayName ?? friend.username}
const renderFriend = (friend: Friend, isOffline = false) => {
const { baseName, domain } = parseFederatedUsername(friend.username);
const friendDisplayName = friend.displayName ?? baseName;
return (
<div
key={friend.id}
onClick={(e) => handleFriendClick(e, friend)}
className="flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer group transition-colors"
>
<Avatar
src={friend.avatar}
name={friendDisplayName}
size={32}
status={isOffline ? 'offline' : friend.status}
className={isOffline ? 'opacity-60' : undefined}
userId={friend.homeUserId ?? friend.id}
/>
<div className="flex-1 min-w-0">
<Username
username={friendDisplayName}
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : 'text-txt-primary'}`}
/>
{domain && !isOffline && (
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{domain}</div>
)}
{!isOffline && friend.customStatus && (
<div className="text-[11px] leading-[1.3] text-txt-tertiary truncate">{friend.customStatus}</div>
)}
</div>
{!isOffline && friend.customStatus && (
<div className="text-[11px] leading-[1.3] text-txt-tertiary truncate">{friend.customStatus}</div>
)}
</div>
</div>
);
);
};
return (
<div className="w-60 bg-surface-channel flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard">
@@ -9,10 +9,12 @@ import { VoiceChannel } from '../voice/VoiceChannel';
import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import { wsSend } from '../../hooks/useWebSocket';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { AudioManager } from '../../audio/AudioManager';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { parseFederatedUsername } from '../../utils/identity';
export function ChannelSidebar() {
const servers = useServerStore((s) => s.servers);
@@ -193,7 +195,7 @@ export function ChannelSidebar() {
const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id;
const dmDisplayName = isGroup
? otherMembers.map(m => m.displayName ?? m.username).join(', ')
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
: otherMembers[0]?.displayName ?? otherMembers[0]?.username;
return (
@@ -224,21 +226,22 @@ export function ChannelSidebar() {
zIndex: 2 - i,
}}
>
<Avatar src={m.avatar} name={m.displayName ?? m.username} size={22} userId={m.id} />
<Avatar src={m.avatar} name={m.displayName ?? parseFederatedUsername(m.username).baseName} size={22} userId={m.homeUserId ?? m.id} />
</div>
))}
</div>
) : (
<Avatar src={otherMembers[0]?.avatar} name={otherMembers[0]?.displayName ?? otherMembers[0]?.username ?? ''} size={32} status={otherMembers[0]?.status as any} userId={otherMembers[0]?.id} />
<Avatar src={otherMembers[0]?.avatar} name={otherMembers[0]?.displayName ?? parseFederatedUsername(otherMembers[0]?.username ?? '').baseName} size={32} status={otherMembers[0]?.status as any} userId={otherMembers[0]?.homeUserId ?? otherMembers[0]?.id} />
)}
<div className="flex-1 min-w-0">
<div className={`text-[15px] truncate leading-tight ${
currentChannelId === dm.id ? 'text-white font-medium'
: isDmUnread ? 'text-white font-bold'
: 'text-txt-tertiary group-hover:text-txt-secondary font-medium'
}`}>
{dmDisplayName}
</div>
<Username
username={dmDisplayName ?? ''}
className={`text-[15px] truncate leading-tight block ${
currentChannelId === dm.id ? 'text-white font-medium'
: isDmUnread ? 'text-white font-bold'
: 'text-txt-tertiary group-hover:text-txt-secondary font-medium'
}`}
/>
{isGroup ? (
<div className="text-[12px] text-txt-tertiary truncate leading-tight mt-0.5">
{dm.members.length} Members
@@ -9,6 +9,7 @@ import { VoiceGrid } from '../voice/VoiceGrid';
import { VoiceControlBar } from '../voice/VoiceControlBar';
import { VoiceChatPanel } from '../voice/VoiceChatPanel';
import { FriendsPage } from '../chat/FriendsPage';
import { ExplorePage } from '../chat/ExplorePage';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { wsSend } from '../../hooks/useWebSocket';
@@ -27,6 +28,7 @@ export function MainContent() {
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const connectionError = useVoiceStore((s) => s.connectionError);
const showDms = useUIStore((s) => s.showDms);
const showExplore = useUIStore((s) => s.showExplore);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const outgoingCall = useVoiceStore((s) => s.outgoingCall);
const dmChannels = useServerStore((s) => s.dmChannels);
@@ -58,8 +60,9 @@ export function MainContent() {
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
if (showDms || !currentServerId) {
if (showDms || showExplore || !currentServerId) {
if (!currentChannelId) {
if (showExplore) return <ExplorePage />;
return <FriendsPage />;
}
@@ -4,6 +4,7 @@ import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import { parseFederatedUsername } from '../../utils/identity';
/**
* Derives the display group for a member based on their highest-positioned role
@@ -95,7 +96,8 @@ export function MemberSidebar() {
};
const renderMember = (member: MemberWithUser, isOffline = false) => {
const displayName = member.user.displayName ?? member.user.username;
const { baseName, domain } = parseFederatedUsername(member.user.username);
const displayName = member.user.displayName ?? baseName;
const colorStyle = isOffline ? undefined : getMemberColor(member);
return (
<div
@@ -117,6 +119,9 @@ export function MemberSidebar() {
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
style={colorStyle}
/>
{domain && !isOffline && (
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{domain}</div>
)}
{!isOffline && member.user.customStatus && (
<div className="text-[11px] leading-[1.3] text-txt-tertiary truncate">{member.user.customStatus}</div>
)}
@@ -14,7 +14,7 @@ interface SidebarItemProps {
active: boolean;
onClick: () => void;
type?: 'server' | 'dm' | 'action';
actionType?: 'add' | 'join';
actionType?: 'add' | 'join' | 'explore';
hasUnread?: boolean;
dimmed?: boolean;
}
@@ -89,6 +89,10 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
) : actionType === 'explore' ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
@@ -116,6 +120,8 @@ export function ServerSidebar() {
const dmChannels = useServerStore((s) => s.dmChannels);
const showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const showExplore = useUIStore((s) => s.showExplore);
const setShowExplore = useUIStore((s) => s.setShowExplore);
const openModal = useUIStore((s) => s.openModal);
const addToast = useUIStore((s) => s.addToast);
const unreadChannels = useChatStore((s) => s.unreadChannels);
@@ -175,6 +181,7 @@ export function ServerSidebar() {
}
setCurrentServer(serverId);
setShowDms(false);
setShowExplore(false);
navigate(`/channels/${serverId}`);
};
@@ -184,6 +191,12 @@ export function ServerSidebar() {
navigate('/channels/@me');
};
const handleExploreClick = () => {
setShowExplore(true);
setCurrentServer(null);
navigate('/channels/@me');
};
return (
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip">
<SidebarItem
@@ -257,6 +270,15 @@ export function ServerSidebar() {
actionType="join"
/>
<SidebarItem
id="explore"
name="Explore Servers"
active={showExplore}
onClick={handleExploreClick}
type="action"
actionType="explore"
/>
</nav>
);
}