summaryrefslogtreecommitdiff
path: root/ui/lib
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-02-25 00:37:33 -0500
committerOwen Jacobson <owen@grimoire.ca>2025-02-26 01:54:44 -0500
commitf788ea84e25a4f7216ca0604aeb216346403b6ef (patch)
treedb62f5d1e15d871f8a73ce20b40cd53053d12f85 /ui/lib
parentf1b124a0423cdaf4d8a6bd62a2059722e9afdf2b (diff)
Track state on a per-session basis, rather than via globals.
Sorry about the thousand-line omnibus change; this is functionally a rewrite of the client's state tracking, flavoured to resemble the existing code as far as is possible, rather than something that can be parted out and committed in pieces. Highlights: * No more `store.writeable()`s. All state is now tracked using state runs or derivatives. State is still largely structured the way it was, but several bits of nested state have been rewritten to ensure that their properties are reactive just as much as their containers are. * State is no longer global. `(app)/+layout` manages a stateful session, created via its load hook and started/stopped via component mount and destroy events. The session also tracks an event source for the current state, and feeds events into the state, broadly along the same lines as the previous stores-based approach. Together these two changes fix up several rough spots integrating state with Svelte, and allow for the possibility of multiple states. This is a major step towards restartable states, and thus towards better connection management, which will require the ability to "start over" once a connection is restored.
Diffstat (limited to 'ui/lib')
-rw-r--r--ui/lib/constants.js3
-rw-r--r--ui/lib/index.js1
-rw-r--r--ui/lib/iterator.js35
-rw-r--r--ui/lib/iterator.test.js93
-rw-r--r--ui/lib/runs.js25
-rw-r--r--ui/lib/session.svelte.js65
-rw-r--r--ui/lib/state/local/channels.svelte.js118
-rw-r--r--ui/lib/state/remote/channels.svelte.js22
-rw-r--r--ui/lib/state/remote/logins.svelte.js18
-rw-r--r--ui/lib/state/remote/messages.svelte.js52
-rw-r--r--ui/lib/state/remote/state.svelte.js89
-rw-r--r--ui/lib/store.js77
-rw-r--r--ui/lib/store/channels.svelte.js115
-rw-r--r--ui/lib/store/logins.js22
-rw-r--r--ui/lib/store/messages.svelte.js78
15 files changed, 517 insertions, 296 deletions
diff --git a/ui/lib/constants.js b/ui/lib/constants.js
deleted file mode 100644
index f854f5d..0000000
--- a/ui/lib/constants.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export const EPOCH_STRING = '1970-01-01T00:00:00Z';
-
-export const STORE_KEY_CHANNELS_DATA = 'pilcrow:channelsData';
diff --git a/ui/lib/index.js b/ui/lib/index.js
deleted file mode 100644
index 856f2b6..0000000
--- a/ui/lib/index.js
+++ /dev/null
@@ -1 +0,0 @@
-// place files you want to import through the `$lib` alias in this folder.
diff --git a/ui/lib/iterator.js b/ui/lib/iterator.js
new file mode 100644
index 0000000..1d6a740
--- /dev/null
+++ b/ui/lib/iterator.js
@@ -0,0 +1,35 @@
+export function* map(xs, fn) {
+ for (const x of xs) {
+ yield fn(x);
+ }
+}
+
+export function reduce(xs, fn, initial) {
+ let value = initial;
+ for (const x of xs) {
+ value = fn(value, x);
+ }
+ return value;
+}
+
+export function* chunkBy(xs, keyFn, coalesceFn) {
+ let chunk;
+ let key;
+ for (const x of xs) {
+ const newKey = keyFn(x);
+
+ if (key === undefined) {
+ chunk = [x];
+ } else if (coalesceFn(key, newKey)) {
+ chunk.push(x);
+ } else {
+ yield { key, chunk };
+
+ chunk = [x];
+ }
+ key = newKey;
+ }
+ if (chunk !== undefined) {
+ yield { key, chunk };
+ }
+}
diff --git a/ui/lib/iterator.test.js b/ui/lib/iterator.test.js
new file mode 100644
index 0000000..4c55358
--- /dev/null
+++ b/ui/lib/iterator.test.js
@@ -0,0 +1,93 @@
+import * as iter from './iterator.js';
+import { beforeEach, expect, test, describe, it, vi } from 'vitest';
+
+describe('map', async () => {
+ it('applies the mapping function to each item', async () => {
+ const seq = Iterator.from([1, 2, 3, 5, 8]);
+ const mapped = iter.map(seq, (x) => 2 * x);
+ expect(Array.from(mapped)).toStrictEqual([2, 4, 6, 10, 16]);
+ });
+});
+
+describe('reduce', async () => {
+ it('accumulates results and returns them', async () => {
+ const seq = Iterator.from([1, 2, 3, 4]);
+ const reduced = iter.reduce(seq, (sum, x) => sum + x, 0);
+
+ // Good ol' triangle numbers.
+ expect(reduced).toStrictEqual(10);
+ });
+});
+
+describe('chunkBy', async () => {
+ describe('with trivial operand functions', async () => {
+ it('yields nothing for an empty input', async () => {
+ const chunks = iter.chunkBy(
+ [],
+ (val) => val,
+ (last, next) => last === next
+ );
+
+ expect(Array.from(chunks)).toStrictEqual([]);
+ });
+
+ it('yields one chunk input for a singleton input', async () => {
+ const chunks = iter.chunkBy(
+ [37],
+ (val) => val,
+ (last, next) => last === next
+ );
+
+ expect(Array.from(chunks)).toStrictEqual([{ key: 37, chunk: [37] }]);
+ });
+
+ it('yields chunks of successive inputs', async () => {
+ const chunks = iter.chunkBy(
+ [37, 37, 28, 37],
+ (val) => val,
+ (last, next) => last === next
+ );
+
+ expect(Array.from(chunks)).toStrictEqual([
+ { key: 37, chunk: [37, 37] },
+ { key: 28, chunk: [28] },
+ {
+ key: 37,
+ chunk: [37]
+ }
+ ]);
+ });
+ });
+
+ describe('with a complex key function', async () => {
+ it('returns the key with each chunk', async () => {
+ const chunks = iter.chunkBy(
+ [37, 37, 28, 37],
+ (val) => val >> 1,
+ (last, next) => last === next
+ );
+
+ expect(Array.from(chunks)).toStrictEqual([
+ { key: 18, chunk: [37, 37] },
+ { key: 14, chunk: [28] },
+ { key: 18, chunk: [37] }
+ ]);
+ });
+ });
+
+ describe('with a complex coalesce function', async () => {
+ it('continues the chunk when specified', async () => {
+ const chunks = iter.chunkBy(
+ [36, 37, 28, 29, 30, 38],
+ (val) => val,
+ (last, next) => last + 1 === next
+ );
+
+ expect(Array.from(chunks)).toStrictEqual([
+ { key: 37, chunk: [36, 37] },
+ { key: 30, chunk: [28, 29, 30] },
+ { key: 38, chunk: [38] }
+ ]);
+ });
+ });
+});
diff --git a/ui/lib/runs.js b/ui/lib/runs.js
new file mode 100644
index 0000000..f4e90be
--- /dev/null
+++ b/ui/lib/runs.js
@@ -0,0 +1,25 @@
+import * as iter from './iterator.js';
+
+const RUN_COALESCE_MAX_INTERVAL = 10 /* min */ * 60 /* sec */ * 1000; /* ms */
+
+export function runs(messages, self) {
+ const bareRuns = iter.chunkBy(messages, runKey, continueRun);
+ return iter.map(bareRuns, (run) => summarizeRun(self, run));
+}
+
+function summarizeRun(self, { key, chunk }) {
+ const [sender, at] = key;
+ return {
+ sender: sender.name,
+ ownMessage: sender.id === self.id,
+ messages: chunk
+ };
+}
+
+function runKey(message) {
+ return [message.sender, message.at];
+}
+
+function continueRun([lastSender, lastAt], [newSender, newAt]) {
+ return lastSender === newSender && newAt - lastAt < RUN_COALESCE_MAX_INTERVAL;
+}
diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js
new file mode 100644
index 0000000..16c2a98
--- /dev/null
+++ b/ui/lib/session.svelte.js
@@ -0,0 +1,65 @@
+import { redirect } from '@sveltejs/kit';
+
+import * as api from './apiServer.js';
+import * as r from './state/remote/state.svelte.js';
+import * as l from './state/local/channels.svelte.js';
+
+class Session {
+ remote = $state();
+ local = $state();
+ currentUser = $derived(this.remote.currentUser);
+ logins = $derived(this.remote.logins.all);
+ channels = $derived(this.remote.channels.all);
+ messages = $derived(
+ this.remote.messages.all.map((message) =>
+ message.resolve({ sender: (id) => this.logins.get(id) })
+ )
+ );
+
+ static boot({ login, logins, channels, messages, resume_point }) {
+ const remote = r.State.boot({
+ currentUser: login,
+ logins,
+ channels,
+ messages,
+ resumePoint: resume_point
+ });
+ const local = l.Channels.fromLocalStorage();
+ return new Session(remote, local);
+ }
+
+ constructor(remote, local) {
+ this.remote = remote;
+ this.local = local;
+ }
+
+ begin() {
+ this.events = api.subscribeToEvents(this.remote.resumePoint);
+ this.events.onmessage = this.onMessage.bind(this);
+ }
+
+ end() {
+ this.events.close();
+ this.events = null;
+ }
+
+ onMessage(message) {
+ const event = JSON.parse(message.data);
+ this.remote.onEvent(event);
+ this.local.retainChannels(this.remote.channels.all.keys());
+ }
+}
+
+export async function boot() {
+ const response = await api.boot();
+ switch (response.status) {
+ case 401:
+ redirect(307, '/login');
+ break;
+ case 503:
+ redirect(307, '/setup');
+ break;
+ case 200:
+ return Session.boot(response.data);
+ }
+}
diff --git a/ui/lib/state/local/channels.svelte.js b/ui/lib/state/local/channels.svelte.js
new file mode 100644
index 0000000..9040685
--- /dev/null
+++ b/ui/lib/state/local/channels.svelte.js
@@ -0,0 +1,118 @@
+import { DateTime } from 'luxon';
+import { SvelteMap } from 'svelte/reactivity';
+
+import * as iter from '$lib/iterator.js';
+
+export const STORE_KEY_CHANNELS_DATA = 'pilcrow:channelsData';
+
+class Channel {
+ draft = $state();
+ lastReadAt = $state(null);
+ scrollPosition = $state(null);
+
+ static fromStored({ draft, lastReadAt, scrollPosition }) {
+ return new Channel({
+ draft,
+ lastReadAt: lastReadAt == null ? null : DateTime.fromISO(lastReadAt),
+ scrollPosition
+ });
+ }
+
+ constructor({ draft = '', lastReadAt = null, scrollPosition = null } = {}) {
+ this.draft = draft;
+ this.lastReadAt = lastReadAt;
+ this.scrollPosition = scrollPosition;
+ }
+
+ toStored() {
+ const { draft, lastReadAt, scrollPosition } = this;
+ return {
+ draft,
+ lastReadAt: lastReadAt?.toISO(),
+ scrollPosition
+ };
+ }
+}
+
+export class Channels {
+ // Store channelId -> { draft = '', lastReadAt = null, scrollPosition = null }
+ all = $state();
+
+ static fromLocalStorage() {
+ const stored = localStorage.getItem(STORE_KEY_CHANNELS_DATA);
+ if (stored !== null) {
+ return Channels.fromStored(stored);
+ }
+ return Channels.empty();
+ }
+
+ static fromStored(stored) {
+ const loaded = Object.keys(stored).map((channelId) => [
+ channelId,
+ Channel.fromStored(stored[channelId])
+ ]);
+ const all = new SvelteMap(loaded);
+ return new Channels({ all });
+ }
+
+ static empty() {
+ return new Channels({ all: new SvelteMap() });
+ }
+
+ constructor({ all }) {
+ this.all = all;
+ }
+
+ channel(channelId) {
+ let channel = this.all.get(channelId);
+ if (channel === undefined) {
+ channel = new Channel();
+ this.all.set(channelId, channel);
+ }
+ return channel;
+ }
+
+ updateLastReadAt(channelId, at) {
+ const channel = this.channel(channelId);
+ // Do it this way, rather than with Math.max tricks, to avoid assignment
+ // when we don't need it, to minimize reactive changes:
+ if (channel.lastReadAt === null || at > channel.lastReadAt) {
+ channel.lastReadAt = at;
+ this.save();
+ }
+ }
+
+ retainChannels(channelIds) {
+ const retain = new Set(channelIds);
+ for (const channelId of Array.from(this.all.keys())) {
+ if (!retain.has(channelId)) {
+ this.all.delete(channelId);
+ }
+ }
+ this.save();
+ }
+
+ toStored() {
+ return iter.reduce(
+ this.all.entries(),
+ (stored, [channelId, channel]) => ({
+ ...stored,
+ [channelId]: channel.toStored()
+ }),
+ {}
+ );
+ }
+
+ save() {
+ let stored = this.toStored();
+ console.log(this, stored);
+ localStorage.setItem(STORE_KEY_CHANNELS_DATA, JSON.stringify(stored));
+ }
+}
+
+function objectMap(object, mapFn) {
+ return Object.keys(object).reduce((result, key) => {
+ result[key] = mapFn(object[key]);
+ return result;
+ }, {});
+}
diff --git a/ui/lib/state/remote/channels.svelte.js b/ui/lib/state/remote/channels.svelte.js
new file mode 100644
index 0000000..64edb09
--- /dev/null
+++ b/ui/lib/state/remote/channels.svelte.js
@@ -0,0 +1,22 @@
+import { SvelteMap } from 'svelte/reactivity';
+
+export class Channels {
+ all = $state();
+
+ static boot(channels) {
+ const all = new SvelteMap(channels.map((channel) => [channel.id, channel]));
+ return new Channels({ all });
+ }
+
+ constructor({ all }) {
+ this.all = all;
+ }
+
+ add({ id, name }) {
+ this.all.set(id, { id, name });
+ }
+
+ remove(id) {
+ this.all.delete(id);
+ }
+}
diff --git a/ui/lib/state/remote/logins.svelte.js b/ui/lib/state/remote/logins.svelte.js
new file mode 100644
index 0000000..d19068d
--- /dev/null
+++ b/ui/lib/state/remote/logins.svelte.js
@@ -0,0 +1,18 @@
+import { SvelteMap } from 'svelte/reactivity';
+
+export class Logins {
+ all = $state();
+
+ static boot(logins) {
+ const all = new SvelteMap(logins.map((login) => [login.id, login]));
+ return new Logins({ all });
+ }
+
+ constructor({ all }) {
+ this.all = all;
+ }
+
+ add({ id, name }) {
+ this.all.set(id, { id, name });
+ }
+}
diff --git a/ui/lib/state/remote/messages.svelte.js b/ui/lib/state/remote/messages.svelte.js
new file mode 100644
index 0000000..576a74e
--- /dev/null
+++ b/ui/lib/state/remote/messages.svelte.js
@@ -0,0 +1,52 @@
+import { DateTime } from 'luxon';
+import { render } from '$lib/markdown.js';
+
+class Message {
+ static boot({ id, at, channel, sender, body }) {
+ return new Message({
+ id,
+ at: DateTime.fromISO(at),
+ channel,
+ sender,
+ body,
+ renderedBody: render(body)
+ });
+ }
+
+ constructor({ id, at, channel, sender, body, renderedBody }) {
+ this.id = id;
+ this.at = at;
+ this.channel = channel;
+ this.sender = sender;
+ this.body = body;
+ this.renderedBody = renderedBody;
+ }
+
+ resolve(get) {
+ const { sender, ...rest } = this;
+ return new Message({ sender: get.sender(sender), ...rest });
+ }
+}
+
+export class Messages {
+ all = $state([]);
+
+ static boot(messages) {
+ const all = messages.map(Message.boot);
+ return new Messages({ all });
+ }
+
+ constructor({ all }) {
+ this.all = all;
+ }
+
+ add({ id, at, channel, sender, body }) {
+ const message = Message.boot({ id, at, channel, sender, body });
+ this.all.push(message);
+ }
+
+ remove(id) {
+ const index = this.all.findIndex((message) => message.id === id);
+ this.all.splice(index, 1);
+ }
+}
diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js
new file mode 100644
index 0000000..c4daf17
--- /dev/null
+++ b/ui/lib/state/remote/state.svelte.js
@@ -0,0 +1,89 @@
+import { Logins } from './logins.svelte.js';
+import { Channels } from './channels.svelte.js';
+import { Messages } from './messages.svelte.js';
+
+export class State {
+ currentUser = $state();
+ logins = $state();
+ channels = $state();
+ messages = $state();
+
+ static boot({ currentUser, logins, channels, messages, resumePoint }) {
+ return new State({
+ currentUser,
+ logins: Logins.boot(logins),
+ channels: Channels.boot(channels),
+ messages: Messages.boot(messages),
+ resumePoint
+ });
+ }
+
+ constructor({ currentUser, logins, channels, messages, resumePoint }) {
+ this.currentUser = currentUser;
+ this.logins = logins;
+ this.channels = channels;
+ this.messages = messages;
+ this.resumePoint = resumePoint;
+ }
+
+ onEvent(event) {
+ switch (event.type) {
+ case 'channel':
+ return this.onChannelEvent(event);
+ case 'login':
+ return this.onLoginEvent(event);
+ case 'message':
+ return this.onMessageEvent(event);
+ }
+ }
+
+ onChannelEvent(event) {
+ switch (event.event) {
+ case 'created':
+ return this.onChannelCreated(event);
+ case 'deleted':
+ return this.onChannelDeleted(event);
+ }
+ }
+
+ onChannelCreated(event) {
+ const { id, name } = event;
+ this.channels.add({ id, name });
+ }
+
+ onChannelDeleted(event) {
+ const { id } = event;
+ this.channels.remove(id);
+ }
+
+ onLoginEvent(event) {
+ switch (event.event) {
+ case 'created':
+ return this.onLoginCreated(event);
+ }
+ }
+
+ onLoginCreated(event) {
+ const { id, name } = event;
+ this.logins.add({ id, name });
+ }
+
+ onMessageEvent(event) {
+ switch (event.event) {
+ case 'sent':
+ return this.onMessageSent(event);
+ case 'deleted':
+ return this.onMessageDeleted(event);
+ }
+ }
+
+ onMessageSent(event) {
+ const { id, at, channel, sender, body } = event;
+ this.messages.add({ id, at, channel, sender, body });
+ }
+
+ onMessageDeleted(event) {
+ const { id } = event;
+ this.messages.remove(id);
+ }
+}
diff --git a/ui/lib/store.js b/ui/lib/store.js
deleted file mode 100644
index 57b5cce..0000000
--- a/ui/lib/store.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import { writable } from 'svelte/store';
-import { browser } from '$app/environment';
-import { Channels, ChannelsMeta } from '$lib/store/channels.svelte.js';
-import { Messages } from '$lib/store/messages.svelte.js';
-import { Logins } from '$lib/store/logins';
-import { STORE_KEY_CHANNELS_DATA } from '$lib/constants';
-
-// Get channelsList content from the local storage
-const channelsMetaData =
- (browser && JSON.parse(localStorage.getItem(STORE_KEY_CHANNELS_DATA))) || {};
-
-export const currentUser = writable(null);
-export const logins = writable(new Logins());
-export const channelsMetaList = writable(new ChannelsMeta({ channelsMetaData }));
-export const channelsList = writable(new Channels({ channelsMetaList }));
-export const messages = writable(new Messages());
-
-export function onEvent(event) {
- switch (event.type) {
- case 'login':
- onLoginEvent(event);
- break;
- case 'channel':
- onChannelEvent(event);
- break;
- case 'message':
- onMessageEvent(event);
- break;
- }
-}
-
-onEvent.fromJson = (event) => {
- const parsed = JSON.parse(event);
- return onEvent(parsed);
-};
-
-onEvent.fromMessage = (message) => {
- const data = message.data;
- return onEvent.fromJson(data);
-};
-
-function onLoginEvent(event) {
- switch (event.event) {
- case 'created':
- logins.update((value) => value.addLogin(event.id, event.name));
- break;
- }
-}
-
-function onChannelEvent(event) {
- switch (event.event) {
- case 'created':
- channelsList.update((value) => value.addChannel(event.id, event.name));
- break;
- case 'deleted':
- channelsList.update((value) => value.deleteChannel(event.id));
- messages.update((value) => value.deleteChannel(event.id));
- break;
- }
-}
-
-function onMessageEvent(event) {
- switch (event.event) {
- case 'sent':
- messages.update((value) =>
- value.addMessage(event.channel, event.id, {
- at: event.at,
- sender: event.sender,
- body: event.body
- })
- );
- break;
- case 'deleted':
- messages.update((value) => value.deleteMessage(event.id));
- break;
- }
-}
diff --git a/ui/lib/store/channels.svelte.js b/ui/lib/store/channels.svelte.js
deleted file mode 100644
index 9058d86..0000000
--- a/ui/lib/store/channels.svelte.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import { DateTime } from 'luxon';
-import { get } from 'svelte/store';
-import { STORE_KEY_CHANNELS_DATA, EPOCH_STRING } from '$lib/constants';
-
-export class Channels {
- channels = $state([]);
-
- constructor({ channelsMetaList }) {
- // This is the state wrapper around the channels meta object. Dammit.
- this.channelsMetaList = channelsMetaList;
- }
-
- getChannel(channelId) {
- return this.channels.filter((ch) => ch.id === channelId)[0] || null;
- }
-
- setChannels(channels) {
- // Because this is called at initialization, we need to initialize the matching meta:
- get(this.channelsMetaList).ensureChannels(channels);
- this.channels = channels;
- this.sort();
- return this;
- }
-
- addChannel(id, name) {
- const newChannel = { id, name };
- this.channels = [...this.channels, newChannel];
- get(this.channelsMetaList).initializeChannel(newChannel);
- this.sort();
- return this;
- }
-
- deleteChannel(id) {
- const channelIndex = this.channels.map((e) => e.id).indexOf(id);
- if (channelIndex !== -1) {
- this.channels.splice(channelIndex, 1);
- }
- return this;
- }
-
- sort() {
- this.channels.sort((a, b) => {
- if (a.name < b.name) {
- return -1;
- } else if (a.name > b.name) {
- return 1;
- }
- return 0;
- });
- }
-}
-
-export class ChannelsMeta {
- // Store channelId -> { draft = '', lastReadAt = null, scrollPosition = null }
- channelsMeta = $state({});
-
- constructor({ channelsMetaData }) {
- const channelsMeta = objectMap(channelsMetaData, (ch) => {
- let lastReadAt = ch.lastReadAt;
- if (typeof lastReadAt === 'string') {
- lastReadAt = DateTime.fromISO(lastReadAt);
- }
- if (!Boolean(lastReadAt)) {
- lastReadAt = DateTime.fromISO(EPOCH_STRING);
- }
- return {
- ...ch,
- lastReadAt
- };
- });
- this.channelsMeta = channelsMeta;
- }
-
- writeOutToLocalStorage() {
- localStorage.setItem(STORE_KEY_CHANNELS_DATA, JSON.stringify(this.channelsMeta));
- }
-
- updateLastReadAt(channelId, at) {
- const channelObject = this.getChannel(channelId);
- // Do it this way, rather than with Math.max tricks, to avoid assignment
- // when we don't need it, to minimize reactive changes:
- if (at > channelObject?.lastReadAt) {
- channelObject.lastReadAt = at;
- this.writeOutToLocalStorage();
- }
- }
-
- ensureChannels(channelsList) {
- channelsList.forEach(({ id }) => {
- this.initializeChannel(id);
- });
- }
-
- initializeChannel(channelId) {
- if (!this.channelsMeta[channelId]) {
- const channelData = {
- lastReadAt: null,
- draft: '',
- scrollPosition: null
- };
- this.channelsMeta[channelId] = channelData;
- }
- }
-
- getChannel(channelId) {
- return this.channelsMeta[channelId] || null;
- }
-}
-
-function objectMap(object, mapFn) {
- return Object.keys(object).reduce((result, key) => {
- result[key] = mapFn(object[key]);
- return result;
- }, {});
-}
diff --git a/ui/lib/store/logins.js b/ui/lib/store/logins.js
deleted file mode 100644
index d449b3a..0000000
--- a/ui/lib/store/logins.js
+++ /dev/null
@@ -1,22 +0,0 @@
-export class Logins {
- constructor() {
- this.logins = {};
- }
-
- addLogin(id, name) {
- this.logins[id] = name;
- return this;
- }
-
- setLogins(logins) {
- this.logins = {};
- for (let { id, name } of logins) {
- this.addLogin(id, name);
- }
- return this;
- }
-
- get(id) {
- return this.logins[id];
- }
-}
diff --git a/ui/lib/store/messages.svelte.js b/ui/lib/store/messages.svelte.js
deleted file mode 100644
index e6fe7f3..0000000
--- a/ui/lib/store/messages.svelte.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import { DateTime } from 'luxon';
-import * as markdown from '$lib/markdown.js';
-
-const RUN_COALESCE_MAX_INTERVAL = 10 /* min */ * 60 /* sec */ * 1000; /* ms */
-
-export class Messages {
- channels = $state({}); // Mapping<ChannelId, Message>
-
- inChannel(channel) {
- return this.channels[channel] || [];
- }
-
- addMessage(channel, id, { at, sender, body }) {
- let parsedAt = DateTime.fromISO(at);
- let renderedBody = markdown.render(body);
- const message = { id, at: parsedAt, body, renderedBody };
-
- // You might be thinking, can't this be
- //
- // let runs = (this.channels[channel] ||= []);
- //
- // Let me tell you, I thought that too. Javascript's semantics allow it. It
- // didn't work - the first message in each channel was getting lost as the
- // update to `this.channels` wasn't actually happening. I suspect this is
- // due to the implementation of Svelte's `$state` rune, but I don't know it
- // for sure.
- //
- // In any case, splitting the read and write up like this has the same
- // semantics, and _works_. (This time, for sure!)
- let runs = this.channels[channel] || [];
-
- let currentRun = runs.slice(-1)[0];
- if (currentRun === undefined) {
- currentRun = { sender, messages: [message] };
- runs.push(currentRun);
- } else {
- let lastMessage = currentRun.messages.slice(-1)[0];
- let newRun =
- currentRun.sender !== sender || parsedAt - lastMessage.at > RUN_COALESCE_MAX_INTERVAL;
-
- if (newRun) {
- currentRun = { sender, messages: [message] };
- runs.push(currentRun);
- } else {
- currentRun.messages.push(message);
- }
- }
-
- this.channels[channel] = runs;
-
- return this;
- }
-
- setMessages(messages) {
- this.channels = {};
- for (let { channel, id, at, sender, body } of messages) {
- this.addMessage(channel, id, { at, sender, body });
- }
- return this;
- }
-
- deleteMessage(messageId) {
- for (let channel in this.channels) {
- this.channels[channel] = this.channels[channel]
- .map(({ sender, messages }) => ({
- sender,
- messages: messages.filter(({ id }) => id != messageId)
- }))
- .filter(({ messages }) => messages.length > 0);
- }
- return this;
- }
-
- deleteChannel(id) {
- delete this.channels[id];
- return this;
- }
-}