summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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>