feat: chat E2EE MVP - rooms list + timeline + composer + sync (issues 0148+0149+0150)

Backend extends MatrixService with Start()/Stop()/ListRooms()/LoadTimeline()/
SendText()/SendMarkdown(). On login the service initialises the crypto store
(cryptohelper, Olm/Megolm via goolm build tag) and a sync loop that fans
events out through Wails events ("matrix:event", "matrix:error"). Pickle
key is 32 random bytes hex-encoded in the OS keyring alongside the access
token, so the crypto SQLite store survives restarts.

Vendors 4 fresh helpers from fn_registry/functions/infra/:
  matrix_crypto_init.go (//go:build goolm || libolm)
  matrix_sync_service.go
  matrix_message_send.go
  matrix_room_list.go
Plus the existing 3 (mas_oidc_loopback, keyring_token_store, matrix_client_init).
go-sqlite3 driver pulled explicitly via sqlite_driver.go.

Frontend rewires HomeScreen as a 3-zone AppShell (sidebar / timeline /
composer). useMatrixRooms polls + reacts to the sync stream; useMatrixTimeline
loads the last 50 events of the selected room and appends live ones. New
components: RoomList, Timeline, EventBubble, Composer. Composer supports
plain text (default) and a markdown toggle; Enter sends, Shift+Enter newline.

wails.json now passes "build:tags": "goolm" by default. Tested with
wails build -tags goolm on linux/amd64 and windows/amd64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-05-25 01:03:31 +02:00
parent f28c2b121e
commit 36a485ea26
21 changed files with 2529 additions and 93 deletions
+191 -57
View File
@@ -1,27 +1,39 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
AppShell,
Avatar,
Badge,
Box,
Burger,
Button,
Code,
Center,
Group,
Paper,
Loader,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconLogout, IconUserCircle } from "@tabler/icons-react";
import { GetSession, Logout } from "../wailsjs/go/main/MatrixService";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconLock,
IconLogout,
IconUserCircle,
} from "@tabler/icons-react";
import {
Logout,
SendMarkdown,
SendText,
Start,
Stop,
} from "../wailsjs/go/main/MatrixService";
import { EventsOn } from "../wailsjs/runtime/runtime";
import RoomList from "./components/RoomList";
import Timeline from "./components/Timeline";
import Composer from "./components/Composer";
import { useMatrixRooms } from "./hooks/useMatrixRooms";
import { useMatrixTimeline } from "./hooks/useMatrixTimeline";
interface Session {
user_id: string;
device_id: string;
homeserver_url: string;
has_token: boolean;
expires_at?: string;
}
const NAVBAR_WIDTH = 300;
export default function HomeScreen({
userID,
@@ -30,12 +42,63 @@ export default function HomeScreen({
userID: string;
onLogout: () => void;
}) {
const [session, setSession] = useState<Session | null>(null);
const [navOpen, navHandlers] = useDisclosure(true);
const [activeRoomID, setActiveRoomID] = useState<string | null>(null);
const [started, setStarted] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
const { rooms } = useMatrixRooms(started);
const { events, loading: timelineLoading, error: timelineError } = useMatrixTimeline(
activeRoomID,
50,
);
// Boot sync on mount; tear down on unmount.
useEffect(() => {
GetSession(userID).then((s) => setSession(s as Session | null));
let cancelled = false;
(async () => {
try {
await Start(userID);
if (!cancelled) setStarted(true);
} catch (e: any) {
if (!cancelled) {
const msg = String(e?.message ?? e);
setStartError(msg);
notifications.show({
title: "Sync error",
message: msg,
color: "red",
autoClose: false,
});
}
}
})();
return () => {
cancelled = true;
Stop().catch(() => {});
};
}, [userID]);
// Show transient Matrix errors as toast notifications.
useEffect(() => {
const off = EventsOn("matrix:error", (msg: string) => {
notifications.show({
title: "Matrix",
message: String(msg),
color: "orange",
autoClose: 6000,
});
});
return () => {
if (typeof off === "function") off();
};
}, []);
const activeRoom = useMemo(
() => rooms.find((r) => r.room_id === activeRoomID) || null,
[rooms, activeRoomID],
);
async function handleLogout() {
try {
await Logout(userID);
@@ -44,22 +107,43 @@ export default function HomeScreen({
}
}
const initials = userID
.replace("@", "")
.split(":")[0]
.slice(0, 2)
.toUpperCase();
async function handleSendText(body: string) {
if (!activeRoomID) return;
await SendText(activeRoomID, body);
}
async function handleSendMarkdown(md: string) {
if (!activeRoomID) return;
await SendMarkdown(activeRoomID, md);
}
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell
header={{ height: 56 }}
navbar={{
width: NAVBAR_WIDTH,
breakpoint: 0,
collapsed: { mobile: !navOpen, desktop: !navOpen },
}}
padding={0}
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group gap="xs">
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Burger
opened={navOpen}
onClick={navHandlers.toggle}
size="sm"
aria-label="Toggle rooms"
/>
<IconUserCircle size={22} />
<Text fw={600}>matrix_client_pc</Text>
<Badge size="sm" variant="light" color="violet">
v0.1.0
v0.2.0
</Badge>
<Text size="xs" c="dimmed" visibleFrom="sm">
{userID}
</Text>
</Group>
<Button
variant="subtle"
@@ -71,41 +155,91 @@ export default function HomeScreen({
</Button>
</Group>
</AppShell.Header>
<AppShell.Main>
<Box maw={720} mx="auto" mt="xl">
<Paper p="xl" radius="lg" withBorder>
<Group gap="lg" align="flex-start">
<Avatar size={64} color="violet" radius="xl">
{initials}
</Avatar>
<Stack gap={6} flex={1}>
<Title order={3}>{userID}</Title>
{session ? (
<Stack gap={4}>
<Text size="sm" c="dimmed">
Device: <Code>{session.device_id || "-"}</Code>
</Text>
<Text size="sm" c="dimmed">
Homeserver: <Code>{session.homeserver_url}</Code>
</Text>
{session.expires_at && (
<Text size="xs" c="dimmed">
Token expira: {session.expires_at}
</Text>
)}
</Stack>
) : (
<Text c="dimmed">Cargando sesion...</Text>
)}
</Stack>
</Group>
</Paper>
<Paper p="md" radius="md" withBorder mt="lg">
<AppShell.Navbar>
<RoomList
rooms={rooms}
activeRoomID={activeRoomID}
onSelect={setActiveRoomID}
/>
</AppShell.Navbar>
<AppShell.Main
style={{
display: "flex",
flexDirection: "column",
height: "calc(100vh - 56px)",
}}
>
{!started && !startError && (
<Center style={{ flex: 1 }}>
<Stack align="center" gap="xs">
<Loader />
<Text size="sm" c="dimmed">
Starting sync and crypto (up to 60s on first run)...
</Text>
</Stack>
</Center>
)}
{startError && (
<Center style={{ flex: 1 }}>
<Stack align="center" gap="xs" maw={520} ta="center">
<Title order={4} c="red">
Could not start sync
</Title>
<Text size="sm" c="dimmed">
{startError}
</Text>
</Stack>
</Center>
)}
{started && !activeRoom && (
<Center style={{ flex: 1 }}>
<Text size="sm" c="dimmed">
Login OK. Sync + rooms + timeline llegan en issue 0148.
Pick a room from the sidebar to view messages
</Text>
</Paper>
</Box>
</Center>
)}
{started && activeRoom && (
<>
<Box p="md" bg="dark.7" style={{ borderBottom: "1px solid var(--mantine-color-dark-5)" }}>
<Group gap="xs">
<Title order={5}>{activeRoom.name || activeRoom.room_id}</Title>
{activeRoom.is_encrypted && (
<Badge size="xs" color="green" variant="light" leftSection={<IconLock size={10} />}>
E2EE
</Badge>
)}
<Text size="xs" c="dimmed">
{activeRoom.member_count} member{activeRoom.member_count === 1 ? "" : "s"}
</Text>
</Group>
{activeRoom.topic && (
<Text size="xs" c="dimmed" mt={4} lineClamp={1}>
{activeRoom.topic}
</Text>
)}
</Box>
<Box style={{ flex: 1, minHeight: 0 }}>
<Timeline
events={events}
loading={timelineLoading}
error={timelineError}
selfUserID={userID}
/>
</Box>
<Composer
disabled={!activeRoomID}
onSendText={handleSendText}
onSendMarkdown={handleSendMarkdown}
/>
</>
)}
</AppShell.Main>
</AppShell>
);
+88
View File
@@ -0,0 +1,88 @@
import { useState, useRef, KeyboardEvent } from "react";
import { ActionIcon, Group, Textarea, Tooltip } from "@mantine/core";
import { IconSend, IconMarkdown } from "@tabler/icons-react";
export interface ComposerProps {
disabled?: boolean;
onSendText: (body: string) => Promise<void>;
onSendMarkdown: (md: string) => Promise<void>;
}
export default function Composer({ disabled, onSendText, onSendMarkdown }: ComposerProps) {
const [value, setValue] = useState("");
const [sending, setSending] = useState(false);
const [markdownMode, setMarkdownMode] = useState(false);
const ref = useRef<HTMLTextAreaElement | null>(null);
async function doSend() {
const body = value.trim();
if (!body || disabled || sending) return;
setSending(true);
try {
if (markdownMode) {
await onSendMarkdown(body);
} else {
await onSendText(body);
}
setValue("");
ref.current?.focus();
} catch (e) {
// surface via console; toast wiring is centralised in HomeScreen
console.error("send failed", e);
} finally {
setSending(false);
}
}
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
doSend();
}
}
return (
<Group gap="xs" align="flex-end" p="md" bg="dark.7" wrap="nowrap">
<Tooltip label={markdownMode ? "Markdown ON" : "Markdown OFF"} withArrow>
<ActionIcon
variant={markdownMode ? "filled" : "subtle"}
color="violet"
size="lg"
onClick={() => setMarkdownMode((v) => !v)}
disabled={disabled}
>
<IconMarkdown size={18} />
</ActionIcon>
</Tooltip>
<Textarea
ref={ref}
flex={1}
autosize
minRows={1}
maxRows={6}
radius="md"
placeholder={
disabled
? "Select a room to start chatting"
: markdownMode
? "Markdown · Enter to send, Shift+Enter newline"
: "Message · Enter to send, Shift+Enter newline"
}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
onKeyDown={onKeyDown}
disabled={disabled || sending}
/>
<ActionIcon
variant="filled"
color="violet"
size="lg"
onClick={doSend}
loading={sending}
disabled={disabled || !value.trim()}
>
<IconSend size={18} />
</ActionIcon>
</Group>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { Avatar, Group, Paper, Stack, Text } from "@mantine/core";
import { IconLockExclamation } from "@tabler/icons-react";
import type { MatrixEvent } from "../types";
function senderInitials(sender: string): string {
return sender.replace(/^@/, "").charAt(0).toUpperCase() || "?";
}
function senderShort(sender: string): string {
// "@local:server" -> "@local"
return sender.split(":")[0];
}
function fmtTime(ts: number): string {
if (!ts) return "";
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export interface EventBubbleProps {
event: MatrixEvent;
isSelf: boolean;
}
export default function EventBubble({ event, isSelf }: EventBubbleProps) {
const isEncrypted = event.encrypted_raw;
return (
<Group
align="flex-start"
gap="xs"
wrap="nowrap"
justify={isSelf ? "flex-end" : "flex-start"}
>
{!isSelf && (
<Avatar size={28} radius="xl" color="violet">
{senderInitials(event.sender)}
</Avatar>
)}
<Stack gap={2} maw="70%" align={isSelf ? "flex-end" : "flex-start"}>
<Group gap={6}>
<Text fz="xs" c="dimmed" fw={500}>
{senderShort(event.sender)}
</Text>
<Text fz="xs" c="dimmed">
{fmtTime(event.ts)}
</Text>
</Group>
<Paper
p="xs"
radius="md"
bg={isSelf ? "violet.9" : "dark.6"}
c={isSelf ? "white" : "gray.0"}
style={{ wordBreak: "break-word" }}
>
{isEncrypted ? (
<Group gap={6} c="dimmed">
<IconLockExclamation size={14} />
<Text fz="sm" fs="italic">
Encrypted message (no session)
</Text>
</Group>
) : (
<Text fz="sm" style={{ whiteSpace: "pre-wrap" }}>
{event.body || (
<Text component="span" c="dimmed" fs="italic">
(no body)
</Text>
)}
</Text>
)}
</Paper>
</Stack>
{isSelf && (
<Avatar size={28} radius="xl" color="violet">
{senderInitials(event.sender)}
</Avatar>
)}
</Group>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Avatar, Group, NavLink, ScrollArea, Stack, Text } from "@mantine/core";
import { IconLock, IconHash, IconUser } from "@tabler/icons-react";
import type { RoomSummary } from "../types";
function relativeTime(ts: number): string {
if (!ts) return "";
const diff = Date.now() - ts;
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d`;
return new Date(ts).toLocaleDateString();
}
function initial(room: RoomSummary): string {
const base = room.name || room.canonical_alias || room.room_id;
return (base.replace(/^[#@!]/, "").charAt(0) || "?").toUpperCase();
}
export interface RoomListProps {
rooms: RoomSummary[];
activeRoomID: string | null;
onSelect: (roomID: string) => void;
}
export default function RoomList({ rooms, activeRoomID, onSelect }: RoomListProps) {
if (!rooms.length) {
return (
<Stack p="md">
<Text size="sm" c="dimmed">
No rooms yet. Sync in progress...
</Text>
</Stack>
);
}
return (
<ScrollArea h="calc(100vh - 56px)" type="hover" offsetScrollbars>
<Stack gap={0} p={4}>
{rooms.map((room) => {
const active = room.room_id === activeRoomID;
const TypeIcon = room.is_direct ? IconUser : IconHash;
return (
<NavLink
key={room.room_id}
active={active}
variant="filled"
color="violet"
onClick={() => onSelect(room.room_id)}
leftSection={
<Avatar size={32} radius="xl" color={active ? "violet" : "gray"}>
{initial(room)}
</Avatar>
}
label={
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<TypeIcon size={12} style={{ flexShrink: 0, opacity: 0.6 }} />
<Text truncate fz="sm" fw={active ? 600 : 500}>
{room.name || room.canonical_alias || room.room_id}
</Text>
</Group>
{room.is_encrypted && (
<IconLock size={12} style={{ flexShrink: 0, opacity: 0.7 }} />
)}
</Group>
}
description={
<Group gap={6} justify="space-between" wrap="nowrap">
<Text fz="xs" c="dimmed" truncate>
{room.member_count} member{room.member_count === 1 ? "" : "s"}
</Text>
<Text fz="xs" c="dimmed">
{relativeTime(room.last_event_ts)}
</Text>
</Group>
}
/>
);
})}
</Stack>
</ScrollArea>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react";
import { Box, Center, Loader, ScrollArea, Stack, Text } from "@mantine/core";
import EventBubble from "./EventBubble";
import type { MatrixEvent } from "../types";
export interface TimelineProps {
events: MatrixEvent[];
loading: boolean;
error: string | null;
selfUserID: string;
}
export default function Timeline({ events, loading, error, selfUserID }: TimelineProps) {
const viewport = useRef<HTMLDivElement | null>(null);
// Auto-scroll to bottom when new events arrive.
useEffect(() => {
if (!viewport.current) return;
viewport.current.scrollTo({
top: viewport.current.scrollHeight,
behavior: "smooth",
});
}, [events.length]);
if (loading && events.length === 0) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (error) {
return (
<Center h="100%">
<Text c="red" size="sm">
{error}
</Text>
</Center>
);
}
if (!events.length) {
return (
<Center h="100%">
<Text size="sm" c="dimmed">
No messages yet
</Text>
</Center>
);
}
return (
<ScrollArea h="100%" viewportRef={viewport} offsetScrollbars>
<Box p="md">
<Stack gap="sm">
{events.map((ev) => (
<EventBubble key={ev.event_id} event={ev} isSelf={ev.sender === selfUserID} />
))}
</Stack>
</Box>
</ScrollArea>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ListRooms } from "../../wailsjs/go/main/MatrixService";
import { EventsOn } from "../../wailsjs/runtime/runtime";
import type { RoomSummary, SyncEventView } from "../types";
/**
* useMatrixRooms keeps a fresh list of joined rooms. It polls every 30s and
* re-fetches on every relevant matrix:event so that recently active rooms
* float to the top of the sidebar without a hard refresh.
*/
export function useMatrixRooms(enabled: boolean) {
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false);
const refresh = useCallback(async () => {
if (inFlight.current) return;
inFlight.current = true;
setLoading(true);
try {
const list = await ListRooms();
setRooms(list || []);
setError(null);
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setLoading(false);
inFlight.current = false;
}
}, []);
useEffect(() => {
if (!enabled) return;
refresh();
const interval = window.setInterval(refresh, 30_000);
const off = EventsOn("matrix:event", (ev: SyncEventView) => {
// Only refresh for events that may move a room in the list.
if (
ev?.type === "message" ||
ev?.type === "encrypted" ||
ev?.type === "membership"
) {
refresh();
}
});
return () => {
window.clearInterval(interval);
if (typeof off === "function") off();
};
}, [enabled, refresh]);
return { rooms, loading, error, refresh };
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useState } from "react";
import { LoadTimeline } from "../../wailsjs/go/main/MatrixService";
import { EventsOn } from "../../wailsjs/runtime/runtime";
import type { MatrixEvent, SyncEventView } from "../types";
/**
* useMatrixTimeline loads the last `limit` events of a room (newest at the
* bottom) and appends live events from the sync stream. Re-fetches whenever
* roomID changes.
*/
export function useMatrixTimeline(roomID: string | null, limit = 50) {
const [events, setEvents] = useState<MatrixEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!roomID) {
setEvents([]);
return;
}
setLoading(true);
try {
const list = await LoadTimeline(roomID, limit);
// Mautrix Messages with DirectionBackward returns newest-first; flip to
// oldest-first so React renders top-to-bottom chronologically.
const ordered = [...(list || [])].reverse();
setEvents(ordered);
setError(null);
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setLoading(false);
}
}, [roomID, limit]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!roomID) return;
const off = EventsOn("matrix:event", (ev: SyncEventView) => {
if (ev?.room_id !== roomID) return;
if (ev?.type !== "message" && ev?.type !== "encrypted") return;
setEvents((prev) => {
// Dedupe by event_id (sync may replay).
if (prev.some((p) => p.event_id === ev.event_id)) return prev;
const next: MatrixEvent = {
event_id: ev.event_id,
room_id: ev.room_id,
sender: ev.sender,
type: ev.type === "encrypted" ? "m.room.encrypted" : "m.room.message",
ts: ev.ts,
body: ev.body ?? "",
encrypted_raw: ev.type === "encrypted",
};
return [...prev, next];
});
});
return () => {
if (typeof off === "function") off();
};
}, [roomID]);
return { events, loading, error, refresh };
}
+19
View File
@@ -0,0 +1,19 @@
// Local convenience aliases for the auto-generated Wails models.
// We re-export the model classes/interfaces so the rest of the app imports
// from one stable path instead of the long wailsjs/go/models.ts.
import { infra, main } from "../wailsjs/go/models";
export type RoomSummary = infra.RoomSummary;
export type MatrixEvent = main.MatrixEvent;
// SyncEventView is what the Go runtime emits through "matrix:event".
// Not part of the Wails-generated module (it never appears as a return type).
export interface SyncEventView {
type: string;
room_id: string;
event_id: string;
sender: string;
ts: number;
body?: string;
}