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:
+191
-57
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user