mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Implement Matrix Spaces navigation and room creation
- Update MatrixService to support Matrix Spaces with isSpace, roomType, and isPublic properties - Add SpaceChild interface for representing space hierarchy - Implement space navigation with drill-down into sub-spaces and back navigation - Add contextual room/space creation (create spaces at top level, rooms within spaces) - Update ChannelsView to show only Matrix Spaces instead of all non-DM rooms - Add breadcrumb navigation and space content display - Include empty tiles for creating new rooms within spaces
This commit is contained in:
@@ -12,13 +12,16 @@ import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Edit2,
|
||||
Star
|
||||
Star,
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Home
|
||||
} from 'lucide-react';
|
||||
import { useMatrix } from '../../contexts/MatrixContext';
|
||||
import MatrixAuth from '../peers/MatrixAuth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTabContext } from '../../contexts/TabContext';
|
||||
import { matrixService } from '../../services/MatrixService';
|
||||
import { matrixService, SpaceChild } from '../../services/MatrixService';
|
||||
|
||||
interface Channel {
|
||||
roomId: string;
|
||||
@@ -155,7 +158,99 @@ const ChannelCard: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyChannelTile: React.FC<{ onCreateChannel: () => void }> = ({ onCreateChannel }) => {
|
||||
const SpaceChildCard: React.FC<{
|
||||
child: SpaceChild;
|
||||
onChildClick: (child: SpaceChild) => void;
|
||||
}> = ({ child, onChildClick }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
onClick={() => onChildClick(child)}
|
||||
className="
|
||||
relative cursor-pointer group
|
||||
bg-background-default
|
||||
transition-colors duration-200
|
||||
hover:bg-background-medium
|
||||
aspect-square
|
||||
flex flex-col
|
||||
rounded-2xl
|
||||
overflow-hidden
|
||||
"
|
||||
>
|
||||
{/* Cover Photo Section - Top half */}
|
||||
{child.avatarUrl ? (
|
||||
<div className="relative w-full h-[60%] overflow-hidden">
|
||||
<img
|
||||
src={child.avatarUrl}
|
||||
alt={child.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/20" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full h-[60%] bg-gradient-to-br from-background-medium via-background-muted to-background-default overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(var(--color-background-accent-rgb),0.3),transparent_70%)]" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{child.isSpace ? (
|
||||
<Hash className="w-12 h-12 text-text-muted/20" />
|
||||
) : (
|
||||
<Hash className="w-12 h-12 text-text-muted/20" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type indicator - top right */}
|
||||
<div className="absolute top-4 right-4 flex items-center gap-1 z-10">
|
||||
<div className={`p-1.5 rounded-full backdrop-blur-sm ${
|
||||
child.isPublic
|
||||
? 'bg-green-500/90 text-white'
|
||||
: 'bg-orange-500/90 text-white'
|
||||
}`}>
|
||||
{child.isSpace ? (
|
||||
<Hash className="w-3 h-3" />
|
||||
) : child.isPublic ? (
|
||||
<Globe className="w-3 h-3" />
|
||||
) : (
|
||||
<Lock className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section - Bottom half */}
|
||||
<div className="flex-1 px-6 pt-6 pb-6 flex flex-col justify-end">
|
||||
<h3 className="text-lg font-light text-text-default truncate mb-1">
|
||||
{child.name || (child.isSpace ? 'Unnamed Space' : 'Unnamed Room')}
|
||||
</h3>
|
||||
{child.topic && (
|
||||
<p className="text-xs text-text-muted truncate mb-2">
|
||||
{child.topic}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<Users className="w-3 h-3" />
|
||||
<span>{child.memberCount || 0} members</span>
|
||||
{child.suggested && (
|
||||
<span className="ml-auto px-2 py-1 bg-background-accent/20 text-background-accent rounded-full text-xs">
|
||||
Suggested
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyChannelTile: React.FC<{ onCreateChannel: () => void; isInSpace?: boolean }> = ({ onCreateChannel, isInSpace = false }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
@@ -184,7 +279,7 @@ const EmptyChannelTile: React.FC<{ onCreateChannel: () => void }> = ({ onCreateC
|
||||
<Plus className="w-6 h-6 text-text-on-accent" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-default text-center">
|
||||
Create Channel
|
||||
{isInSpace ? 'Create Room' : 'Create Space'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -378,7 +473,8 @@ const CreateChannelModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, topic: string, isPublic: boolean) => Promise<void>;
|
||||
}> = ({ isOpen, onClose, onCreate }) => {
|
||||
isInSpace?: boolean;
|
||||
}> = ({ isOpen, onClose, onCreate, isInSpace = false }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
@@ -421,7 +517,9 @@ const CreateChannelModal: React.FC<{
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-text-default">Create Channel</h2>
|
||||
<h2 className="text-xl font-semibold text-text-default">
|
||||
Create {isInSpace ? 'Room' : 'Space'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-background-medium transition-colors"
|
||||
@@ -435,13 +533,13 @@ const CreateChannelModal: React.FC<{
|
||||
{/* Channel Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-default mb-2">
|
||||
Channel Name *
|
||||
{isInSpace ? 'Room' : 'Space'} Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="general, announcements, etc."
|
||||
placeholder={isInSpace ? "general, announcements, etc." : "project-alpha, team-workspace, etc."}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border-default bg-background-muted focus:outline-none focus:ring-2 focus:ring-background-accent"
|
||||
disabled={isCreating}
|
||||
required
|
||||
@@ -456,7 +554,7 @@ const CreateChannelModal: React.FC<{
|
||||
<textarea
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="Describe what this channel is about..."
|
||||
placeholder={isInSpace ? "Describe what this room is about..." : "Describe what this space is about..."}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border-default bg-background-muted focus:outline-none focus:ring-2 focus:ring-background-accent resize-none"
|
||||
disabled={isCreating}
|
||||
@@ -518,7 +616,7 @@ const CreateChannelModal: React.FC<{
|
||||
disabled={!name.trim() || isCreating}
|
||||
className="flex-1 px-4 py-3 rounded-lg bg-background-accent text-text-on-accent hover:bg-background-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Channel'}
|
||||
{isCreating ? 'Creating...' : `Create ${isInSpace ? 'Room' : 'Space'}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -535,7 +633,10 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
rooms,
|
||||
setRoomName,
|
||||
setRoomTopic,
|
||||
setRoomAvatar
|
||||
setRoomAvatar,
|
||||
createSpace,
|
||||
createRoom,
|
||||
getSpaceChildren
|
||||
} = useMatrix();
|
||||
|
||||
const { openMatrixChat } = useTabContext();
|
||||
@@ -547,6 +648,12 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
const [showMatrixAuth, setShowMatrixAuth] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
|
||||
// Space navigation state
|
||||
const [currentSpace, setCurrentSpace] = useState<Channel | null>(null);
|
||||
const [spaceChildren, setSpaceChildren] = useState<SpaceChild[]>([]);
|
||||
const [loadingSpaceChildren, setLoadingSpaceChildren] = useState(false);
|
||||
const [spaceNavigationStack, setSpaceNavigationStack] = useState<Channel[]>([]);
|
||||
|
||||
// Load favorites from localStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -606,9 +713,9 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
return mxcUrl;
|
||||
};
|
||||
|
||||
// Filter channels (non-DM rooms) from Matrix rooms and add favorite status
|
||||
// Filter spaces from Matrix rooms and add favorite status
|
||||
const channels: Channel[] = rooms
|
||||
.filter(room => !room.isDirectMessage)
|
||||
.filter(room => room.isSpace) // ✅ Now filtering for Matrix Spaces only
|
||||
.map(room => ({
|
||||
roomId: room.roomId,
|
||||
name: room.name || 'Unnamed Channel',
|
||||
@@ -638,23 +745,100 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
|
||||
const handleOpenChannel = async (channel: Channel) => {
|
||||
try {
|
||||
console.log('📱 Opening channel:', channel);
|
||||
console.log('🌌 Navigating into space:', channel);
|
||||
|
||||
// Open a new tab/chat session with Matrix room parameters
|
||||
// Pass the channel name so it appears in the tab title
|
||||
openMatrixChat(channel.roomId, currentUser?.userId || '', channel.name);
|
||||
// Fetch space children
|
||||
setLoadingSpaceChildren(true);
|
||||
const children = await getSpaceChildren(channel.roomId);
|
||||
console.log('✅ Space children loaded:', children);
|
||||
|
||||
// Navigate to the pair view where the tabs are displayed
|
||||
navigate('/pair');
|
||||
// Update navigation state
|
||||
setCurrentSpace(channel);
|
||||
setSpaceChildren(children);
|
||||
setSpaceNavigationStack([...spaceNavigationStack, channel]);
|
||||
|
||||
console.log('🧭 Space navigation updated:', {
|
||||
currentSpace: channel.name,
|
||||
childrenCount: children.length,
|
||||
navigationDepth: spaceNavigationStack.length + 1
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to open channel:', error);
|
||||
console.error('❌ Failed to navigate into space:', error);
|
||||
alert('Failed to load space contents. Please try again.');
|
||||
} finally {
|
||||
setLoadingSpaceChildren(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToSpaces = () => {
|
||||
console.log('🔙 Navigating back to spaces');
|
||||
setCurrentSpace(null);
|
||||
setSpaceChildren([]);
|
||||
setSpaceNavigationStack([]);
|
||||
};
|
||||
|
||||
const handleSpaceChildClick = async (child: SpaceChild) => {
|
||||
try {
|
||||
if (child.isSpace) {
|
||||
// Navigate into sub-space
|
||||
console.log('🌌 Navigating into sub-space:', child);
|
||||
|
||||
setLoadingSpaceChildren(true);
|
||||
const children = await getSpaceChildren(child.roomId);
|
||||
console.log('✅ Sub-space children loaded:', children);
|
||||
|
||||
// Create a Channel object for the sub-space
|
||||
const subSpaceChannel: Channel = {
|
||||
roomId: child.roomId,
|
||||
name: child.name || 'Unnamed Space',
|
||||
topic: child.topic,
|
||||
isPublic: child.isPublic || false,
|
||||
memberCount: child.memberCount || 0,
|
||||
avatarUrl: convertMxcToHttp(child.avatarUrl),
|
||||
coverPhotoUrl: convertMxcToHttp(child.avatarUrl),
|
||||
isFavorite: favorites.has(child.roomId),
|
||||
};
|
||||
|
||||
// Update navigation state
|
||||
setCurrentSpace(subSpaceChannel);
|
||||
setSpaceChildren(children);
|
||||
setSpaceNavigationStack([...spaceNavigationStack, subSpaceChannel]);
|
||||
} else {
|
||||
// Open room in chat
|
||||
console.log('💬 Opening room in chat:', child);
|
||||
openMatrixChat(child.roomId, currentUser?.userId || '', child.name || 'Unnamed Room');
|
||||
navigate('/pair');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to handle space child click:', error);
|
||||
alert('Failed to open space content. Please try again.');
|
||||
} finally {
|
||||
setLoadingSpaceChildren(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChannel = async (name: string, topic: string, isPublic: boolean) => {
|
||||
// TODO: Implement channel creation via Matrix service
|
||||
console.log('Creating channel:', { name, topic, isPublic });
|
||||
alert('Channel creation not yet implemented');
|
||||
try {
|
||||
if (currentSpace) {
|
||||
// We're inside a space, create a room within this space
|
||||
console.log('💬 Creating Matrix Room in space:', { name, topic, isPublic, parentSpaceId: currentSpace.roomId });
|
||||
const roomId = await createRoom(name, topic, isPublic, currentSpace.roomId);
|
||||
console.log('✅ Matrix Room created successfully:', roomId);
|
||||
|
||||
// Refresh space children to show the new room
|
||||
const children = await getSpaceChildren(currentSpace.roomId);
|
||||
setSpaceChildren(children);
|
||||
} else {
|
||||
// We're in the main spaces view, create a new space
|
||||
console.log('🌌 Creating Matrix Space:', { name, topic, isPublic });
|
||||
const spaceId = await createSpace(name, topic, isPublic);
|
||||
console.log('✅ Matrix Space created successfully:', spaceId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create:', error);
|
||||
const itemType = currentSpace ? 'room' : 'space';
|
||||
alert(`Failed to create ${itemType}. Please try again.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditChannel = (channel: Channel) => {
|
||||
@@ -740,14 +924,52 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
<div className="pt-14 pb-4 px-4 mb-0.5 bg-background-default rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-background-accent rounded-full flex items-center justify-center">
|
||||
<Hash className="w-5 h-5 text-text-on-accent" />
|
||||
</div>
|
||||
{currentSpace ? (
|
||||
<button
|
||||
onClick={handleBackToSpaces}
|
||||
className="w-8 h-8 bg-background-accent rounded-full flex items-center justify-center hover:bg-background-accent/80 transition-colors"
|
||||
title="Back to Spaces"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-text-on-accent" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-background-accent rounded-full flex items-center justify-center">
|
||||
<Hash className="w-5 h-5 text-text-on-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-default">Channels</h1>
|
||||
<p className="text-sm text-text-muted">
|
||||
{isConnected ? `${channels.length} channels` : 'Not connected'}
|
||||
</p>
|
||||
{currentSpace ? (
|
||||
<>
|
||||
{/* Breadcrumb navigation */}
|
||||
<div className="flex items-center gap-1 text-sm text-text-muted mb-1">
|
||||
<button
|
||||
onClick={handleBackToSpaces}
|
||||
className="hover:text-text-default transition-colors"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
</button>
|
||||
{spaceNavigationStack.map((space, index) => (
|
||||
<React.Fragment key={space.roomId}>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span className={index === spaceNavigationStack.length - 1 ? 'text-text-default font-medium' : ''}>
|
||||
{space.name}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-text-default">{currentSpace.name}</h1>
|
||||
<p className="text-sm text-text-muted">
|
||||
{loadingSpaceChildren ? 'Loading...' : `${spaceChildren.length} items`}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-xl font-semibold text-text-default">Spaces</h1>
|
||||
<p className="text-sm text-text-muted">
|
||||
{isConnected ? `${channels.length} spaces` : 'Not connected'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -785,7 +1007,7 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search channels..."
|
||||
placeholder="Search spaces..."
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-border-default bg-background-muted focus:outline-none focus:ring-2 focus:ring-background-accent text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -801,7 +1023,7 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
<WifiOff className="w-12 h-12 text-text-muted mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-default mb-2">Not Connected</h3>
|
||||
<p className="text-text-muted mb-6">
|
||||
Connect to Matrix to access channels and collaborate with your team.
|
||||
Connect to Matrix to access spaces and collaborate with your team.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowMatrixAuth(true)}
|
||||
@@ -816,7 +1038,43 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
<h3 className="text-lg font-medium text-text-default mb-2">Loading...</h3>
|
||||
<p className="text-text-muted">Syncing with Matrix server...</p>
|
||||
</div>
|
||||
) : currentSpace ? (
|
||||
// Show space contents
|
||||
loadingSpaceChildren ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-background-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-default mb-2">Loading Space Contents...</h3>
|
||||
<p className="text-text-muted">Fetching rooms and sub-spaces...</p>
|
||||
</div>
|
||||
) : spaceChildren.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Hash className="w-12 h-12 text-text-muted mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-default mb-2">Empty Space</h3>
|
||||
<p className="text-text-muted mb-6">
|
||||
This space doesn't contain any rooms or sub-spaces yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-0.5">
|
||||
{spaceChildren.map((child) => (
|
||||
<SpaceChildCard
|
||||
key={child.roomId}
|
||||
child={child}
|
||||
onChildClick={handleSpaceChildClick}
|
||||
/>
|
||||
))}
|
||||
{/* Empty tiles for creating new rooms in this space - fill remaining space */}
|
||||
{Array.from({ length: 50 - spaceChildren.length }).map((_, index) => (
|
||||
<EmptyChannelTile
|
||||
key={`empty-room-${index}`}
|
||||
onCreateChannel={() => setShowCreateModal(true)}
|
||||
isInSpace={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// Show spaces grid
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-0.5">
|
||||
{filteredChannels.map((channel) => (
|
||||
<ChannelCard
|
||||
@@ -847,6 +1105,7 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreate={handleCreateChannel}
|
||||
isInSpace={!!currentSpace}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { MatrixService, MatrixUser, MatrixRoom, GooseAIMessage, GooseChatMessage, GooseInstance } from '../services/MatrixService';
|
||||
import { MatrixService, MatrixUser, MatrixRoom, SpaceChild, GooseAIMessage, GooseChatMessage, GooseInstance } from '../services/MatrixService';
|
||||
|
||||
interface MatrixContextType {
|
||||
// Connection state
|
||||
@@ -19,6 +19,11 @@ interface MatrixContextType {
|
||||
searchUsers: (query: string) => Promise<MatrixUser[]>;
|
||||
addFriend: (userId: string) => Promise<void>;
|
||||
createAISession: (name: string, inviteUserIds?: string[]) => Promise<string>;
|
||||
createSpace: (name: string, topic: string, isPublic?: boolean) => Promise<string>;
|
||||
createRoom: (name: string, topic: string, isPublic?: boolean, parentSpaceId?: string) => Promise<string>;
|
||||
getSpaceChildren: (spaceId: string) => Promise<SpaceChild[]>;
|
||||
addChildToSpace: (spaceId: string, childRoomId: string, suggested?: boolean, order?: string) => Promise<void>;
|
||||
removeChildFromSpace: (spaceId: string, childRoomId: string) => Promise<void>;
|
||||
joinRoom: (roomId: string) => Promise<void>;
|
||||
inviteToRoom: (roomId: string, userId: string) => Promise<void>;
|
||||
sendMessage: (roomId: string, message: string) => Promise<void>;
|
||||
@@ -233,6 +238,26 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children, matrix
|
||||
return await matrixService.createAISession(name, inviteUserIds);
|
||||
};
|
||||
|
||||
const createSpace = async (name: string, topic: string, isPublic: boolean = false) => {
|
||||
return await matrixService.createSpace(name, topic, isPublic);
|
||||
};
|
||||
|
||||
const createRoom = async (name: string, topic: string, isPublic: boolean = false, parentSpaceId?: string) => {
|
||||
return await matrixService.createRoom(name, topic, isPublic, parentSpaceId);
|
||||
};
|
||||
|
||||
const getSpaceChildren = async (spaceId: string) => {
|
||||
return await matrixService.getSpaceChildren(spaceId);
|
||||
};
|
||||
|
||||
const addChildToSpace = async (spaceId: string, childRoomId: string, suggested: boolean = false, order?: string) => {
|
||||
await matrixService.addChildToSpace(spaceId, childRoomId, suggested, order);
|
||||
};
|
||||
|
||||
const removeChildFromSpace = async (spaceId: string, childRoomId: string) => {
|
||||
await matrixService.removeChildFromSpace(spaceId, childRoomId);
|
||||
};
|
||||
|
||||
const joinRoom = async (roomId: string) => {
|
||||
await matrixService.joinRoom(roomId);
|
||||
// Data will be updated via the membershipChange event
|
||||
@@ -399,6 +424,11 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children, matrix
|
||||
searchUsers,
|
||||
addFriend,
|
||||
createAISession,
|
||||
createSpace,
|
||||
createRoom,
|
||||
getSpaceChildren,
|
||||
addChildToSpace,
|
||||
removeChildFromSpace,
|
||||
joinRoom,
|
||||
inviteToRoom,
|
||||
sendMessage,
|
||||
|
||||
@@ -17,7 +17,24 @@ export interface MatrixRoom {
|
||||
topic?: string;
|
||||
members: MatrixUser[];
|
||||
isDirectMessage: boolean;
|
||||
isSpace: boolean;
|
||||
roomType?: string;
|
||||
lastActivity?: Date;
|
||||
avatarUrl?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface SpaceChild {
|
||||
roomId: string;
|
||||
name?: string;
|
||||
topic?: string;
|
||||
avatarUrl?: string;
|
||||
isSpace: boolean;
|
||||
isPublic?: boolean;
|
||||
suggested?: boolean;
|
||||
via?: string[];
|
||||
order?: string;
|
||||
memberCount?: number;
|
||||
}
|
||||
|
||||
export interface GooseAIMessage {
|
||||
@@ -914,6 +931,269 @@ export class MatrixService extends EventEmitter {
|
||||
return room.room_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the children (rooms and sub-spaces) of a Matrix Space
|
||||
*/
|
||||
async getSpaceChildren(spaceId: string): Promise<SpaceChild[]> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
console.log('🌌 Getting children for space:', spaceId);
|
||||
|
||||
try {
|
||||
const space = this.client.getRoom(spaceId);
|
||||
if (!space) {
|
||||
console.error('❌ Space not found:', spaceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all m.space.child state events
|
||||
const childEvents = space.currentState.getStateEvents('m.space.child');
|
||||
const children: SpaceChild[] = [];
|
||||
|
||||
for (const event of childEvents) {
|
||||
const childRoomId = event.getStateKey();
|
||||
if (!childRoomId) continue;
|
||||
|
||||
const content = event.getContent();
|
||||
|
||||
// Skip if the child is deleted (empty content)
|
||||
if (!content || Object.keys(content).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get information about the child room/space
|
||||
const childRoom = this.client.getRoom(childRoomId);
|
||||
let childInfo: SpaceChild;
|
||||
|
||||
if (childRoom) {
|
||||
// We have local information about this room
|
||||
const avatarEvent = childRoom.currentState.getStateEvents('m.room.avatar', '');
|
||||
const avatarUrl = avatarEvent?.getContent()?.url || null;
|
||||
|
||||
const createEvent = childRoom.currentState.getStateEvents('m.room.create', '');
|
||||
const isChildSpace = createEvent?.getContent()?.type === 'm.space';
|
||||
|
||||
const joinRulesEvent = childRoom.currentState.getStateEvents('m.room.join_rules', '');
|
||||
const isPublic = joinRulesEvent?.getContent()?.join_rule === 'public';
|
||||
|
||||
childInfo = {
|
||||
roomId: childRoomId,
|
||||
name: childRoom.name || content.name || 'Unnamed Room',
|
||||
topic: childRoom.currentState.getStateEvents('m.room.topic', '')?.getContent()?.topic || content.topic,
|
||||
avatarUrl: avatarUrl,
|
||||
isSpace: isChildSpace,
|
||||
isPublic: isPublic,
|
||||
suggested: content.suggested || false,
|
||||
via: content.via || [],
|
||||
order: content.order,
|
||||
memberCount: childRoom.getMembers().length,
|
||||
};
|
||||
} else {
|
||||
// We don't have local info, use what's in the space child event
|
||||
childInfo = {
|
||||
roomId: childRoomId,
|
||||
name: content.name || 'Unknown Room',
|
||||
topic: content.topic,
|
||||
avatarUrl: content.avatar_url,
|
||||
isSpace: false, // We can't determine this without the room
|
||||
isPublic: false, // We can't determine this without the room
|
||||
suggested: content.suggested || false,
|
||||
via: content.via || [],
|
||||
order: content.order,
|
||||
memberCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
children.push(childInfo);
|
||||
}
|
||||
|
||||
// Sort children by order, then by name
|
||||
children.sort((a, b) => {
|
||||
if (a.order && b.order) {
|
||||
return a.order.localeCompare(b.order);
|
||||
}
|
||||
if (a.order && !b.order) return -1;
|
||||
if (!a.order && b.order) return 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
console.log('✅ Found', children.length, 'children in space:', spaceId);
|
||||
return children;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get space children:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a room or space as a child of a Matrix Space
|
||||
*/
|
||||
async addChildToSpace(spaceId: string, childRoomId: string, suggested: boolean = false, order?: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
console.log('🌌 Adding child to space:', { spaceId, childRoomId, suggested, order });
|
||||
|
||||
try {
|
||||
// Get the child room to determine via servers
|
||||
const childRoom = this.client.getRoom(childRoomId);
|
||||
let via: string[] = [];
|
||||
|
||||
if (childRoom) {
|
||||
// Extract server names from room members for via servers
|
||||
const members = childRoom.getMembers();
|
||||
const servers = new Set<string>();
|
||||
members.forEach(member => {
|
||||
const serverName = member.userId.split(':')[1];
|
||||
if (serverName) {
|
||||
servers.add(serverName);
|
||||
}
|
||||
});
|
||||
via = Array.from(servers).slice(0, 3); // Limit to 3 servers
|
||||
}
|
||||
|
||||
// Create the space child state event
|
||||
const content: any = {
|
||||
via: via.length > 0 ? via : [this.config.homeserverUrl.replace('https://', '')],
|
||||
suggested: suggested,
|
||||
};
|
||||
|
||||
if (order) {
|
||||
content.order = order;
|
||||
}
|
||||
|
||||
// Set the m.space.child state event
|
||||
await this.client.sendStateEvent(spaceId, 'm.space.child', content, childRoomId);
|
||||
|
||||
console.log('✅ Successfully added child to space');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to add child to space:', error);
|
||||
throw new Error('Failed to add child to space');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a child from a Matrix Space
|
||||
*/
|
||||
async removeChildFromSpace(spaceId: string, childRoomId: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
console.log('🌌 Removing child from space:', { spaceId, childRoomId });
|
||||
|
||||
try {
|
||||
// Remove the m.space.child state event by sending empty content
|
||||
await this.client.sendStateEvent(spaceId, 'm.space.child', {}, childRoomId);
|
||||
console.log('✅ Successfully removed child from space');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to remove child from space:', error);
|
||||
throw new Error('Failed to remove child from space');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Matrix Space
|
||||
*/
|
||||
async createSpace(name: string, topic: string, isPublic: boolean = false): Promise<string> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
console.log('🌌 Creating Matrix Space:', { name, topic, isPublic });
|
||||
|
||||
try {
|
||||
// Create a Matrix Space room
|
||||
const room = await this.client.createRoom({
|
||||
name: name,
|
||||
topic: topic,
|
||||
preset: isPublic ? 'public_chat' : 'private_chat',
|
||||
creation_content: {
|
||||
type: 'm.space', // This makes it a space instead of a regular room
|
||||
},
|
||||
initial_state: [
|
||||
{
|
||||
type: 'm.room.history_visibility',
|
||||
content: {
|
||||
history_visibility: isPublic ? 'world_readable' : 'invited',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'm.room.guest_access',
|
||||
content: {
|
||||
guest_access: isPublic ? 'can_join' : 'forbidden',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Matrix Space created successfully:', room.room_id);
|
||||
|
||||
// Clear rooms cache to refresh space data
|
||||
this.cachedRooms = null;
|
||||
|
||||
return room.room_id;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create Matrix Space:', error);
|
||||
throw new Error('Failed to create space');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Matrix Room (regular room, not a space)
|
||||
*/
|
||||
async createRoom(name: string, topic: string, isPublic: boolean = false, parentSpaceId?: string): Promise<string> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
console.log('💬 Creating Matrix Room:', { name, topic, isPublic, parentSpaceId });
|
||||
|
||||
try {
|
||||
// Create a regular Matrix room (not a space)
|
||||
const room = await this.client.createRoom({
|
||||
name: name,
|
||||
topic: topic,
|
||||
preset: isPublic ? 'public_chat' : 'private_chat',
|
||||
// No creation_content.type means it's a regular room
|
||||
initial_state: [
|
||||
{
|
||||
type: 'm.room.history_visibility',
|
||||
content: {
|
||||
history_visibility: isPublic ? 'world_readable' : 'invited',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'm.room.guest_access',
|
||||
content: {
|
||||
guest_access: isPublic ? 'can_join' : 'forbidden',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Matrix Room created successfully:', room.room_id);
|
||||
|
||||
// If a parent space is specified, add this room as a child
|
||||
if (parentSpaceId) {
|
||||
console.log('🌌 Adding room to parent space:', parentSpaceId);
|
||||
await this.addChildToSpace(parentSpaceId, room.room_id, false);
|
||||
console.log('✅ Room added to parent space');
|
||||
}
|
||||
|
||||
// Clear rooms cache to refresh room data
|
||||
this.cachedRooms = null;
|
||||
|
||||
return room.room_id;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create Matrix Room:', error);
|
||||
throw new Error('Failed to create room');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to an existing room
|
||||
*/
|
||||
@@ -1138,6 +1418,27 @@ export class MatrixService extends EventEmitter {
|
||||
return isTrueDM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a room is a Matrix Space
|
||||
*/
|
||||
private isSpaceRoom(room: any): boolean {
|
||||
// Check for room creation event with type m.space
|
||||
const createEvent = room.currentState.getStateEvents('m.room.create', '');
|
||||
const roomType = createEvent?.getContent()?.type;
|
||||
|
||||
return roomType === 'm.space';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a room is public (joinable by anyone)
|
||||
*/
|
||||
private isPublicRoom(room: any): boolean {
|
||||
const joinRulesEvent = room.currentState.getStateEvents('m.room.join_rules', '');
|
||||
const joinRule = joinRulesEvent?.getContent()?.join_rule;
|
||||
|
||||
return joinRule === 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rooms the user is in
|
||||
*/
|
||||
@@ -1157,6 +1458,21 @@ export class MatrixService extends EventEmitter {
|
||||
const avatarEvent = room.currentState.getStateEvents('m.room.avatar', '');
|
||||
const avatarUrl = avatarEvent?.getContent()?.url || null;
|
||||
|
||||
// Get room type from creation event
|
||||
const createEvent = room.currentState.getStateEvents('m.room.create', '');
|
||||
const roomType = createEvent?.getContent()?.type;
|
||||
|
||||
// Check if this is a space
|
||||
const isSpace = this.isSpaceRoom(room);
|
||||
|
||||
// Check if this is a public room
|
||||
const isPublic = this.isPublicRoom(room);
|
||||
|
||||
// Debug logging for spaces
|
||||
if (isSpace) {
|
||||
console.log('🌌 getRooms: Found Matrix Space:', room.name || 'Unnamed Space', '→', room.roomId.substring(0, 20) + '...');
|
||||
}
|
||||
|
||||
// Debug logging for avatar URLs
|
||||
if (avatarUrl) {
|
||||
console.log('🖼️ getRooms: Found avatar for room', room.roomId.substring(0, 20) + '...', '→', avatarUrl);
|
||||
@@ -1179,6 +1495,9 @@ export class MatrixService extends EventEmitter {
|
||||
};
|
||||
}),
|
||||
isDirectMessage: this.isDirectMessageRoom(room), // Use improved DM detection
|
||||
isSpace: isSpace, // Matrix Space detection
|
||||
roomType: roomType, // Store the room type
|
||||
isPublic: isPublic, // Public/private status
|
||||
lastActivity: new Date(room.getLastActiveTimestamp()),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user