From 77a1a7920cda51db7bb96f696c085757d062b1b0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:20:06 +0100 Subject: [PATCH] feat: add standalone MobileMembersScreen replacing blank member panel Create MobileMembersScreen that renders space members grouped by role without depending on desktop memberListOpen flag or hidden md:block. Add members button to voice full screen header for space channels. --- .../components/layout/MobileMembersScreen.tsx | 178 ++++++++++++++++++ .../web/src/components/layout/MobileShell.tsx | 4 +- .../layout/MobileVoiceFullScreen.tsx | 11 ++ 3 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/components/layout/MobileMembersScreen.tsx diff --git a/packages/web/src/components/layout/MobileMembersScreen.tsx b/packages/web/src/components/layout/MobileMembersScreen.tsx new file mode 100644 index 00000000..7cb0ad70 --- /dev/null +++ b/packages/web/src/components/layout/MobileMembersScreen.tsx @@ -0,0 +1,178 @@ +import React, { useMemo } from 'react'; +import type { MemberWithUser } from '@backspace/shared'; +import { useSpaceStore } from '../../stores/spaceStore'; +import { useActivityStore } from '../../stores/activityStore'; +import { useUIStore } from '../../stores/uiStore'; +import { Avatar } from '../ui/Avatar'; +import { Username } from '../ui/Username'; +import { ActivityCard, hasRichActivity, getActivityAccentClass } from '../ui/ActivityCard'; +import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; +import { parseFederatedUsername } from '../../utils/identity'; +import { MobileScreenHeader } from './MobileScreenHeader'; + +/** + * Derives the display group for a member based on their highest-positioned role + * or owner status. Returns { key, label, color, position }. + */ +function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) { + if (ownerId && member.userId === ownerId) { + const ownerRole = member.roles?.find(r => r.position > 0); + return { + key: '__owner__', + label: 'OWNER', + color: ownerRole?.color ?? 'rgb(var(--accent-rose))', + position: Infinity, + }; + } + if (member.roles && member.roles.length > 0) { + const sorted = [...member.roles].sort((a, b) => b.position - a.position); + const top = sorted[0]!; + return { + key: top.id, + label: top.name.toUpperCase(), + color: top.color, + position: top.position, + }; + } + return { + key: '__online__', + label: 'ONLINE', + color: undefined, + position: -1, + }; +} + +interface MobileMembersScreenProps { + params?: Record; +} + +export function MobileMembersScreen({ params }: MobileMembersScreenProps) { + const members = useSpaceStore((s) => s.members); + const spaces = useSpaceStore((s) => s.spaces); + const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const userActivities = useActivityStore((s) => s.userActivities); + const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); + + const spaceId = params?.spaceId || currentSpaceId; + const space = spaces.find(s => s.id === spaceId); + const ownerId = space?.ownerId; + + const { roleGroups, offlineMembers } = useMemo(() => { + const online = members.filter(m => m.user.status !== 'offline'); + const offline = members.filter(m => m.user.status === 'offline'); + + const groups = new Map(); + for (const m of online) { + const group = getMemberGroup(m, ownerId); + if (!groups.has(group.key)) { + groups.set(group.key, { label: group.label, color: group.color, position: group.position, members: [] }); + } + groups.get(group.key)!.members.push(m); + } + + const sorted = [...groups.entries()].sort( + (a, b) => b[1].position - a[1].position + ); + + return { roleGroups: sorted, offlineMembers: offline }; + }, [members, ownerId]); + + const totalCount = members.length; + + const getMemberColor = (member: MemberWithUser): React.CSSProperties | undefined => { + if (member.roles && member.roles.length > 0) { + const sorted = [...member.roles].sort((a, b) => b.position - a.position); + return { color: sorted[0]!.color }; + } + if (ownerId && member.userId === ownerId) { + return { color: 'rgb(var(--accent-rose))' }; + } + return undefined; + }; + + const handleMemberClick = (userId: string) => { + pushMobileScreen('user-profile', { userId }); + }; + + const renderMember = (member: MemberWithUser, isOffline = false) => { + const { baseName, domain } = parseFederatedUsername(member.user.username); + const displayName = member.user.displayName ?? baseName; + const colorStyle = isOffline ? undefined : getMemberColor(member); + const activities = userActivities.get(member.userId) ?? []; + const isRichActivity = !isOffline && hasRichActivity(activities); + const primary = getPrimaryActivity(activities); + const accentClass = primary ? getActivityAccentClass(primary.type) : ''; + + const rowClass = isRichActivity + ? `flex items-center gap-2.5 px-4 py-2.5 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass} active:bg-interactive-hover` + : 'flex items-center gap-2.5 px-4 py-2.5 rounded-[4px] cursor-pointer transition-colors active:bg-interactive-hover'; + + return ( +
handleMemberClick(member.userId)} + className={rowClass} + > + +
+ + {domain && !isOffline && ( +
@{domain}
+ )} + {!isOffline && ( + + )} +
+
+ ); + }; + + const onlineCount = roleGroups.reduce((sum, [, g]) => sum + g.members.length, 0); + + return ( +
+ 0 ? `Members — ${totalCount}` : 'Members'} /> +
+ {onlineCount === 0 && offlineMembers.length === 0 ? ( +
+ No members found +
+ ) : ( + <> + {roleGroups.map(([key, group]) => ( +
+

+ {group.label} — {group.members.length} +

+ {group.members.map((m) => renderMember(m))} +
+ ))} + + {offlineMembers.length > 0 && ( +
+

+ OFFLINE — {offlineMembers.length} +

+ {offlineMembers.map((m) => renderMember(m, true))} +
+ )} + + )} +
+
+ ); +} diff --git a/packages/web/src/components/layout/MobileShell.tsx b/packages/web/src/components/layout/MobileShell.tsx index e777e454..584d9cdf 100644 --- a/packages/web/src/components/layout/MobileShell.tsx +++ b/packages/web/src/components/layout/MobileShell.tsx @@ -15,7 +15,7 @@ import { MobileInstancePanel } from './MobileInstancePanel'; import { MobileScreenHeader } from './MobileScreenHeader'; import { MobileVoiceMiniBar } from './MobileVoiceMiniBar'; import { MobileVoiceFullScreen } from './MobileVoiceFullScreen'; -import { MemberSidebar } from './MemberSidebar'; +import { MobileMembersScreen } from './MobileMembersScreen'; import { FriendsPage } from '../chat/FriendsPage'; import { ExplorePage } from '../chat/ExplorePage'; import { UserProfileModal } from '../modals/UserProfileModal'; @@ -57,7 +57,7 @@ const screenMap: Record) => React.React
), - 'members': () => , + 'members': (params) => , 'voice-full': () => , 'explore': () => , 'user-profile': (params) => { diff --git a/packages/web/src/components/layout/MobileVoiceFullScreen.tsx b/packages/web/src/components/layout/MobileVoiceFullScreen.tsx index 04c01183..e2164f02 100644 --- a/packages/web/src/components/layout/MobileVoiceFullScreen.tsx +++ b/packages/web/src/components/layout/MobileVoiceFullScreen.tsx @@ -7,6 +7,7 @@ import { Avatar } from '../ui/Avatar'; export function MobileVoiceFullScreen() { const popMobileScreen = useUIStore((s) => s.popMobileScreen); + const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const isMuted = useVoiceStore((s) => s.isMuted); @@ -100,6 +101,16 @@ export function MobileVoiceFullScreen() { {spaceName &&

{spaceName}

} {participantIds.length} connected + {!isDmCall && ( + + )} {/* Participant grid */}