From eac4e5f5ebb0bcc5d5f97aac90cd97c9d6ee089c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:35:53 +0100 Subject: [PATCH] feat(mobile): add MobileSpacesScreen with space strip and channel list --- .../web/src/components/layout/MobileShell.tsx | 2 +- .../components/layout/MobileSpacesScreen.tsx | 360 ++++++++++++++++++ 2 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/components/layout/MobileSpacesScreen.tsx diff --git a/packages/web/src/components/layout/MobileShell.tsx b/packages/web/src/components/layout/MobileShell.tsx index 94f380db..010d5627 100644 --- a/packages/web/src/components/layout/MobileShell.tsx +++ b/packages/web/src/components/layout/MobileShell.tsx @@ -13,8 +13,8 @@ const PlaceholderScreen = ({ label }: { label: string }) => ( ); +import { MobileSpacesScreen } from './MobileSpacesScreen'; // These will be replaced with real imports as each task is completed: -const MobileSpacesScreen = () => ; const MobileDmsScreen = () => ; const MobileYouScreen = () => ; const MobileChatScreen = ({ params: _params }: { params?: Record }) => ; diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx new file mode 100644 index 00000000..c5406d8b --- /dev/null +++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx @@ -0,0 +1,360 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { useUIStore } from '../../stores/uiStore'; +import { useSpaceStore } from '../../stores/spaceStore'; +import { useChatStore } from '../../stores/chatStore'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { useAuthStore } from '../../stores/authStore'; +import { useNavigate } from 'react-router-dom'; +import { getSpaceGradient } from '../../utils/gradients'; +import { resolveAssetUrl } from '../../utils/assetUrls'; +import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; +import type { Channel } from '@backspace/shared'; + +export function MobileSpacesScreen() { + const spaces = useSpaceStore((s) => s.spaces); + const channels = useSpaceStore((s) => s.channels); + const categories = useSpaceStore((s) => s.categories); + const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); + const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); + const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap); + const voiceChannelIds = useSpaceStore((s) => s.voiceChannelIds); + const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const channelPermissions = useSpaceStore((s) => s.channelPermissions); + + const unreadChannels = useChatStore((s) => s.unreadChannels); + const currentChannelId = useChatStore((s) => s.currentChannelId); + + const voiceUsers = useVoiceStore((s) => s.voiceUsers); + const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const joinChannel = useVoiceStore((s) => s.joinChannel); + + const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); + const setMobileTab = useUIStore((s) => s.setMobileTab); + const openModal = useUIStore((s) => s.openModal); + const setLastChannel = useUIStore((s) => s.setLastChannel); + + const navigate = useNavigate(); + const user = useAuthStore((s) => s.user); + + // Local state + const [selectedSpaceId, setSelectedSpaceId] = useState(currentSpaceId); + const [collapsedCategories, setCollapsedCategories] = useState>(new Set()); + const [showAddSheet, setShowAddSheet] = useState(false); + + // Sync selected space with store's current space + useEffect(() => { + if (selectedSpaceId) { + setCurrentSpace(selectedSpaceId); + loadSpaceDetail(selectedSpaceId); + } + }, [selectedSpaceId, setCurrentSpace, loadSpaceDetail]); + + // Auto-select first space if none selected + useEffect(() => { + if (!selectedSpaceId && spaces.length > 0) { + setSelectedSpaceId(spaces[0].id); + } + }, [selectedSpaceId, spaces]); + + // Check if loaded channels belong to selected space + const spaceChannels = useMemo(() => { + if (!selectedSpaceId) return []; + return channels.filter(c => channelToSpaceMap.get(c.id) === selectedSpaceId); + }, [channels, channelToSpaceMap, selectedSpaceId]); + + // Group channels by category + const channelsByCategory = useMemo(() => { + const map = new Map(); + for (const ch of spaceChannels) { + if (!ch.categoryId) continue; + let arr = map.get(ch.categoryId); + if (!arr) { arr = []; map.set(ch.categoryId, arr); } + arr.push(ch); + } + for (const [key, arr] of map) { + map.set(key, arr.sort((a, b) => a.position - b.position)); + } + return map; + }, [spaceChannels]); + + const uncategorizedChannels = useMemo(() => + spaceChannels + .filter(c => !c.categoryId) + .sort((a, b) => a.position - b.position), + [spaceChannels] + ); + + const sortedCategories = useMemo(() => + [...categories].sort((a, b) => a.position - b.position), + [categories] + ); + + // Check if space has unread text channels + const spaceHasUnread = (spaceId: string): boolean => { + for (const chId of unreadChannels) { + if (channelToSpaceMap.get(chId) === spaceId && !voiceChannelIds.has(chId)) { + return true; + } + } + return false; + }; + + const handleSpaceSelect = (spaceId: string) => { + setSelectedSpaceId(spaceId); + setCollapsedCategories(new Set()); + }; + + const handleChannelTap = (channel: Channel) => { + if (channel.type === 'voice') { + joinChannel(channel.id); + return; + } + if (selectedSpaceId) { + setLastChannel(selectedSpaceId, channel.id); + navigate(`/channels/${selectedSpaceId}/${channel.id}`); + } + pushMobileScreen('channel-chat', { + channelId: channel.id, + spaceId: selectedSpaceId || '', + }); + }; + + const toggleCategory = (categoryId: string) => { + setCollapsedCategories(prev => { + const next = new Set(prev); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + return next; + }); + }; + + const selectedSpace = spaces.find(s => s.id === selectedSpaceId); + const myPerms = selectedSpaceId ? spacePermissions.get(selectedSpaceId) : undefined; + const canInvite = hasPermissionBit(myPerms, PermissionBits.CREATE_INVITE); + const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE); + + const renderChannelItem = (channel: Channel) => { + const isVoice = channel.type === 'voice'; + const isActive = !isVoice && currentChannelId === channel.id; + const isUnread = unreadChannels.has(channel.id) && !isActive; + const isInVoice = currentVoiceChannelId === channel.id; + const vUsers = voiceUsers.get(channel.id) || []; + const canView = hasPermissionBit(channelPermissions.get(channel.id), PermissionBits.VIEW_CHANNEL); + if (canView === false) return null; + + return ( + + ); + }; + + return ( +
+ {/* Space strip */} +
+ {/* Home / DMs button */} + + +
+ + {/* Space icons */} + {spaces.map(space => { + const isSelected = space.id === selectedSpaceId; + const hasUnread = spaceHasUnread(space.id); + const iconUrl = space.icon + ? resolveAssetUrl(space.icon, space._instanceOrigin) ?? `/api/uploads/${space.icon}` + : null; + const grad = getSpaceGradient(space.id, space.name, space.avatarColor); + + return ( +
+ {/* Unread / selected pill */} +
+
+
+ +
+ ); + })} + +
+ + {/* Add space button */} + +
+ + {/* Channel list */} +
+ {/* Space header */} + {selectedSpace && ( +
+

+ {selectedSpace.name} +

+ {canInvite && ( + + )} + {canManageSpace && ( + + )} +
+ )} + + {/* Channel list */} +
+ {/* Uncategorized channels */} + {uncategorizedChannels.map(renderChannelItem)} + + {/* Categorized channels */} + {sortedCategories.map(category => { + const catChannels = channelsByCategory.get(category.id) || []; + if (catChannels.length === 0) return null; + const isCollapsed = collapsedCategories.has(category.id); + + return ( +
+ + {!isCollapsed && catChannels.map(renderChannelItem)} +
+ ); + })} + + {/* Empty state */} + {spaceChannels.length === 0 && selectedSpaceId && ( +
+ No channels yet +
+ )} +
+
+ + {/* Add Space bottom sheet */} + {showAddSheet && ( + <> +
setShowAddSheet(false)} /> +
+
+
+ + + +
+
+ + )} +
+ ); +}