+ } else if (!connState?.connected || !clientState) {
+ const msg = connState?.connected ?
+ "Waiting for client state..." : "Connecting to backend..."
+ return
+
+ {msg}
+
+ } else if (!clientState.is_logged_in) {
+ return
+ } else if (!clientState.is_verified) {
+ return
+ } else {
+ return
+ }
+}
+
+export default App
diff --git a/web/src/MainScreen.css b/web/src/MainScreen.css
new file mode 100644
index 0000000..3fe4e5b
--- /dev/null
+++ b/web/src/MainScreen.css
@@ -0,0 +1,7 @@
+main.matrix-main {
+ position: fixed;
+ inset: 0;
+
+ display: grid;
+ grid-template: "roomlist roomview" 1fr / 300px 1fr;
+}
diff --git a/web/src/MainScreen.tsx b/web/src/MainScreen.tsx
new file mode 100644
index 0000000..f8d3643
--- /dev/null
+++ b/web/src/MainScreen.tsx
@@ -0,0 +1,36 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { useState } from "react"
+import type Client from "./client.ts"
+import type { RoomID } from "./hitypes.ts"
+import RoomList from "./RoomList.tsx"
+import RoomView from "./RoomView.tsx"
+import "./MainScreen.css"
+
+export interface MainScreenProps {
+ client: Client
+}
+
+const MainScreen = ({ client }: MainScreenProps) => {
+ const [activeRoomID, setActiveRoomID] = useState(null)
+ const activeRoom = activeRoomID && client.store.rooms.get(activeRoomID)
+ return
+
+ {activeRoom && }
+
+}
+
+export default MainScreen
diff --git a/web/src/RoomList.css b/web/src/RoomList.css
new file mode 100644
index 0000000..19fb4e9
--- /dev/null
+++ b/web/src/RoomList.css
@@ -0,0 +1,50 @@
+div.room-list {
+ grid-area: roomlist;
+ overflow-y: auto;
+
+ div.room-entry {
+ width: 100%;
+ display: flex;
+ gap: 4px;
+ border-radius: 4px;
+ user-select: none;
+ cursor: pointer;
+
+ &:hover {
+ background-color: #EEE;
+ }
+
+ > div.room-entry-left {
+ height: 48px;
+ width: 48px;
+
+ > img.room-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ padding: 4px;
+ }
+ }
+
+ > div.room-entry-right {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ justify-content: space-around;
+
+ > div.room-name {
+ font-weight: bold;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ > div.message-preview {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+ }
+ }
+}
diff --git a/web/src/RoomList.tsx b/web/src/RoomList.tsx
new file mode 100644
index 0000000..b15addb
--- /dev/null
+++ b/web/src/RoomList.tsx
@@ -0,0 +1,101 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React, { useMemo } from "react"
+import Client from "./client.ts"
+import { DBEvent, RoomID } from "./hitypes.ts"
+import { useNonNullEventAsState } from "./eventdispatcher.ts"
+import { RoomListEntry } from "./statestore.ts"
+import "./RoomList.css"
+
+export interface RoomListProps {
+ client: Client
+ setActiveRoom: (room_id: RoomID) => void
+}
+
+const RoomList = ({ client, setActiveRoom }: RoomListProps) => {
+ const roomList = useNonNullEventAsState(client.store.roomList)
+ const clickRoom = useMemo(() => (evt: React.MouseEvent) => {
+ const roomID = evt.currentTarget.getAttribute("data-room-id")
+ if (roomID) {
+ setActiveRoom(roomID)
+ } else {
+ console.warn("No room ID :(", evt.currentTarget)
+ }
+ }, [setActiveRoom])
+
+ return
+}
+
+export default RoomList
diff --git a/web/src/RoomView.css b/web/src/RoomView.css
new file mode 100644
index 0000000..445b64a
--- /dev/null
+++ b/web/src/RoomView.css
@@ -0,0 +1,3 @@
+div.room-view {
+ overflow-y: scroll;
+}
diff --git a/web/src/RoomView.tsx b/web/src/RoomView.tsx
new file mode 100644
index 0000000..fe8ff44
--- /dev/null
+++ b/web/src/RoomView.tsx
@@ -0,0 +1,38 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import Client from "./client.ts"
+import { RoomStateStore } from "./statestore.ts"
+import { useNonNullEventAsState } from "./eventdispatcher.ts"
+import "./RoomView.css"
+import TimelineEvent from "./TimelineEvent.tsx"
+
+export interface RoomViewProps {
+ client: Client
+ room: RoomStateStore
+}
+
+const RoomView = ({ client, room }: RoomViewProps) => {
+ const roomMeta = useNonNullEventAsState(room.meta)
+ const timeline = useNonNullEventAsState(room.timeline)
+ return
+}
+
+export default RoomView
diff --git a/web/src/TimelineEvent.css b/web/src/TimelineEvent.css
new file mode 100644
index 0000000..e69de29
diff --git a/web/src/TimelineEvent.tsx b/web/src/TimelineEvent.tsx
new file mode 100644
index 0000000..91adca4
--- /dev/null
+++ b/web/src/TimelineEvent.tsx
@@ -0,0 +1,39 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { RoomViewProps } from "./RoomView.tsx"
+import "./TimelineEvent.css"
+
+export interface TimelineEventProps extends RoomViewProps {
+ eventRowID: number
+}
+
+const TimelineEvent = ({ room, eventRowID }: TimelineEventProps) => {
+ const evt = room.eventsByRowID.get(eventRowID)
+ if (!evt) {
+ return null
+ }
+ // @ts-expect-error TODO add content types
+ const body = (evt.decrypted ?? evt.content).body
+ return
+}
+
+export default TimelineEvent
diff --git a/web/src/client.ts b/web/src/client.ts
new file mode 100644
index 0000000..0f1525e
--- /dev/null
+++ b/web/src/client.ts
@@ -0,0 +1,81 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import type {
+ ClientWellKnown, DBEvent, EventID, EventRowID, EventType, RoomID, TimelineRowID, UserID,
+} from "./hitypes.ts"
+import { ClientState, RPCEvent } from "./hievents.ts"
+import { RPCClient } from "./rpc.ts"
+import { CachedEventDispatcher } from "./eventdispatcher.ts"
+import { StateStore } from "./statestore.ts"
+
+export default class Client {
+ readonly state = new CachedEventDispatcher()
+ readonly store = new StateStore()
+
+ constructor(readonly rpc: RPCClient) {
+ this.rpc.event.listen(this.#handleEvent)
+ }
+
+ #handleEvent = (ev: RPCEvent) => {
+ if (ev.command === "client_state") {
+ this.state.emit(ev.data)
+ } else if (ev.command === "sync_complete") {
+ this.store.applySync(ev.data)
+ } else if (ev.command === "events_decrypted") {
+ this.store.applyDecrypted(ev.data)
+ }
+ }
+
+ request(command: string, data: Req): Promise {
+ return this.rpc.request(command, data)
+ }
+
+ sendMessage(room_id: RoomID, event_type: EventType, content: Record): Promise {
+ return this.request("send_message", { room_id, event_type, content })
+ }
+
+ ensureGroupSessionShared(room_id: RoomID): Promise {
+ return this.request("ensure_group_session_shared", { room_id })
+ }
+
+ getEvent(room_id: RoomID, event_id: EventID): Promise {
+ return this.request("get_event", { room_id, event_id })
+ }
+
+ getEventsByRowIDs(row_ids: EventRowID[]): Promise {
+ return this.request("get_events_by_row_ids", { row_ids })
+ }
+
+ paginate(room_id: RoomID, max_timeline_id: TimelineRowID, limit: number): Promise {
+ return this.request("paginate", { room_id, max_timeline_id, limit })
+ }
+
+ paginateServer(room_id: RoomID, limit: number): Promise {
+ return this.request("paginate_server", { room_id, limit })
+ }
+
+ discoverHomeserver(user_id: UserID): Promise {
+ return this.request("discover_homeserver", { user_id })
+ }
+
+ login(homeserver_url: string, username: string, password: string): Promise {
+ return this.request("login", { homeserver_url, username, password })
+ }
+
+ verify(recovery_key: string): Promise {
+ return this.request("verify", { recovery_key })
+ }
+}
diff --git a/web/src/eventdispatcher.ts b/web/src/eventdispatcher.ts
new file mode 100644
index 0000000..9daae59
--- /dev/null
+++ b/web/src/eventdispatcher.ts
@@ -0,0 +1,79 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { useEffect, useState } from "react"
+
+export function useEventAsState(dispatcher?: EventDispatcher): T | null {
+ const [state, setState] = useState(null)
+ useEffect(() => dispatcher && dispatcher.listen(setState), [dispatcher])
+ return state
+}
+
+export function useNonNullEventAsState(dispatcher: NonNullCachedEventDispatcher): T {
+ const [state, setState] = useState(dispatcher.current)
+ useEffect(() => dispatcher.listen(setState), [dispatcher])
+ return state
+}
+
+export class EventDispatcher {
+ #listeners: ((data: T) => void)[] = []
+
+ listen(listener: (data: T) => void): () => void {
+ this.#listeners.push(listener)
+ return () => {
+ const idx = this.#listeners.indexOf(listener)
+ if (idx >= 0) {
+ this.#listeners.splice(idx, 1)
+ }
+ }
+ }
+
+ emit(data: T) {
+ for (const listener of this.#listeners) {
+ listener(data)
+ }
+ }
+}
+
+export class CachedEventDispatcher extends EventDispatcher {
+ current: T | null
+
+ constructor(cache?: T | null) {
+ super()
+ this.current = cache ?? null
+ }
+
+ emit(data: T) {
+ this.current = data
+ super.emit(data)
+ }
+
+ listen(listener: (data: T) => void): () => void {
+ const unlisten = super.listen(listener)
+ if (this.current !== null) {
+ listener(this.current)
+ }
+ return unlisten
+ }
+}
+
+export class NonNullCachedEventDispatcher extends CachedEventDispatcher {
+ current: T
+
+ constructor(cache: T) {
+ super(cache)
+ this.current = cache
+ }
+}
diff --git a/web/src/hievents.ts b/web/src/hievents.ts
new file mode 100644
index 0000000..cbd6ea5
--- /dev/null
+++ b/web/src/hievents.ts
@@ -0,0 +1,96 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import {
+ DBEvent,
+ DBRoom,
+ DeviceID,
+ EventRowID,
+ RoomID,
+ TimelineRowTuple,
+ UserID,
+} from "./hitypes.ts"
+
+export interface RPCCommand {
+ command: string
+ request_id: number
+ data: T
+}
+
+export interface TypingEventData {
+ room_id: RoomID
+ user_ids: UserID[]
+}
+
+export interface TypingEvent extends RPCCommand {
+ command: "typing"
+}
+
+export interface SendCompleteData {
+ event: DBEvent
+ error: string | null
+}
+
+export interface SendCompleteEvent extends RPCCommand {
+ command: "send_complete"
+}
+
+export interface EventsDecryptedData {
+ room_id: RoomID
+ preview_event_rowid?: EventRowID
+ events: DBEvent[]
+}
+
+export interface EventsDecryptedEvent extends RPCCommand {
+ command: "events_decrypted"
+}
+
+export interface SyncRoom {
+ meta: DBRoom
+ timeline: TimelineRowTuple[]
+ events: DBEvent[]
+ reset: boolean
+}
+
+export interface SyncCompleteData {
+ rooms: Record
+}
+
+export interface SyncCompleteEvent extends RPCCommand {
+ command: "sync_complete"
+}
+
+
+export type ClientState = {
+ is_logged_in: false
+ is_verified: false
+} | {
+ is_logged_in: true
+ is_verified: boolean
+ user_id: UserID
+ device_id: DeviceID
+ homeserver_url: string
+}
+
+export interface ClientStateEvent extends RPCCommand {
+ command: "client_state"
+}
+
+export type RPCEvent =
+ ClientStateEvent |
+ TypingEvent |
+ SendCompleteEvent |
+ EventsDecryptedEvent |
+ SyncCompleteEvent
diff --git a/web/src/hitypes.ts b/web/src/hitypes.ts
new file mode 100644
index 0000000..190aebe
--- /dev/null
+++ b/web/src/hitypes.ts
@@ -0,0 +1,125 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+export type EventRowID = number
+export type TimelineRowID = number
+export type RoomID = string
+export type EventID = string
+export type UserID = string
+export type DeviceID = string
+export type EventType = string
+export type ContentURI = string
+export type RoomAlias = string
+export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11"
+export type RoomType = "" | "m.space"
+export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
+
+export interface TimelineRowTuple {
+ timeline_rowid: TimelineRowID
+ event_rowid: EventRowID
+}
+
+export enum RoomNameQuality {
+ Nil = 0,
+ Participants,
+ CanonicalAlias,
+ Explicit,
+}
+
+export interface RoomPredecessor {
+ room_id: RoomID
+ event_id: EventID
+}
+
+export interface CreateEventContent {
+ type: RoomType
+ "m.federate": boolean
+ room_version: RoomVersion
+ predecessor: RoomPredecessor
+}
+
+export interface LazyLoadSummary {
+ heroes?: UserID[]
+ "m.joined_member_count"?: number
+ "m.invited_member_count"?: number
+}
+
+export interface EncryptionEventContent {
+ algorithm: string
+ rotation_period_ms?: number
+ rotation_period_msgs?: number
+}
+
+export interface DBRoom {
+ room_id: RoomID
+ creation_content: CreateEventContent
+
+ name?: string
+ name_quality: RoomNameQuality
+ avatar?: ContentURI
+ topic?: string
+ canonical_alias?: RoomAlias
+ lazy_load_summary?: LazyLoadSummary
+
+ encryption_event?: EncryptionEventContent
+ has_member_list: boolean
+
+ preview_event_rowid: EventRowID
+ sorting_timestamp: number
+
+ prev_batch: string
+}
+
+export interface DBEvent {
+ rowid: EventRowID
+ timeline_rowid: TimelineRowID
+
+ room_id: RoomID
+ event_id: EventID
+ sender: UserID
+ type: EventType
+ state_key?: string
+ timestamp: number
+
+ content: unknown
+ decrypted?: unknown
+ decrypted_type?: EventType
+ unsigned: EventUnsigned
+
+ transaction_id?: string
+
+ redacted_by?: EventID
+ relates_to?: EventID
+ relation_type?: RelationType
+
+ decryption_error?: string
+
+ reactions?: Record
+ last_edit_rowid?: EventRowID
+}
+
+export interface EventUnsigned {
+ prev_content?: unknown
+ prev_sender?: UserID
+}
+
+export interface ClientWellKnown {
+ "m.homeserver": {
+ base_url: string
+ },
+ "m.identity_server": {
+ base_url: string
+ }
+}
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 0000000..8d46438
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,32 @@
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ margin: 0;
+ padding: 0;
+ background-color: #EEE;
+}
+
+#root {
+ display: flex;
+ justify-content: center;
+}
+
+main {
+ background-color: white;
+}
+
+pre, code {
+ font-family: "Fira Code", monospace;
+}
+
+button {
+ cursor: pointer;
+ font-size: 1em;
+}
+
+:root {
+ --primary-color: #00c853;
+ --primary-color-light: #92ffc0;
+ --primary-color-dark: #00b24a;
+ --error-color: red;
+ --error-color-light: #ff6666;
+}
diff --git a/web/src/login/LoginScreen.css b/web/src/login/LoginScreen.css
new file mode 100644
index 0000000..7f67ffd
--- /dev/null
+++ b/web/src/login/LoginScreen.css
@@ -0,0 +1,66 @@
+main.matrix-login {
+ max-width: 30rem;
+ width: 100%;
+ padding: 3rem 6rem;
+
+ box-shadow: 0 0 1rem rgba(0, 0, 0, 0.25);
+ margin: 2rem;
+
+ @media (width < 800px) {
+ padding: 2rem 4rem;
+ }
+
+ @media (width < 500px) {
+ padding: 1rem;
+ box-shadow: none;
+ margin: 0 !important;
+ }
+
+ h1 {
+ margin: 0 0 2rem;
+ text-align: center;
+ }
+
+ button, input {
+ margin-top: .5rem;
+ padding: 1rem;
+ font-size: 1rem;
+ width: 100%;
+ display: block;
+ border-radius: .25rem;
+ box-sizing: border-box;
+ }
+
+ input {
+ border: 1px solid var(--primary-color);
+
+ &:hover {
+ outline: 1px solid var(--primary-color);
+ }
+
+ &:focus {
+ outline: 3px solid var(--primary-color);
+ }
+ }
+
+ form {
+ margin: 2rem 0;
+ }
+
+ button {
+ background-color: var(--primary-color);
+ color: white;
+ font-weight: bold;
+ border: none;
+
+ &:hover, &:focus {
+ background-color: var(--primary-color-dark);
+ }
+ }
+
+ div.error {
+ border: 2px solid var(--error-color);
+ border-radius: .25rem;
+ padding: 1rem;
+ }
+}
diff --git a/web/src/login/LoginScreen.tsx b/web/src/login/LoginScreen.tsx
new file mode 100644
index 0000000..a85ad24
--- /dev/null
+++ b/web/src/login/LoginScreen.tsx
@@ -0,0 +1,87 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React, { useCallback, useEffect, useState } from "react"
+import type Client from "../client.ts"
+import "./LoginScreen.css"
+import { ClientState } from "../hievents.ts"
+
+export interface LoginScreenProps {
+ client: Client
+ clientState: ClientState
+}
+
+export const LoginScreen = ({ client }: LoginScreenProps) => {
+ const [username, setUsername] = useState("")
+ const [password, setPassword] = useState("")
+ const [homeserverURL, setHomeserverURL] = useState("")
+ const [error, setError] = useState("")
+
+ const login = useCallback((evt: React.FormEvent) => {
+ evt.preventDefault()
+ client.login(homeserverURL, username, password).then(
+ () => {},
+ err => setError(err.toString()),
+ )
+ }, [homeserverURL, username, password, client])
+
+ const resolveHomeserver = useCallback(() => {
+ client.discoverHomeserver(username).then(
+ resp => setHomeserverURL(resp["m.homeserver"].base_url),
+ err => setError(`Failed to resolve homeserver: ${err}`),
+ )
+ }, [client, username])
+
+ useEffect(() => {
+ if (!username.startsWith("@") || !username.includes(":") || !username.includes(".")) {
+ return
+ }
+ const timeout = setTimeout(resolveHomeserver, 500)
+ return () => {
+ clearTimeout(timeout)
+ }
+ }, [username, resolveHomeserver])
+
+ return
+
gomuks web
+
+ {error &&
+ {error}
+
}
+
+}
diff --git a/web/src/login/VerificationScreen.tsx b/web/src/login/VerificationScreen.tsx
new file mode 100644
index 0000000..2333e0f
--- /dev/null
+++ b/web/src/login/VerificationScreen.tsx
@@ -0,0 +1,52 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React, { useCallback, useState } from "react"
+import "./LoginScreen.css"
+import { LoginScreenProps } from "./LoginScreen.tsx"
+
+export const VerificationScreen = ({ client, clientState }: LoginScreenProps) => {
+ if (!clientState.is_logged_in) {
+ throw new Error("Invalid state")
+ }
+ const [recoveryKey, setRecoveryKey] = useState("")
+ const [error, setError] = useState("")
+
+ const verify = useCallback((evt: React.FormEvent) => {
+ evt.preventDefault()
+ client.verify(recoveryKey).then(
+ () => {},
+ err => setError(err.toString()),
+ )
+ }, [recoveryKey, client])
+
+ return
+
gomuks web
+
+ {error &&
+ {error}
+
}
+
+}
diff --git a/web/src/login/index.ts b/web/src/login/index.ts
new file mode 100644
index 0000000..18e4e84
--- /dev/null
+++ b/web/src/login/index.ts
@@ -0,0 +1,2 @@
+export { LoginScreen } from "./LoginScreen.tsx"
+export { VerificationScreen } from "./VerificationScreen.tsx"
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..173a9d9
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,25 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { StrictMode } from "react"
+import { createRoot } from "react-dom/client"
+import App from "./App.tsx"
+import "./index.css"
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+)
diff --git a/web/src/rpc.ts b/web/src/rpc.ts
new file mode 100644
index 0000000..95ec284
--- /dev/null
+++ b/web/src/rpc.ts
@@ -0,0 +1,39 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { RPCEvent } from "./hievents.ts"
+import { EventDispatcher } from "./eventdispatcher.ts"
+
+export class CancellablePromise extends Promise {
+ constructor(
+ executor: (resolve: (value: T) => void, reject: (reason?: Error) => void) => void,
+ readonly cancel: (reason: string) => void,
+ ) {
+ super(executor)
+ }
+}
+
+export interface RPCClient {
+ connect: EventDispatcher
+ event: EventDispatcher
+ start(): void
+ stop(): void
+ request(command: string, data: Req): CancellablePromise
+}
+
+export interface ConnectionEvent {
+ connected: boolean
+ error: Error | null
+}
diff --git a/web/src/statestore.ts b/web/src/statestore.ts
new file mode 100644
index 0000000..62553e5
--- /dev/null
+++ b/web/src/statestore.ts
@@ -0,0 +1,196 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import type {
+ ContentURI,
+ DBEvent,
+ DBRoom,
+ EventID,
+ EventRowID,
+ LazyLoadSummary,
+ RoomID,
+ TimelineRowTuple,
+} from "./hitypes.ts"
+import type { EventsDecryptedData, SyncCompleteData, SyncRoom } from "./hievents.ts"
+import { NonNullCachedEventDispatcher } from "./eventdispatcher.ts"
+
+function arraysAreEqual(arr1?: T[], arr2?: T[]): boolean {
+ if (!arr1 || !arr2) {
+ return !arr1 && !arr2
+ }
+ if (arr1.length !== arr2.length) {
+ return false
+ }
+ for (let i = 0; i < arr1.length; i++) {
+ if (arr1[i] !== arr2[i]) {
+ return false
+ }
+ }
+ return true
+}
+
+function llSummaryIsEqual(ll1?: LazyLoadSummary, ll2?: LazyLoadSummary): boolean {
+ return ll1?.["m.joined_member_count"] === ll2?.["m.joined_member_count"] &&
+ ll1?.["m.invited_member_count"] === ll2?.["m.invited_member_count"] &&
+ arraysAreEqual(ll1?.heroes, ll2?.heroes)
+}
+
+function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
+ return meta1.name === meta2.name &&
+ meta1.avatar === meta2.avatar &&
+ meta1.topic === meta2.topic &&
+ meta1.canonical_alias === meta2.canonical_alias &&
+ llSummaryIsEqual(meta1.lazy_load_summary, meta2.lazy_load_summary) &&
+ meta1.encryption_event?.algorithm === meta2.encryption_event?.algorithm &&
+ meta1.has_member_list === meta2.has_member_list
+}
+
+export class RoomStateStore {
+ readonly meta: NonNullCachedEventDispatcher
+ readonly timeline = new NonNullCachedEventDispatcher([])
+ readonly eventsByRowID: Map = new Map()
+ readonly eventsByID: Map = new Map()
+
+ constructor(meta: DBRoom) {
+ this.meta = new NonNullCachedEventDispatcher(meta)
+ }
+
+ applySync(sync: SyncRoom) {
+ if (visibleMetaIsEqual(this.meta.current, sync.meta)) {
+ this.meta.current = sync.meta
+ } else {
+ this.meta.emit(sync.meta)
+ }
+ for (const evt of sync.events) {
+ this.eventsByRowID.set(evt.rowid, evt)
+ this.eventsByID.set(evt.event_id, evt)
+ }
+ if (sync.reset) {
+ this.timeline.emit(sync.timeline)
+ } else {
+ this.timeline.emit([...this.timeline.current, ...sync.timeline])
+ }
+ }
+
+ applyDecrypted(decrypted: EventsDecryptedData) {
+ let timelineChanged = false
+ for (const evt of decrypted.events) {
+ timelineChanged = timelineChanged || !!this.timeline.current.find(rt => rt.event_rowid === evt.rowid)
+ this.eventsByRowID.set(evt.rowid, evt)
+ this.eventsByID.set(evt.event_id, evt)
+ }
+ if (timelineChanged) {
+ this.timeline.emit([...this.timeline.current])
+ }
+ if (decrypted.preview_event_rowid) {
+ this.meta.current.preview_event_rowid = decrypted.preview_event_rowid
+ }
+ }
+}
+
+export interface RoomListEntry {
+ room_id: RoomID
+ sorting_timestamp: number
+ preview_event?: DBEvent
+ name: string
+ avatar?: ContentURI
+}
+
+export class StateStore {
+ readonly rooms: Map = new Map()
+ readonly roomList = new NonNullCachedEventDispatcher([])
+
+ #roomListEntryChanged(entry: SyncRoom, oldEntry: RoomStateStore): boolean {
+ return entry.meta.sorting_timestamp !== oldEntry.meta.current.sorting_timestamp ||
+ entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid ||
+ entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1
+ }
+
+ #makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry {
+ if (!room) {
+ room = this.rooms.get(entry.meta.room_id)
+ }
+ return {
+ room_id: entry.meta.room_id,
+ sorting_timestamp: entry.meta.sorting_timestamp,
+ preview_event: room?.eventsByRowID.get(entry.meta.preview_event_rowid),
+ name: entry.meta.name ?? "Unnamed room",
+ avatar: entry.meta.avatar,
+ }
+ }
+
+ applySync(sync: SyncCompleteData) {
+ const resyncRoomList = this.roomList.current.length === 0
+ const changedRoomListEntries = new Map()
+ for (const [roomID, data] of Object.entries(sync.rooms)) {
+ let isNewRoom = false
+ let room = this.rooms.get(roomID)
+ if (!room) {
+ room = new RoomStateStore(data.meta)
+ this.rooms.set(roomID, room)
+ isNewRoom = true
+ }
+ const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room))
+ room.applySync(data)
+ if (roomListEntryChanged) {
+ changedRoomListEntries.set(roomID, this.#makeRoomListEntry(data, room))
+ }
+ }
+
+ let updatedRoomList: RoomListEntry[] | undefined
+ if (resyncRoomList) {
+ updatedRoomList = Object.values(sync.rooms).map(entry => this.#makeRoomListEntry(entry))
+ updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp)
+ } else if (changedRoomListEntries.size > 0) {
+ updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id))
+ for (const entry of changedRoomListEntries.values()) {
+ if (updatedRoomList.length === 0 || entry.sorting_timestamp >=
+ updatedRoomList[updatedRoomList.length - 1].sorting_timestamp) {
+ updatedRoomList.push(entry)
+ } else if (entry.sorting_timestamp <= 0 ||
+ entry.sorting_timestamp < updatedRoomList[0]?.sorting_timestamp) {
+ updatedRoomList.unshift(entry)
+ } else {
+ const indexToPushAt = updatedRoomList.findLastIndex(val =>
+ val.sorting_timestamp <= entry.sorting_timestamp)
+ updatedRoomList.splice(indexToPushAt + 1, 0, entry)
+ }
+ }
+ }
+ if (updatedRoomList) {
+ this.roomList.emit(updatedRoomList)
+ }
+ }
+
+ applyDecrypted(decrypted: EventsDecryptedData) {
+ const room = this.rooms.get(decrypted.room_id)
+ if (!room) {
+ // TODO log or something?
+ return
+ }
+ room.applyDecrypted(decrypted)
+ if (decrypted.preview_event_rowid) {
+ const idx = this.roomList.current.findIndex(entry => entry.room_id === decrypted.room_id)
+ if (idx !== -1) {
+ const updatedRoomList = [...this.roomList.current]
+ updatedRoomList[idx] = {
+ ...updatedRoomList[idx],
+ preview_event: room.eventsByRowID.get(decrypted.preview_event_rowid),
+ }
+ this.roomList.emit(updatedRoomList)
+ }
+ }
+ }
+}
diff --git a/web/src/wsclient.ts b/web/src/wsclient.ts
new file mode 100644
index 0000000..279560d
--- /dev/null
+++ b/web/src/wsclient.ts
@@ -0,0 +1,147 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { RPCCommand, RPCEvent } from "./hievents.ts"
+import { CachedEventDispatcher, EventDispatcher } from "./eventdispatcher.ts"
+import { CancellablePromise, ConnectionEvent, RPCClient } from "./rpc.ts"
+
+export class ErrorResponse extends Error {
+ constructor(public data: unknown) {
+ super(`${data}`)
+ }
+}
+
+export default class WSClient implements RPCClient {
+ #conn: WebSocket | null = null
+ readonly connect: CachedEventDispatcher = new CachedEventDispatcher()
+ readonly event: EventDispatcher = new EventDispatcher()
+ readonly #pendingRequests: Map void,
+ reject: (err: Error) => void
+ }> = new Map()
+ #nextRequestID: number = 1
+
+ constructor(readonly addr: string) {
+
+ }
+
+ start() {
+ try {
+ console.info("Connecting to websocket", this.addr)
+ this.#conn = new WebSocket(this.addr)
+ this.#conn.onmessage = this.#onMessage
+ this.#conn.onopen = this.#onOpen
+ this.#conn.onerror = this.#onError
+ this.#conn.onclose = this.#onClose
+ } catch (err) {
+ this.#dispatchConnectionStatus(false, err as Error)
+ }
+ }
+
+ stop() {
+ this.#conn?.close(1000, "Client closed")
+ }
+
+ #cancelRequest(request_id: number, reason: string) {
+ if (!this.#pendingRequests.has(request_id)) {
+ console.debug("Tried to cancel unknown request", request_id)
+ return
+ }
+ this.request("cancel", { request_id, reason }).then(
+ () => console.debug("Cancelled request", request_id, "for", reason),
+ err => console.debug("Failed to cancel request", request_id, "for", reason, err),
+ )
+ }
+
+ request(command: string, data: Req): CancellablePromise {
+ if (!this.#conn) {
+ return new CancellablePromise((_resolve, reject) => {
+ reject(new Error("Websocket not connected"))
+ }, () => {
+ })
+ }
+ const request_id = this.#nextRequestID++
+ return new CancellablePromise((resolve, reject) => {
+ if (!this.#conn) {
+ reject(new Error("Websocket not connected"))
+ return
+ }
+ this.#pendingRequests.set(request_id, { resolve: resolve as ((value: unknown) => void), reject })
+ this.#conn.send(JSON.stringify({
+ command,
+ request_id,
+ data,
+ }))
+ }, this.#cancelRequest.bind(this, request_id))
+ }
+
+ #onMessage = (ev: MessageEvent) => {
+ let parsed: RPCCommand
+ try {
+ parsed = JSON.parse(ev.data)
+ if (!parsed.command) {
+ throw new Error("Missing 'command' field in JSON message")
+ }
+ } catch (err) {
+ console.error("Malformed JSON in websocket:", err)
+ console.error("Message:", ev.data)
+ this.#conn?.close(1003, "Malformed JSON")
+ return
+ }
+ if (parsed.command === "response" || parsed.command === "error") {
+ const target = this.#pendingRequests.get(parsed.request_id)
+ if (!target) {
+ console.error("Received response for unknown request:", parsed)
+ return
+ }
+ this.#pendingRequests.delete(parsed.request_id)
+ if (parsed.command === "response") {
+ target.resolve(parsed.data)
+ } else {
+ target.reject(new ErrorResponse(parsed.data))
+ }
+ } else {
+ this.event.emit(parsed as RPCEvent)
+ }
+ }
+
+ #dispatchConnectionStatus(connected: boolean, error: Error | null) {
+ this.connect.emit({ connected, error })
+ }
+
+ #onOpen = () => {
+ console.info("Websocket opened")
+ this.#dispatchConnectionStatus(true, null)
+ }
+
+ #clearPending = () => {
+ for (const { reject } of this.#pendingRequests.values()) {
+ reject(new Error("Websocket closed"))
+ }
+ this.#pendingRequests.clear()
+ }
+
+ #onError = (ev: Event) => {
+ console.error("Websocket error:", ev)
+ this.#dispatchConnectionStatus(false, new Error("Websocket error"))
+ this.#clearPending()
+ }
+
+ #onClose = (ev: CloseEvent) => {
+ console.warn("Websocket closed:", ev)
+ this.#dispatchConnectionStatus(false, new Error(`Websocket closed: ${ev.code} ${ev.reason}`))
+ this.#clearPending()
+ }
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..e618c22
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "useDefineForClassFields": true,
+ "lib": [
+ "ES2023",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": [
+ "src", "vite.config.ts"
+ ]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..941d643
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,17 @@
+import {defineConfig} from "vite"
+import react from "@vitejs/plugin-react-swc"
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ "/_gomuks/websocket": {
+ target: "http://localhost:29325",
+ ws: true,
+ },
+ "/_gomuks": {
+ target: "http://localhost:29325",
+ }
+ },
+ },
+})
diff --git a/websocket.go b/websocket.go
new file mode 100644
index 0000000..38e7e1e
--- /dev/null
+++ b/websocket.go
@@ -0,0 +1,267 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "runtime/debug"
+ "sync"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/rs/zerolog"
+
+ "maunium.net/go/mautrix/hicli"
+ "maunium.net/go/mautrix/hicli/database"
+ "maunium.net/go/mautrix/id"
+)
+
+func writeCmd(ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommand) error {
+ writer, err := conn.Writer(ctx, websocket.MessageText)
+ if err != nil {
+ return err
+ }
+ err = json.NewEncoder(writer).Encode(&cmd)
+ if err != nil {
+ return err
+ }
+ return writer.Close()
+}
+
+const StatusEventsStuck = 4001
+
+func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
+ var conn *websocket.Conn
+ log := zerolog.Ctx(r.Context())
+ recoverPanic := func(context string) bool {
+ err := recover()
+ if err != nil {
+ logEvt := log.Error().
+ Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
+ Str("goroutine", context)
+ if realErr, ok := err.(error); ok {
+ logEvt = logEvt.Err(realErr)
+ } else {
+ logEvt = logEvt.Any(zerolog.ErrorFieldName, err)
+ }
+ logEvt.Msg("Panic in websocket handler")
+ return true
+ }
+ return false
+ }
+ defer recoverPanic("read loop")
+
+ conn, acceptErr := websocket.Accept(w, r, &websocket.AcceptOptions{
+ OriginPatterns: []string{"localhost:*"},
+ })
+ if acceptErr != nil {
+ log.Warn().Err(acceptErr).Msg("Failed to accept websocket connection")
+ return
+ }
+ log.Info().Msg("Accepted new websocket connection")
+ conn.SetReadLimit(128 * 1024)
+ ctx, cancel := context.WithCancel(context.Background())
+ ctx = log.WithContext(ctx)
+ unsubscribe := func() {}
+ evts := make(chan *hicli.JSONCommand, 32)
+ forceClose := func() {
+ cancel()
+ unsubscribe()
+ _ = conn.CloseNow()
+ close(evts)
+ }
+ var closeOnce sync.Once
+ defer closeOnce.Do(forceClose)
+ closeManually := func(statusCode websocket.StatusCode, reason string) {
+ log.Debug().Stringer("status_code", statusCode).Str("reason", reason).Msg("Closing connection manually")
+ _ = conn.Close(statusCode, reason)
+ closeOnce.Do(forceClose)
+ }
+ unsubscribe = gmx.SubscribeEvents(closeManually, func(evt *hicli.JSONCommand) {
+ if ctx.Err() != nil {
+ return
+ }
+ select {
+ case evts <- evt:
+ default:
+ log.Warn().Msg("Event queue full, closing connection")
+ cancel()
+ go func() {
+ defer recoverPanic("closing connection after error in event handler")
+ _ = conn.Close(StatusEventsStuck, "Event queue full")
+ closeOnce.Do(forceClose)
+ }()
+ }
+ })
+
+ go func() {
+ defer recoverPanic("event loop")
+ defer closeOnce.Do(forceClose)
+ ctxDone := ctx.Done()
+ for {
+ select {
+ case cmd := <-evts:
+ err := writeCmd(ctx, conn, cmd)
+ if err != nil {
+ log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write outgoing event")
+ return
+ } else {
+ log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent outgoing event")
+ }
+ case <-ctxDone:
+ return
+ }
+ }
+ }()
+ submitCmd := func(cmd *hicli.JSONCommand) {
+ defer func() {
+ if recoverPanic("command handler") {
+ _ = conn.Close(websocket.StatusInternalError, "Command handler panicked")
+ closeOnce.Do(forceClose)
+ }
+ }()
+ log.Trace().
+ Int64("req_id", cmd.RequestID).
+ Str("command", cmd.Command).
+ RawJSON("data", cmd.Data).
+ Msg("Received command")
+ resp := gmx.Client.SubmitJSONCommand(ctx, cmd)
+ if ctx.Err() != nil {
+ return
+ }
+ err := writeCmd(ctx, conn, resp)
+ if err != nil && ctx.Err() == nil {
+ log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write response")
+ closeOnce.Do(forceClose)
+ } else {
+ log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command")
+ }
+ }
+ initData, initErr := json.Marshal(gmx.Client.State())
+ if initErr != nil {
+ log.Err(initErr).Msg("Failed to marshal init message")
+ return
+ }
+ initErr = writeCmd(ctx, conn, &hicli.JSONCommand{
+ Command: "client_state",
+ Data: initData,
+ })
+ if initErr != nil {
+ log.Err(initErr).Msg("Failed to write init message")
+ return
+ }
+ go gmx.sendInitialData(ctx, conn)
+ log.Debug().Msg("Connection initialization complete")
+ var closeErr websocket.CloseError
+ for {
+ msgType, reader, err := conn.Reader(ctx)
+ if err != nil {
+ if errors.As(err, &closeErr) {
+ log.Debug().
+ Stringer("status_code", closeErr.Code).
+ Str("reason", closeErr.Reason).
+ Msg("Connection closed")
+ } else {
+ log.Err(err).Msg("Failed to read message")
+ }
+ return
+ } else if msgType != websocket.MessageText {
+ log.Error().Stringer("message_type", msgType).Msg("Unexpected message type")
+ _ = conn.Close(websocket.StatusUnsupportedData, "Non-text message")
+ return
+ }
+ var cmd hicli.JSONCommand
+ err = json.NewDecoder(reader).Decode(&cmd)
+ if err != nil {
+ log.Err(err).Msg("Failed to parse message")
+ _ = conn.Close(websocket.StatusUnsupportedData, "Invalid JSON")
+ return
+ }
+ go submitCmd(&cmd)
+ }
+}
+
+func (gmx *Gomuks) sendInitialData(ctx context.Context, conn *websocket.Conn) {
+ maxTS := time.Now().Add(1 * time.Hour)
+ log := zerolog.Ctx(ctx)
+ var roomCount int
+ const BatchSize = 100
+ for {
+ rooms, err := gmx.Client.DB.Room.GetBySortTS(ctx, maxTS, BatchSize)
+ if err != nil {
+ if ctx.Err() == nil {
+ log.Err(err).Msg("Failed to get initial rooms to send to client")
+ }
+ return
+ }
+ roomCount += len(rooms)
+ payload := hicli.SyncComplete{
+ Rooms: make(map[id.RoomID]*hicli.SyncRoom, len(rooms)-1),
+ }
+ for _, room := range rooms {
+ if room.SortingTimestamp == rooms[len(rooms)-1].SortingTimestamp {
+ break
+ }
+ maxTS = room.SortingTimestamp.Time
+ syncRoom := &hicli.SyncRoom{
+ Meta: room,
+ Events: make([]*database.Event, 0, 2),
+ Timeline: make([]database.TimelineRowTuple, 0),
+ }
+ payload.Rooms[room.ID] = syncRoom
+ if room.PreviewEventRowID != 0 {
+ previewEvent, err := gmx.Client.DB.Event.GetByRowID(ctx, room.PreviewEventRowID)
+ if err != nil {
+ log.Err(err).Msg("Failed to get preview event for room")
+ return
+ } else if previewEvent != nil {
+ syncRoom.Events = append(syncRoom.Events, previewEvent)
+ }
+ if previewEvent != nil && previewEvent.LastEditRowID != nil {
+ lastEdit, err := gmx.Client.DB.Event.GetByRowID(ctx, *previewEvent.LastEditRowID)
+ if err != nil {
+ log.Err(err).Msg("Failed to get last edit for preview event")
+ return
+ } else if lastEdit != nil {
+ syncRoom.Events = append(syncRoom.Events, lastEdit)
+ }
+ }
+ }
+ }
+ marshaledPayload, err := json.Marshal(&payload)
+ if err != nil {
+ log.Err(err).Msg("Failed to marshal initial rooms to send to client")
+ return
+ }
+ err = writeCmd(ctx, conn, &hicli.JSONCommand{
+ Command: "sync_complete",
+ RequestID: 0,
+ Data: marshaledPayload,
+ })
+ if err != nil {
+ log.Err(err).Msg("Failed to send initial rooms to client")
+ return
+ }
+ if len(rooms) < BatchSize {
+ break
+ }
+ }
+ log.Info().Int("room_count", roomCount).Msg("Sent initial rooms to client")
+}