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 @@