feat(matrix): implement WhatsApp-style mobile navigation

Mobile users now get a separate room list and chat view:
- /chat shows full-screen room list on mobile
- /chat/[roomId] shows full-screen chat with back button
- Desktop retains side-by-side layout unchanged
- Last room restore only happens on desktop
This commit is contained in:
Till-JS 2026-02-13 12:08:23 +01:00
parent f4c2663122
commit 435d06a756
4 changed files with 362 additions and 172 deletions

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import {
ArrowLeft,
List,
Phone,
VideoCamera,
@ -18,9 +19,19 @@
onSearchClick?: () => void;
onVoiceCall?: () => void;
onVideoCall?: () => void;
showBackButton?: boolean;
onBackClick?: () => void;
}
let { onMenuClick, onInfoClick, onSearchClick, onVoiceCall, onVideoCall }: Props = $props();
let {
onMenuClick,
onInfoClick,
onSearchClick,
onVoiceCall,
onVideoCall,
showBackButton = false,
onBackClick,
}: Props = $props();
// Check if calls are possible (DMs only for now)
let canCall = $derived(matrixStore.currentSimpleRoom?.isDirect ?? false);
@ -68,13 +79,23 @@
<header
class="flex items-center gap-3 border-b border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/5 backdrop-blur-sm px-4 py-3"
>
<!-- Mobile menu button -->
<button
class="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors lg:hidden"
onclick={onMenuClick}
>
<List class="h-5 w-5" />
</button>
<!-- Mobile back button or menu button -->
{#if showBackButton}
<button
class="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
onclick={onBackClick}
aria-label="Zurück"
>
<ArrowLeft class="h-5 w-5" />
</button>
{:else}
<button
class="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors lg:hidden"
onclick={onMenuClick}
>
<List class="h-5 w-5" />
</button>
{/if}
<!-- Room avatar with online indicator -->
<div class="relative flex-shrink-0">

View file

@ -262,26 +262,8 @@ class MatrixStore {
this._rooms = this._client!.getRooms();
console.log(`Matrix sync prepared, ${this._rooms.length} rooms loaded`);
// Restore last selected room
if (browser && !this._currentRoomId) {
const lastRoomId = localStorage.getItem(LAST_ROOM_KEY);
console.log(
`[Matrix] Restore check: lastRoomId=${lastRoomId}, rooms=${this._rooms.length}`
);
if (lastRoomId) {
// Check if room exists in loaded rooms
const roomExists = this._rooms.some((r) => r.roomId === lastRoomId);
console.log(`[Matrix] Room exists: ${roomExists}`);
if (roomExists) {
console.log(`[Matrix] Restoring room: ${lastRoomId}`);
this.selectRoom(lastRoomId);
}
}
} else {
console.log(
`[Matrix] Skip restore: browser=${browser}, currentRoomId=${this._currentRoomId}`
);
}
// Note: Last room restore is now handled in the /chat page component
// to support different behavior on mobile vs desktop
}
if (state === 'ERROR') {
@ -482,7 +464,6 @@ class MatrixStore {
* Select a room to view
*/
selectRoom(roomId: string) {
console.log(`[Matrix] selectRoom called: ${roomId}`);
this._currentRoomId = roomId;
const room = this._client?.getRoom(roomId);
@ -498,10 +479,8 @@ class MatrixStore {
// Save last room to localStorage
if (browser) {
localStorage.setItem(LAST_ROOM_KEY, roomId);
console.log(`[Matrix] Saved to localStorage: ${roomId}`);
}
} else {
console.log(`[Matrix] Room not found by getRoom: ${roomId}`);
this._timeline = [];
}
}

View file

@ -6,15 +6,15 @@
import SearchDialog from '$lib/components/chat/SearchDialog.svelte';
import ForwardMessageDialog from '$lib/components/chat/ForwardMessageDialog.svelte';
import { CallView, IncomingCallDialog } from '$lib/components/call';
import { ChatCircle, Plus, Gear, List } from '@manacore/shared-icons';
import { ChatCircle, Plus, Gear } from '@manacore/shared-icons';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// Call state
let activeCall = $derived(matrixStore.activeCall);
let incomingCall = $derived(matrixStore.incomingCall);
// Start with sidebar closed on mobile
let sidebarOpen = $state(browser ? window.innerWidth >= 1024 : true);
let showCreateRoom = $state(false);
let showRoomSettings = $state(false);
let showSearch = $state(false);
@ -25,17 +25,13 @@
let editMessage = $state<SimpleMessage | null>(null);
let forwardMessage = $state<SimpleMessage | null>(null);
// Check if mobile
// Check if mobile/desktop
let isMobile = $state(browser ? window.innerWidth < 1024 : false);
// Update on resize
if (browser) {
window.addEventListener('resize', () => {
isMobile = window.innerWidth < 1024;
// Auto-close sidebar on resize to mobile
if (isMobile && sidebarOpen) {
sidebarOpen = false;
}
});
// Keyboard shortcuts
@ -53,14 +49,39 @@
});
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
// Handle last room restore - only on desktop
onMount(() => {
if (browser && !isMobile && matrixStore.isReady) {
const lastRoomId = localStorage.getItem('matrix_last_room');
if (lastRoomId) {
const roomExists = matrixStore.rooms.some((r) => r.id === lastRoomId);
if (roomExists) {
matrixStore.selectRoom(lastRoomId);
}
}
}
});
function selectRoomAndCloseSidebar(roomId: string) {
matrixStore.selectRoom(roomId);
// Also watch for sync state changes to restore room on desktop
$effect(() => {
if (browser && !isMobile && matrixStore.isReady && !matrixStore.currentRoomId) {
const lastRoomId = localStorage.getItem('matrix_last_room');
if (lastRoomId) {
const roomExists = matrixStore.rooms.some((r) => r.id === lastRoomId);
if (roomExists) {
matrixStore.selectRoom(lastRoomId);
}
}
}
});
function handleSelectRoom(roomId: string) {
if (isMobile) {
sidebarOpen = false;
// On mobile, navigate to the room page
goto('/chat/' + encodeURIComponent(roomId));
} else {
// On desktop, just select the room (stay on same page)
matrixStore.selectRoom(roomId);
}
}
@ -80,7 +101,11 @@
}
function handleRoomCreated(roomId: string) {
matrixStore.selectRoom(roomId);
if (isMobile) {
goto('/chat/' + encodeURIComponent(roomId));
} else {
matrixStore.selectRoom(roomId);
}
}
// Call handlers
@ -109,147 +134,162 @@
}
</script>
<div class="chat-layout flex h-full min-h-0 overflow-hidden bg-background relative">
<!-- Mobile Sidebar Backdrop -->
{#if sidebarOpen && isMobile}
<button
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
onclick={() => (sidebarOpen = false)}
aria-label="Sidebar schließen"
></button>
{/if}
<!-- Sidebar -->
<aside
class="flex flex-col border-r border-black/10 dark:border-white/10 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl transition-all duration-300 ease-in-out
fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto
w-[85vw] max-w-[320px] lg:w-80 lg:max-w-none
{sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}"
>
{#if isMobile}
<!-- Mobile: Full-screen room list -->
<div class="flex flex-col h-full bg-background">
<!-- User Info / Status Bar -->
<div class="border-b border-black/10 dark:border-white/10 px-4 py-3">
<div
class="border-b border-black/10 dark:border-white/10 px-4 py-3 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl"
>
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium">{matrixStore.userId}</p>
<div>
<h1 class="text-xl font-bold text-foreground">Manalink</h1>
<p class="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{matrixStore.syncState === 'SYNCING' ? 'Verbunden' : matrixStore.syncState}
{#if matrixStore.totalUnreadCount > 0}
<span
class="ml-2 px-1.5 py-0.5 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-xs font-medium"
>
{matrixStore.totalUnreadCount} ungelesen
</span>
{/if}
</p>
</div>
<div class="flex items-center gap-1">
<a
href="/settings"
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
class="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title="Einstellungen"
>
<Gear class="h-4 w-4" />
<Gear class="h-5 w-5" />
</a>
<button
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
class="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title="Neuer Chat"
onclick={() => (showCreateRoom = true)}
>
<Plus class="h-4 w-4" />
<Plus class="h-5 w-5" />
</button>
</div>
</div>
<p class="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{matrixStore.syncState === 'SYNCING' ? 'Verbunden' : matrixStore.syncState}
{#if matrixStore.totalUnreadCount > 0}
<span
class="ml-auto px-1.5 py-0.5 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-xs font-medium"
>
{matrixStore.totalUnreadCount}
</span>
{/if}
</p>
</div>
<!-- Room List -->
<div class="flex-1 min-h-0 overflow-hidden">
<RoomList
onCreateRoom={() => (showCreateRoom = true)}
onSelectRoom={selectRoomAndCloseSidebar}
/>
<RoomList onCreateRoom={() => (showCreateRoom = true)} onSelectRoom={handleSelectRoom} />
</div>
</aside>
<!-- Main Chat Area -->
<main class="flex flex-1 min-h-0 flex-col overflow-hidden bg-background">
{#if matrixStore.currentRoom}
<!-- Room Header -->
<RoomHeader
onMenuClick={toggleSidebar}
onInfoClick={() => (showRoomSettings = true)}
onSearchClick={() => (showSearch = true)}
onVoiceCall={handleVoiceCall}
onVideoCall={handleVideoCall}
/>
<!-- Timeline -->
<Timeline onReply={handleReply} onEdit={handleEdit} onForward={handleForward} />
<!-- Message Input -->
<MessageInput
{replyTo}
{editMessage}
onCancelReply={() => (replyTo = null)}
onCancelEdit={() => (editMessage = null)}
/>
{:else}
<!-- No Room Selected -->
<div
class="flex flex-1 flex-col items-center justify-center gap-4 p-8 pb-24 text-muted-foreground"
>
<div class="p-4 rounded-2xl bg-gradient-to-br from-violet-500/20 to-purple-600/20">
<ChatCircle class="h-12 w-12 text-violet-500" />
</div>
<div class="text-center">
<h2 class="text-xl font-semibold text-foreground">Willkommen bei Manalink</h2>
<p class="mt-2 max-w-sm">
Wähle eine Unterhaltung aus der Seitenleiste oder starte einen neuen Chat
</p>
</div>
<button
class="mt-4 px-6 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-indigo-600 text-white font-medium shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex items-center gap-2"
onclick={() => (showCreateRoom = true)}
>
<Plus class="h-4 w-4" />
Neuer Chat
</button>
<!-- Stats -->
<div class="mt-8 flex gap-8 text-center">
<div class="glass-card rounded-xl px-6 py-4">
<p class="text-3xl font-bold text-foreground">{matrixStore.rooms.length}</p>
<p class="text-sm text-muted-foreground">Räume</p>
</div>
<div class="glass-card rounded-xl px-6 py-4">
<p class="text-3xl font-bold text-foreground">{matrixStore.totalUnreadCount}</p>
<p class="text-sm text-muted-foreground">Ungelesen</p>
</div>
</div>
</div>
{/if}
</main>
<!-- Room Settings Panel -->
<RoomSettingsPanel open={showRoomSettings} onClose={() => (showRoomSettings = false)} />
<!-- FAB to open sidebar (mobile/tablet only, when sidebar is closed) -->
{#if !sidebarOpen}
<button
onclick={toggleSidebar}
class="fixed bottom-24 left-4 z-[100] lg:hidden flex items-center justify-center w-14 h-14 rounded-full bg-gradient-to-r from-violet-500 to-purple-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all duration-200"
aria-label="Chats anzeigen"
</div>
{:else}
<!-- Desktop: Side-by-side layout -->
<div class="chat-layout flex h-full min-h-0 overflow-hidden bg-background">
<!-- Sidebar -->
<aside
class="flex flex-col border-r border-black/10 dark:border-white/10 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl w-80"
>
<List class="h-6 w-6" weight="bold" />
{#if matrixStore.totalUnreadCount > 0}
<span
class="absolute -top-1 -right-1 min-w-[22px] h-[22px] px-1.5 flex items-center justify-center rounded-full bg-red-500 text-white text-xs font-bold shadow-md"
<!-- User Info / Status Bar -->
<div class="border-b border-black/10 dark:border-white/10 px-4 py-3">
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium">{matrixStore.userId}</p>
<div class="flex items-center gap-1">
<a
href="/settings"
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title="Einstellungen"
>
<Gear class="h-4 w-4" />
</a>
<button
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title="Neuer Chat"
onclick={() => (showCreateRoom = true)}
>
<Plus class="h-4 w-4" />
</button>
</div>
</div>
<p class="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{matrixStore.syncState === 'SYNCING' ? 'Verbunden' : matrixStore.syncState}
{#if matrixStore.totalUnreadCount > 0}
<span
class="ml-auto px-1.5 py-0.5 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-xs font-medium"
>
{matrixStore.totalUnreadCount}
</span>
{/if}
</p>
</div>
<!-- Room List -->
<div class="flex-1 min-h-0 overflow-hidden">
<RoomList onCreateRoom={() => (showCreateRoom = true)} onSelectRoom={handleSelectRoom} />
</div>
</aside>
<!-- Main Chat Area -->
<main class="flex flex-1 min-h-0 flex-col overflow-hidden bg-background">
{#if matrixStore.currentRoom}
<!-- Room Header -->
<RoomHeader
onInfoClick={() => (showRoomSettings = true)}
onSearchClick={() => (showSearch = true)}
onVoiceCall={handleVoiceCall}
onVideoCall={handleVideoCall}
/>
<!-- Timeline -->
<Timeline onReply={handleReply} onEdit={handleEdit} onForward={handleForward} />
<!-- Message Input -->
<MessageInput
{replyTo}
{editMessage}
onCancelReply={() => (replyTo = null)}
onCancelEdit={() => (editMessage = null)}
/>
{:else}
<!-- No Room Selected -->
<div
class="flex flex-1 flex-col items-center justify-center gap-4 p-8 pb-24 text-muted-foreground"
>
{matrixStore.totalUnreadCount > 99 ? '99+' : matrixStore.totalUnreadCount}
</span>
<div class="p-4 rounded-2xl bg-gradient-to-br from-violet-500/20 to-purple-600/20">
<ChatCircle class="h-12 w-12 text-violet-500" />
</div>
<div class="text-center">
<h2 class="text-xl font-semibold text-foreground">Willkommen bei Manalink</h2>
<p class="mt-2 max-w-sm">
Wähle eine Unterhaltung aus der Seitenleiste oder starte einen neuen Chat
</p>
</div>
<button
class="mt-4 px-6 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-indigo-600 text-white font-medium shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex items-center gap-2"
onclick={() => (showCreateRoom = true)}
>
<Plus class="h-4 w-4" />
Neuer Chat
</button>
<!-- Stats -->
<div class="mt-8 flex gap-8 text-center">
<div class="glass-card rounded-xl px-6 py-4">
<p class="text-3xl font-bold text-foreground">{matrixStore.rooms.length}</p>
<p class="text-sm text-muted-foreground">Räume</p>
</div>
<div class="glass-card rounded-xl px-6 py-4">
<p class="text-3xl font-bold text-foreground">{matrixStore.totalUnreadCount}</p>
<p class="text-sm text-muted-foreground">Ungelesen</p>
</div>
</div>
</div>
{/if}
</button>
{/if}
</div>
</main>
<!-- Room Settings Panel -->
<RoomSettingsPanel open={showRoomSettings} onClose={() => (showRoomSettings = false)} />
</div>
{/if}
<!-- Create Room Dialog -->
<CreateRoomDialog

View file

@ -1,26 +1,176 @@
<script lang="ts">
import { page } from '$app/stores';
import { matrixStore } from '$lib/matrix';
import { matrixStore, type SimpleMessage } from '$lib/matrix';
import { RoomHeader, Timeline, MessageInput } from '$lib/components/chat';
import RoomSettingsPanel from '$lib/components/chat/RoomSettingsPanel.svelte';
import SearchDialog from '$lib/components/chat/SearchDialog.svelte';
import ForwardMessageDialog from '$lib/components/chat/ForwardMessageDialog.svelte';
import { CallView, IncomingCallDialog } from '$lib/components/call';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
// Get roomId from URL and select that room
// Call state
let activeCall = $derived(matrixStore.activeCall);
let incomingCall = $derived(matrixStore.incomingCall);
let showRoomSettings = $state(false);
let showSearch = $state(false);
let showForward = $state(false);
// Reply/Edit/Forward state
let replyTo = $state<SimpleMessage | null>(null);
let editMessage = $state<SimpleMessage | null>(null);
let forwardMessage = $state<SimpleMessage | null>(null);
// Check if mobile
let isMobile = $state(browser ? window.innerWidth < 1024 : false);
// Update on resize
if (browser) {
window.addEventListener('resize', () => {
const wasMobile = isMobile;
isMobile = window.innerWidth < 1024;
// If we switched from mobile to desktop, redirect to /chat
if (wasMobile && !isMobile) {
goto('/chat');
}
});
}
// On mount: Select the room and handle desktop redirect
onMount(() => {
const roomId = $page.params.roomId;
if (roomId) {
// URL decode the room ID (Matrix room IDs contain special chars)
const decodedRoomId = decodeURIComponent(roomId);
// On desktop, redirect to /chat and select the room there
if (!isMobile) {
matrixStore.selectRoom(decodedRoomId);
goto('/chat');
return;
}
// On mobile, select the room and stay on this page
matrixStore.selectRoom(decodedRoomId);
}
});
// Redirect to main chat page - the room selection is handled there
// Handle URL changes (e.g., navigating between rooms)
$effect(() => {
goto('/chat');
const roomId = $page.params.roomId;
if (roomId && isMobile) {
const decodedRoomId = decodeURIComponent(roomId);
if (matrixStore.currentRoomId !== decodedRoomId) {
matrixStore.selectRoom(decodedRoomId);
}
}
});
function handleBack() {
goto('/chat');
}
function handleReply(message: SimpleMessage) {
editMessage = null;
replyTo = message;
}
function handleEdit(message: SimpleMessage) {
replyTo = null;
editMessage = message;
}
function handleForward(message: SimpleMessage) {
forwardMessage = message;
showForward = true;
}
// Call handlers
async function handleVoiceCall() {
if (matrixStore.currentRoom) {
await matrixStore.placeVoiceCall(matrixStore.currentRoom.roomId);
}
}
async function handleVideoCall() {
if (matrixStore.currentRoom) {
await matrixStore.placeVideoCall(matrixStore.currentRoom.roomId);
}
}
function handleCallHangup() {
// Call ended - UI will update automatically
}
function handleCallAnswer() {
// Call answered - UI will update automatically
}
function handleCallReject() {
// Call rejected - UI will update automatically
}
</script>
<!-- This page just handles deep-linking to specific rooms -->
<div class="flex h-screen items-center justify-center">
<p class="text-base-content/60">Loading room...</p>
<!-- Full-screen chat view for mobile -->
<div class="flex flex-col h-full bg-background">
{#if matrixStore.currentRoom}
<!-- Room Header with back button -->
<RoomHeader
showBackButton={true}
onBackClick={handleBack}
onInfoClick={() => (showRoomSettings = true)}
onSearchClick={() => (showSearch = true)}
onVoiceCall={handleVoiceCall}
onVideoCall={handleVideoCall}
/>
<!-- Timeline -->
<Timeline onReply={handleReply} onEdit={handleEdit} onForward={handleForward} />
<!-- Message Input -->
<MessageInput
{replyTo}
{editMessage}
onCancelReply={() => (replyTo = null)}
onCancelEdit={() => (editMessage = null)}
/>
{:else}
<!-- Loading state -->
<div class="flex flex-1 items-center justify-center">
<div class="text-center">
<div
class="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-4"
></div>
<p class="text-muted-foreground">Lade Raum...</p>
</div>
</div>
{/if}
</div>
<!-- Room Settings Panel -->
<RoomSettingsPanel open={showRoomSettings} onClose={() => (showRoomSettings = false)} />
<!-- Search Dialog -->
<SearchDialog open={showSearch} onClose={() => (showSearch = false)} />
<!-- Active Call View -->
{#if activeCall}
<CallView call={activeCall} onHangup={handleCallHangup} />
{/if}
<!-- Incoming Call Dialog -->
{#if incomingCall && !activeCall}
<IncomingCallDialog call={incomingCall} onAnswer={handleCallAnswer} onReject={handleCallReject} />
{/if}
<!-- Forward Message Dialog -->
<ForwardMessageDialog
open={showForward}
message={forwardMessage}
onClose={() => {
showForward = false;
forwardMessage = null;
}}
/>