diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2025-03-23 14:33:07 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2025-03-23 14:33:07 -0400 |
| commit | 876472299d67f8fe3a789b7777b9d8ee44297b23 (patch) | |
| tree | db62f5d1e15d871f8a73ce20b40cd53053d12f85 /ui | |
| parent | fa0f653f141efee3f5a01e1fa696d29140ec12c2 (diff) | |
| parent | f788ea84e25a4f7216ca0604aeb216346403b6ef (diff) | |
Merge branch 'prop/restartable-state'
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/lib/constants.js | 3 | ||||
| -rw-r--r-- | ui/lib/index.js | 1 | ||||
| -rw-r--r-- | ui/lib/iterator.js | 35 | ||||
| -rw-r--r-- | ui/lib/iterator.test.js | 93 | ||||
| -rw-r--r-- | ui/lib/markdown.js | 6 | ||||
| -rw-r--r-- | ui/lib/runs.js | 25 | ||||
| -rw-r--r-- | ui/lib/session.svelte.js | 65 | ||||
| -rw-r--r-- | ui/lib/state/local/channels.svelte.js | 118 | ||||
| -rw-r--r-- | ui/lib/state/remote/channels.svelte.js | 22 | ||||
| -rw-r--r-- | ui/lib/state/remote/logins.svelte.js | 18 | ||||
| -rw-r--r-- | ui/lib/state/remote/messages.svelte.js | 52 | ||||
| -rw-r--r-- | ui/lib/state/remote/state.svelte.js | 89 | ||||
| -rw-r--r-- | ui/lib/store.js | 77 | ||||
| -rw-r--r-- | ui/lib/store/channels.svelte.js | 115 | ||||
| -rw-r--r-- | ui/lib/store/logins.js | 22 | ||||
| -rw-r--r-- | ui/lib/store/messages.svelte.js | 79 | ||||
| -rw-r--r-- | ui/routes/(app)/+layout.js | 8 | ||||
| -rw-r--r-- | ui/routes/(app)/+layout.svelte | 125 | ||||
| -rw-r--r-- | ui/routes/(app)/ch/[channel]/+page.svelte | 25 | ||||
| -rw-r--r-- | ui/routes/(app)/me/+page.svelte | 2 | ||||
| -rw-r--r-- | ui/routes/+layout.svelte | 8 |
21 files changed, 578 insertions, 410 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/markdown.js b/ui/lib/markdown.js new file mode 100644 index 0000000..2e73309 --- /dev/null +++ b/ui/lib/markdown.js @@ -0,0 +1,6 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +export function render(body) { + return DOMPurify.sanitize(marked.parse(body, { breaks: true })); +} 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 dadade6..0000000 --- a/ui/lib/store/messages.svelte.js +++ /dev/null @@ -1,79 +0,0 @@ -import { DateTime } from 'luxon'; -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; - -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 = DOMPurify.sanitize(marked.parse(body, { breaks: true })); - 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…</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> |
