summaryrefslogtreecommitdiff
path: root/ui
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
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')
-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
-rw-r--r--ui/routes/(app)/+layout.js8
-rw-r--r--ui/routes/(app)/+layout.svelte125
-rw-r--r--ui/routes/(app)/ch/[channel]/+page.svelte25
-rw-r--r--ui/routes/(app)/me/+page.svelte2
-rw-r--r--ui/routes/+layout.svelte8
20 files changed, 572 insertions, 409 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;
- }
-}
diff --git a/ui/routes/(app)/+layout.js b/ui/routes/(app)/+layout.js
new file mode 100644
index 0000000..651bc8c
--- /dev/null
+++ b/ui/routes/(app)/+layout.js
@@ -0,0 +1,8 @@
+import * as session from '$lib/session.svelte.js';
+
+export async function load() {
+ let s = await session.boot();
+ return {
+ session: s
+ };
+}
diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte
index 7818505..9ec5244 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -8,61 +8,42 @@
import TinyGesture from 'tinygesture';
import * as api from '$lib/apiServer.js';
- import {
- channelsList,
- channelsMetaList,
- currentUser,
- logins,
- messages,
- onEvent
- } from '$lib/store';
import ChannelList from '$lib/components/ChannelList.svelte';
import CreateChannelForm from '$lib/components/CreateChannelForm.svelte';
- let events = null;
let gesture = null;
+ const { data, children } = $props();
+ const { session } = data;
+
+ onMount(session.begin.bind(session));
+ onDestroy(session.end.bind(session));
+
let pageContext = getContext('page');
- let { children } = $props();
- let loading = $state(true);
let channel = $derived(page.params.channel);
- let rawChannels = $derived($channelsList.channels);
- let rawChannelsMeta = $derived($channelsMetaList.channelsMeta);
- let rawMessages = $derived($messages);
-
- let enrichedChannels = $derived.by(() => {
- const channels = rawChannels;
- const channelsMeta = rawChannelsMeta;
- const messages = rawMessages;
+ let rawChannels = $derived(session.channels);
+ let rawChannelsMeta = $derived(session.local.all);
+ let rawMessages = $derived(session.messages);
+ function enrichChannels(channels, channelsMeta, messages) {
const enrichedChannels = [];
- if (channels && messages) {
- for (let ch of channels) {
- let runs = messages.inChannel(ch.id);
- let lastRun = runs?.slice(-1)[0];
- let lastMessage = lastRun?.messages.slice(-1)[0];
- let lastMessageAt = lastMessage?.at;
- let hasUnreads = lastMessageAt > channelsMeta[ch.id]?.lastReadAt;
- enrichedChannels.push({
- ...ch,
- hasUnreads
- });
- }
+ for (const ch of channels.values()) {
+ const channelMessages = messages.filter((message) => message.channel === ch.id);
+ const lastMessage = channelMessages.slice(-1)[0];
+ const lastMessageAt = lastMessage?.at;
+ const lastReadAt = channelsMeta.get(ch.id)?.lastReadAt;
+ const hasUnreads = lastReadAt === null || lastMessageAt > lastReadAt;
+ enrichedChannels.push({
+ ...ch,
+ hasUnreads
+ });
}
return enrichedChannels;
- });
-
- function onBooted(boot) {
- currentUser.set({
- id: boot.login.id,
- username: boot.login.name
- });
- logins.update((value) => value.setLogins(boot.logins));
- channelsList.update((value) => value.setChannels(boot.channels));
- messages.update((value) => value.setMessages(boot.messages));
}
+ const enrichedChannels = $derived(enrichChannels(rawChannels, rawChannelsMeta, rawMessages));
+
function setUpGestures() {
if (!browser) {
// Meaningless if we're not in a browser, so...
@@ -77,46 +58,14 @@
});
}
- onMount(async () => {
- let response = await api.boot();
- switch (response.status) {
- case 200:
- onBooted(response.data);
- events = api.subscribeToEvents(response.data.resume_point);
- events.onmessage = onEvent.fromMessage;
- break;
- case 401:
- currentUser.set(null);
- await goto('/login');
- break;
- case 503:
- currentUser.set(null);
- await goto('/setup');
- break;
- default:
- // TODO: display error.
- break;
- }
- setUpGestures();
-
- loading = false;
- });
+ onMount(setUpGestures);
onDestroy(async () => {
- if (events !== null) {
- events.close();
- }
if (gesture !== null) {
gesture.destroy();
}
});
- function onbeforeunload(event) {
- if (events !== null) {
- events.close();
- }
- }
-
const STORE_KEY_LAST_ACTIVE = 'pilcrow:lastActiveChannel';
function getLastActiveChannel() {
@@ -142,25 +91,19 @@
}
</script>
-<svelte:window {onbeforeunload} />
-
<svelte:head>
<!-- TODO: unread count? -->
<title>pilcrow</title>
</svelte:head>
-{#if loading}
- <h2>Loading&hellip;</h2>
-{:else}
- <div id="interface">
- <nav id="sidebar" data-expanded={pageContext.showMenu}>
- <ChannelList active={channel} channels={enrichedChannels} />
- <div class="create-channel">
- <CreateChannelForm {createChannel} />
- </div>
- </nav>
- <main>
- {@render children?.()}
- </main>
- </div>
-{/if}
+<div id="interface">
+ <nav id="sidebar" data-expanded={pageContext.showMenu}>
+ <ChannelList active={channel} channels={enrichedChannels} />
+ <div class="create-channel">
+ <CreateChannelForm {createChannel} />
+ </div>
+ </nav>
+ <main>
+ {@render children?.()}
+ </main>
+</div>
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index 095e66a..c8507cc 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -3,24 +3,17 @@
import { page } from '$app/state';
import ActiveChannel from '$lib/components/ActiveChannel.svelte';
import MessageInput from '$lib/components/MessageInput.svelte';
- import { channelsList, channelsMetaList, currentUser, logins, messages } from '$lib/store';
+ import { runs } from '$lib/runs.js';
import * as api from '$lib/apiServer';
- let channel = $derived(page.params.channel);
- let messageRuns = $derived(
- $messages.inChannel(channel).map(({ sender, messages }) => {
- let senderName = $derived($logins.get(sender));
- let ownMessage = $derived($currentUser !== null && $currentUser.id === sender);
-
- return {
- sender: senderName,
- ownMessage,
- messages
- };
- })
- );
+ const { data } = $props();
+ const { session } = data;
let activeChannel;
+ const channel = $derived(page.params.channel);
+ const messages = $derived(session.messages.filter((message) => message.channel === channel));
+ const messageRuns = $derived(runs(messages, session.currentUser));
+
function inView(parentElement, element) {
const parRect = parentElement.getBoundingClientRect();
const parentTop = parRect.top;
@@ -49,12 +42,12 @@
const lastInView = getLastVisibleMessage();
if (lastInView) {
const at = DateTime.fromISO(lastInView.dataset.at);
- $channelsMetaList.updateLastReadAt(channel, at);
+ session.local.updateLastReadAt(channel, at);
}
}
$effect(() => {
- const _ = $messages.inChannel(channel);
+ const _ = session.messages;
setLastRead();
});
diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte
index ab214e9..0c960c8 100644
--- a/ui/routes/(app)/me/+page.svelte
+++ b/ui/routes/(app)/me/+page.svelte
@@ -5,14 +5,12 @@
import { goto } from '$app/navigation';
import * as api from '$lib/apiServer.js';
- import { currentUser } from '$lib/store';
let invites = $state([]);
async function logOut() {
const response = await api.logOut();
if (200 <= response.status && response.status < 300) {
- currentUser.set(null);
await goto('/login');
}
}
diff --git a/ui/routes/+layout.svelte b/ui/routes/+layout.svelte
index 6c19a95..b9468c6 100644
--- a/ui/routes/+layout.svelte
+++ b/ui/routes/+layout.svelte
@@ -1,11 +1,11 @@
<script>
import { setContext } from 'svelte';
import { onNavigate } from '$app/navigation';
- import { page } from '$app/stores';
+ import { page } from '$app/state';
import '../app.css';
import logo from '$lib/assets/logo.png';
- import { currentUser } from '$lib/store';
+ const session = $derived(page.data.session);
let pageContext = $state({
showMenu: false
});
@@ -31,9 +31,9 @@
</div>
<a href="/">pilcrow</a>
<div class="trail">
- {#if $currentUser}
+ {#if session}
<div>
- <a href="/me">@{$currentUser.username}</a>
+ <a href="/me">@{session.currentUser.name}</a>
</div>
{/if}
</div>