feat: matrix

This commit is contained in:
2024-09-29 02:00:52 +02:00
parent 236e23086c
commit 2f0d8f2e1d
14 changed files with 376 additions and 246 deletions

View File

@@ -69,6 +69,7 @@
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"rxjs": "^7.8.1",
"sass": "^1.77.8", "sass": "^1.77.8",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"stylelint": "^16.8.1", "stylelint": "^16.8.1",

3
pnpm-lock.yaml generated
View File

@@ -113,6 +113,9 @@ importers:
prettier-plugin-svelte: prettier-plugin-svelte:
specifier: ^3.2.6 specifier: ^3.2.6
version: 3.2.6(prettier@3.3.3)(svelte@5.0.0-next.221) version: 3.2.6(prettier@3.3.3)(svelte@5.0.0-next.221)
rxjs:
specifier: ^7.8.1
version: 7.8.1
sass: sass:
specifier: ^1.77.8 specifier: ^1.77.8
version: 1.77.8 version: 1.77.8

View File

@@ -1,73 +0,0 @@
<script lang="ts">
import type { Room } from "matrix-js-sdk";
import { matrixClient, currentRoomId } from "./chat";
let { rooms }: { rooms: Room[] } = $props();
</script>
<div class="rooms">
{#each $matrixClient.getRooms() as room}
{@const avatar = room.getMxcAvatarUrl()}
<button
class:active={$currentRoomId === room.roomId}
class="room"
onclick={() => ($currentRoomId = room.roomId)}
>
{#if avatar}
<img
alt={room.name}
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
width="16"
height="16"
/>
{:else}
<div>#</div>
{/if}
<div>{room.name}</div>
</button>
{/each}
{#await $matrixClient.publicRooms()}
<div>Loading...</div>
{:then rooms}
{#each rooms.chunk as room}
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
<div>#</div>
<div>{room.name}</div>
</button>
{/each}
{:catch error}
<div>{error.message}</div>
{/await}
</div>
<style lang="scss">
.rooms {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
padding-left: 0;
width: 100%;
}
.room {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
cursor: pointer;
padding-block: 2px;
min-height: 0;
height: unset;
padding-inline: 16px;
padding-block: 4px;
border-radius: 8px;
width: 100%;
&.active {
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
}
</style>

View File

View File

@@ -1,5 +1,10 @@
import { writable, type Writable } from "svelte/store"; import { derived, writable, type Writable } from "svelte/store";
import type { MatrixClient, RoomMember } from "matrix-js-sdk"; import type {
ClientEvent,
LoginResponse,
MatrixClient,
RoomMember,
} from "matrix-js-sdk";
import { persistentWritable } from "$lib/storage"; import { persistentWritable } from "$lib/storage";
import { import {
themeFromSourceColor, themeFromSourceColor,
@@ -7,14 +12,83 @@ import {
type CustomColorGroup, type CustomColorGroup,
} from "@material/material-color-utilities"; } from "@material/material-color-utilities";
import type { UserTheme } from "$lib/preferences"; import type { UserTheme } from "$lib/preferences";
import { MatrixRx } from "./matrix-rx/client";
export const matrixClient: Writable<MatrixClient> = writable(); export const matrixClient: Writable<MatrixClient> = writable();
export const isLoggedIn: Writable<boolean> = writable(false);
export const matrix = derived(
[matrixClient, isLoggedIn],
([matrixClient, isLoggedIn]) =>
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
);
export const currentRoomId = persistentWritable<string | null>( export const currentRoomId = persistentWritable<string | null>(
"currentRoomId", "currentRoomId",
null, 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( export function memberColor(
member: RoomMember, member: RoomMember,
theme: UserTheme, theme: UserTheme,

View File

@@ -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<Room[]>;
topLevelSpaces$: Observable<Room[]>;
topLevelChats$: Observable<Room[]>;
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,
) {}
}

View File

@@ -0,0 +1,11 @@
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
import { fromEvent, type Observable } from "rxjs";
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
client: MatrixClient,
eventName: `${T}`, // hack so we can use strings instead of enums
): Observable<Parameters<ClientEventHandlerMap[T]>> {
return fromEvent(client, eventName) as Observable<
Parameters<ClientEventHandlerMap[T]>
>;
}

View File

@@ -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<Room | undefined> {
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<MatrixEvent[] | undefined> {
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<MatrixEvent[]> {
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<T extends keyof RoomStateEventHandlerMap>(
state: RoomState,
eventName: `${T}`,
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
return fromEvent(state, eventName) as Observable<
Parameters<RoomStateEventHandlerMap[T]>
>;
}

View File

@@ -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<MatrixEvent[]> {
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()),
);
}

View File

@@ -9,10 +9,17 @@ export function persistentWritable<T>(
): Writable<T> { ): Writable<T> {
if (browser) { if (browser) {
const persistedValue = localStorage.getItem(key); const persistedValue = localStorage.getItem(key);
const store = let store: Writable<T>;
persistedValue !== null try {
? writable(JSON.parse(persistedValue)) store =
: writable(value); persistedValue !== null
? writable(JSON.parse(persistedValue))
: writable(value);
} catch (e) {
console.error(e);
} finally {
store = writable(value);
}
store.subscribe((value) => { store.subscribe((value) => {
if (!condition || condition()) if (!condition || condition())
localStorage.setItem(key, JSON.stringify(value)); localStorage.setItem(key, JSON.stringify(value));

View File

@@ -15,7 +15,6 @@
import { initSerial } from "$lib/serial/connection"; import { initSerial } from "$lib/serial/connection";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import BrowserWarning from "./BrowserWarning.svelte";
import "tippy.js/animations/shift-away.css"; import "tippy.js/animations/shift-away.css";
import "tippy.js/dist/tippy.css"; import "tippy.js/dist/tippy.css";
import tippy from "tippy.js"; import tippy from "tippy.js";
@@ -30,6 +29,7 @@
import { restoreFromFile } from "$lib/backup/backup"; import { restoreFromFile } from "$lib/backup/backup";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { hotkeys } from "$lib/title"; import { hotkeys } from "$lib/title";
import { initMatrixClient } from "$lib/chat/chat";
const locale = const locale =
((browser && localStorage.getItem("locale")) as Locales) || detectLocale(); ((browser && localStorage.getItem("locale")) as Locales) || detectLocale();
@@ -66,6 +66,9 @@
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) { if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
await initSerial(); await initSerial();
} }
if (browser) {
await initMatrixClient();
}
if (data.importFile) { if (data.importFile) {
restoreFromFile(data.importFile); restoreFromFile(data.importFile);
@@ -128,10 +131,6 @@
</PageTransition> </PageTransition>
<Footer /> <Footer />
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
<BrowserWarning />
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">

View File

@@ -1,184 +1,84 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { isLoggedIn, matrix } from "$lib/chat/chat";
import { onDestroy, onMount, setContext } from "svelte"; import { flip } from "svelte/animate";
import type { import { slide } from "svelte/transition";
IndexedDBStore, import Login from "./Login.svelte";
IndexedDBCryptoStore,
LoginResponse,
} from "matrix-js-sdk";
import MatrixTimeline from "$lib/chat/MatrixTimeline.svelte";
import { matrixClient, currentRoomId } from "$lib/chat/chat";
import MatrixRooms from "$lib/chat/MatrixRooms.svelte";
import MatrixRoomMembers from "$lib/chat/MatrixRoomMembers.svelte";
let loggedIn = $state(false); let { children } = $props();
let ready = $state(false);
let store: IndexedDBStore; let spaces = $derived($matrix?.topLevelSpaces$);
let cryptoStore: IndexedDBCryptoStore;
onMount(async () => { function spaceShort(name: string) {
if (!browser) return; return name
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import( .split(" ")
"matrix-js-sdk" .map((it) => it[0])
); .join("");
const storedLogin = getStoredLogin();
store = new IndexedDBStore({
dbName: "matrix",
indexedDB: window.indexedDB,
});
cryptoStore = new IndexedDBCryptoStore(window.indexedDB, "matrix-crypto");
$matrixClient = createClient({
baseUrl: import.meta.env.VITE_MATRIX_URL,
userId: storedLogin?.user_id,
accessToken: storedLogin?.access_token,
timelineSupport: true,
store,
cryptoStore,
});
const loginToken = new URLSearchParams(window.location.search).get(
"loginToken",
);
if (loginToken) {
await handleLogin(await $matrixClient.loginWithToken(loginToken));
window.history.replaceState({}, document.title, window.location.pathname);
}
await postLogin();
});
async function passwordLogin(event: SubmitEvent) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const username = (form.elements.namedItem("username") as HTMLInputElement)
.value;
const password = (form.elements.namedItem("password") as HTMLInputElement)
.value;
await handleLogin(
await $matrixClient.loginWithPassword(username, password),
);
await postLogin();
} }
async function handleLogin(response: LoginResponse) {
localStorage.setItem("matrix-login", JSON.stringify(response));
}
async function postLogin() {
loggedIn = $matrixClient.isLoggedIn();
if (loggedIn) {
await store.startup();
await cryptoStore.startup();
await $matrixClient.startClient();
$matrixClient.once("sync", function (state, prevState, res) {
ready = true;
});
}
}
function getStoredLogin(): LoginResponse | undefined {
try {
return JSON.parse(localStorage.getItem("matrix-login")!);
} catch {
return undefined;
}
}
onDestroy(() => {
if ($matrixClient) {
$matrixClient.stopClient();
}
});
</script> </script>
{#if $matrixClient && loggedIn} {#if $isLoggedIn}
{#if ready} <div class="layout">
<div class="chat"> <nav class="spaces">
<div class="rooms"> <a href="/chat/chats" class="icon chats">chat</a>
<button <hr />
onclick={() => { {#if $spaces}
$matrixClient.logout(true); <ul>
$matrixClient.clearStores(); {#each $spaces as space (space.roomId)}
localStorage.removeItem("matrix-login"); <li animate:flip transition:slide>
window.location.reload(); <a class="space" href="/chat/space/{space.roomId}">
}}>logout</button {spaceShort(space.name)}
> </a>
<MatrixRooms rooms={$matrixClient.getRooms()} /> </li>
</div>
{#if $currentRoomId}
{@const room = $matrixClient.getRoom($currentRoomId)}
{#key room}
{#if room}
<div class="timeline">
<MatrixTimeline timeline={room.getLiveTimeline()} />
</div>
<div class="members">
<MatrixRoomMembers members={room.getJoinedMembers()} />
</div>
{/if}
{/key}
{/if}
</div>
{/if}
{:else if $matrixClient}
{#await $matrixClient.loginFlows() then flows}
{#each flows.flows as flow}
{#if flow.type === "m.login.sso"}
<a
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
>
{#each flow.identity_providers as idp}
{#if idp.icon}
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
{:else}
{idp.name}
{/if}
{/each} {/each}
</a> </ul>
{:else if flow.type === "m.login.password"}
<form onsubmit={passwordLogin}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
{/if} {/if}
{/each} <button class="icon">add</button>
{/await} </nav>
</div>
{:else}
<Login />
{/if} {/if}
<style lang="scss"> <style lang="scss">
.chat { nav {
display: flex;
width: 100%;
height: 100%;
> *:not(:last-child) {
border-right: 1px solid var(--md-sys-color-outline);
}
}
.room {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1;
} }
.timeline { .layout {
flex-grow: 1; display: flex;
height: 100%;
width: 100%;
} }
.rooms { hr {
flex-shrink: 0; width: 60%;
height: 1px;
} }
.members { ul {
width: 200px; list-style: none;
flex-shrink: 0; padding: 0;
margin: 0;
}
button,
a {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
width: 56px;
height: 56px;
background: var(--md-sys-color-surface-variant);
}
.chats {
font-size: 24px;
}
.space {
font-size: 20px;
margin-bottom: 8px;
} }
</style> </style>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { matrixClient } from "$lib/chat/chat";
function passwordLogin() {
// TODO
}
</script>
{#if $matrixClient}
{#await $matrixClient.loginFlows() then flows}
{#each flows.flows as flow}
{#if flow.type === "m.login.sso"}
<a
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
>
{#each flow.identity_providers as idp}
{#if idp.icon}
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
{:else}
{idp.name}
{/if}
{/each}
</a>
{:else if flow.type === "m.login.password"}
<form onsubmit={passwordLogin}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
{/if}
{/each}
{/await}
{/if}

View File