diff --git a/package.json b/package.json
index d35a4477..a202caaa 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
+ "rxjs": "^7.8.1",
"sass": "^1.77.8",
"socket.io-client": "^4.7.5",
"stylelint": "^16.8.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 972a7a1a..e75c44c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -113,6 +113,9 @@ importers:
prettier-plugin-svelte:
specifier: ^3.2.6
version: 3.2.6(prettier@3.3.3)(svelte@5.0.0-next.221)
+ rxjs:
+ specifier: ^7.8.1
+ version: 7.8.1
sass:
specifier: ^1.77.8
version: 1.77.8
diff --git a/src/lib/chat/MatrixRooms.svelte b/src/lib/chat/MatrixRooms.svelte
deleted file mode 100644
index 71306d97..00000000
--- a/src/lib/chat/MatrixRooms.svelte
+++ /dev/null
@@ -1,73 +0,0 @@
-
-
-
- {#each $matrixClient.getRooms() as room}
- {@const avatar = room.getMxcAvatarUrl()}
-
- {/each}
-
- {#await $matrixClient.publicRooms()}
-
Loading...
- {:then rooms}
- {#each rooms.chunk as room}
-
- {/each}
- {:catch error}
-
{error.message}
- {/await}
-
-
-
diff --git a/src/lib/chat/MatrixSpace.svelte b/src/lib/chat/MatrixSpace.svelte
new file mode 100644
index 00000000..e69de29b
diff --git a/src/lib/chat/chat.ts b/src/lib/chat/chat.ts
index a2b07a6d..34ee3526 100644
--- a/src/lib/chat/chat.ts
+++ b/src/lib/chat/chat.ts
@@ -1,5 +1,10 @@
-import { writable, type Writable } from "svelte/store";
-import type { MatrixClient, RoomMember } from "matrix-js-sdk";
+import { derived, writable, type Writable } from "svelte/store";
+import type {
+ ClientEvent,
+ LoginResponse,
+ MatrixClient,
+ RoomMember,
+} from "matrix-js-sdk";
import { persistentWritable } from "$lib/storage";
import {
themeFromSourceColor,
@@ -7,14 +12,83 @@ import {
type CustomColorGroup,
} from "@material/material-color-utilities";
import type { UserTheme } from "$lib/preferences";
+import { MatrixRx } from "./matrix-rx/client";
export const matrixClient: Writable = writable();
+export const isLoggedIn: Writable = writable(false);
+
+export const matrix = derived(
+ [matrixClient, isLoggedIn],
+ ([matrixClient, isLoggedIn]) =>
+ isLoggedIn ? new MatrixRx(matrixClient) : undefined,
+);
+
export const currentRoomId = persistentWritable(
"currentRoomId",
null,
);
+function getStoredLogin(): LoginResponse | undefined {
+ try {
+ return JSON.parse(localStorage.getItem("matrix-login")!);
+ } catch {
+ return undefined;
+ }
+}
+
+export function storeLogin(response: LoginResponse) {
+ localStorage.setItem("matrix-login", JSON.stringify(response));
+}
+
+export async function initMatrixClient() {
+ const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
+ "matrix-js-sdk"
+ );
+
+ const storedLogin = getStoredLogin();
+
+ const store = new IndexedDBStore({
+ dbName: "matrix",
+ indexedDB: window.indexedDB,
+ });
+ const cryptoStore = new IndexedDBCryptoStore(
+ window.indexedDB,
+ "matrix-crypto",
+ );
+
+ const client = createClient({
+ baseUrl: import.meta.env.VITE_MATRIX_URL,
+ userId: storedLogin?.user_id,
+ accessToken: storedLogin?.access_token,
+ timelineSupport: true,
+ store,
+ cryptoStore,
+ });
+
+ console.log("store");
+ await store.startup();
+ console.log("cryptoStore");
+ await cryptoStore.startup();
+ console.log("client");
+ await client.startClient();
+ client.once("sync" as ClientEvent.Sync, () => {
+ isLoggedIn.set(client.isLoggedIn());
+ });
+
+ const loginToken = new URLSearchParams(window.location.search).get(
+ "loginToken",
+ );
+ if (loginToken) {
+ storeLogin(await client.loginWithToken(loginToken));
+ window.history.replaceState({}, document.title, window.location.pathname);
+ isLoggedIn.set(client.isLoggedIn());
+ }
+
+ matrixClient.set(client);
+ console.log("done");
+}
+
export function memberColor(
member: RoomMember,
theme: UserTheme,
diff --git a/src/lib/chat/matrix-rx/client.ts b/src/lib/chat/matrix-rx/client.ts
new file mode 100644
index 00000000..fc098a53
--- /dev/null
+++ b/src/lib/chat/matrix-rx/client.ts
@@ -0,0 +1,71 @@
+import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
+import {
+ filter,
+ map,
+ type Observable,
+ of,
+ distinctUntilChanged,
+ merge,
+} from "rxjs";
+import { fromMatrixClientEvent } from "./events";
+
+function roomListDistinct(prev: Room[], curr: Room[]) {
+ if (prev.length !== curr.length) return false;
+ for (let i = 0; i < prev.length; i++) {
+ if (prev[i]!.roomId !== curr[i]!.roomId) return false;
+ }
+ return true;
+}
+
+export class MatrixRx {
+ topLevelRooms$: Observable;
+
+ topLevelSpaces$: Observable;
+
+ topLevelChats$: Observable;
+
+ constructor(private client: MatrixClient) {
+ this.topLevelRooms$ = merge(
+ of([]),
+ fromMatrixClientEvent(client, "Room"),
+ fromMatrixClientEvent(client, "deleteRoom"),
+ fromMatrixClientEvent(client, "Room.myMembership"),
+ fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
+ filter(
+ ([_room, prev, curr]) =>
+ prev.getStateEvents("m.space.parent").length !==
+ curr.getStateEvents("m.space.parent").length,
+ ),
+ ),
+ ).pipe(
+ map(() =>
+ this.client.getVisibleRooms().filter(
+ (room) =>
+ room.getMyMembership() !== "leave" &&
+ room
+ .getLiveTimeline()
+ .getState("f" as Direction.Forward)
+ ?.getStateEvents("m.space.parent").length === 0,
+ ),
+ ),
+ distinctUntilChanged(roomListDistinct),
+ );
+
+ this.topLevelSpaces$ = this.topLevelRooms$.pipe(
+ map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
+ distinctUntilChanged(roomListDistinct),
+ );
+
+ this.topLevelChats$ = this.topLevelRooms$.pipe(
+ map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
+ distinctUntilChanged(roomListDistinct),
+ );
+ }
+}
+
+export class SpaceRx {
+ constructor(
+ private client: MatrixClient,
+ private space: Room,
+ ) {}
+}
diff --git a/src/lib/chat/matrix-rx/events.ts b/src/lib/chat/matrix-rx/events.ts
new file mode 100644
index 00000000..57f6764e
--- /dev/null
+++ b/src/lib/chat/matrix-rx/events.ts
@@ -0,0 +1,11 @@
+import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
+import { fromEvent, type Observable } from "rxjs";
+
+export function fromMatrixClientEvent(
+ client: MatrixClient,
+ eventName: `${T}`, // hack so we can use strings instead of enums
+): Observable> {
+ return fromEvent(client, eventName) as Observable<
+ Parameters
+ >;
+}
diff --git a/src/lib/chat/matrix-rx/rooms.ts b/src/lib/chat/matrix-rx/rooms.ts
new file mode 100644
index 00000000..986e4737
--- /dev/null
+++ b/src/lib/chat/matrix-rx/rooms.ts
@@ -0,0 +1,85 @@
+import type {
+ MatrixClient,
+ MatrixEvent,
+ Room,
+ Direction,
+ RoomState,
+ RoomStateEventHandlerMap,
+ EventType,
+} from "matrix-js-sdk";
+import { fromMatrixClientEvent } from "./events";
+import {
+ map,
+ filter,
+ merge,
+ startWith,
+ Observable,
+ of,
+ fromEvent,
+ concat,
+ defer,
+} from "rxjs";
+
+export function matrixRoom$(
+ client: MatrixClient,
+ roomId: string | undefined,
+): Observable {
+ return merge([
+ fromMatrixClientEvent(client, "Room").pipe(
+ filter(([room]) => room.roomId === roomId),
+ ),
+ fromMatrixClientEvent(client, "deleteRoom").pipe(
+ filter(([id]) => id === roomId),
+ ),
+ ]).pipe(
+ startWith([]),
+ map(() => client.getRoom(roomId) ?? undefined),
+ );
+}
+
+export function roomTimeline$(
+ client: MatrixClient,
+ room: Room | undefined,
+): Observable {
+ if (!room) return of(undefined);
+ const eventTimeline = room.getLiveTimeline();
+
+ return fromMatrixClientEvent(client, "Room.timeline").pipe(
+ filter(
+ ([, eventRoom]) =>
+ eventRoom !== undefined && eventRoom.roomId === room.roomId,
+ ),
+ startWith([]),
+ map(() => eventTimeline.getEvents()),
+ );
+}
+
+export function roomCurrentStateEvents$(
+ client: MatrixClient,
+ room: Room,
+ eventType: EventType | string,
+): Observable {
+ return concat(
+ defer(() =>
+ of(
+ room
+ .getLiveTimeline()
+ .getState("f" as Direction.Forward)
+ ?.getStateEvents(eventType) ?? [],
+ ),
+ ),
+ fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
+ filter(([room]) => room.roomId === room.roomId),
+ map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
+ ),
+ );
+}
+
+export function fromRoomStateEvent(
+ state: RoomState,
+ eventName: `${T}`,
+): Observable> {
+ return fromEvent(state, eventName) as Observable<
+ Parameters
+ >;
+}
diff --git a/src/lib/chat/matrix-rx/timeline.ts b/src/lib/chat/matrix-rx/timeline.ts
new file mode 100644
index 00000000..3cf9e743
--- /dev/null
+++ b/src/lib/chat/matrix-rx/timeline.ts
@@ -0,0 +1,19 @@
+import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
+import { filter, map, of, startWith, type Observable } from "rxjs";
+import { fromMatrixClientEvent } from "./events";
+
+export function roomTimeline(
+ client: MatrixClient,
+ roomId: string | undefined,
+): Observable {
+ if (!roomId) return of([]);
+ const room = client.getRoom(roomId);
+ if (!room) return of([]);
+ const eventTimeline = room.getLiveTimeline();
+
+ return fromMatrixClientEvent(client, "Room.timeline").pipe(
+ filter(([, room]) => room?.roomId === roomId),
+ startWith([]),
+ map(() => eventTimeline.getEvents()),
+ );
+}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index 92746229..8d92e1d6 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -9,10 +9,17 @@ export function persistentWritable(
): Writable {
if (browser) {
const persistedValue = localStorage.getItem(key);
- const store =
- persistedValue !== null
- ? writable(JSON.parse(persistedValue))
- : writable(value);
+ let store: Writable;
+ try {
+ store =
+ persistedValue !== null
+ ? writable(JSON.parse(persistedValue))
+ : writable(value);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ store = writable(value);
+ }
store.subscribe((value) => {
if (!condition || condition())
localStorage.setItem(key, JSON.stringify(value));
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 556867b2..00a070cb 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -15,7 +15,6 @@
import { initSerial } from "$lib/serial/connection";
import type { LayoutData } from "./$types";
import { browser } from "$app/environment";
- import BrowserWarning from "./BrowserWarning.svelte";
import "tippy.js/animations/shift-away.css";
import "tippy.js/dist/tippy.css";
import tippy from "tippy.js";
@@ -30,6 +29,7 @@
import { restoreFromFile } from "$lib/backup/backup";
import { goto } from "$app/navigation";
import { hotkeys } from "$lib/title";
+ import { initMatrixClient } from "$lib/chat/chat";
const locale =
((browser && localStorage.getItem("locale")) as Locales) || detectLocale();
@@ -66,6 +66,9 @@
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
await initSerial();
}
+ if (browser) {
+ await initMatrixClient();
+ }
if (data.importFile) {
restoreFromFile(data.importFile);
@@ -128,10 +131,6 @@
-
- {#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
-
- {/if}
diff --git a/src/routes/(app)/chat/Login.svelte b/src/routes/(app)/chat/Login.svelte
new file mode 100644
index 00000000..5af64d54
--- /dev/null
+++ b/src/routes/(app)/chat/Login.svelte
@@ -0,0 +1,33 @@
+
+
+{#if $matrixClient}
+ {#await $matrixClient.loginFlows() then flows}
+ {#each flows.flows as flow}
+ {#if flow.type === "m.login.sso"}
+
+ {#each flow.identity_providers as idp}
+ {#if idp.icon}
+
+ {:else}
+ {idp.name}
+ {/if}
+ {/each}
+
+ {:else if flow.type === "m.login.password"}
+
+ {/if}
+ {/each}
+ {/await}
+{/if}
diff --git a/src/routes/(app)/chat/Space.svelte b/src/routes/(app)/chat/Space.svelte
new file mode 100644
index 00000000..e69de29b