diff options
| -rw-r--r-- | ui/lib/apiServer.js | 18 | ||||
| -rw-r--r-- | ui/lib/components/ActiveChannel.svelte | 15 | ||||
| -rw-r--r-- | ui/lib/components/Message.svelte | 25 | ||||
| -rw-r--r-- | ui/lib/components/MessageRun.svelte | 10 | ||||
| -rw-r--r-- | ui/lib/outbox.svelte.js | 111 | ||||
| -rw-r--r-- | ui/lib/runs.js | 3 | ||||
| -rw-r--r-- | ui/lib/state/remote/messages.svelte.js | 2 | ||||
| -rw-r--r-- | ui/lib/state/remote/state.svelte.js | 4 | ||||
| -rw-r--r-- | ui/lib/state/remote/users.svelte.js | 19 | ||||
| -rw-r--r-- | ui/routes/(app)/+layout.js | 2 | ||||
| -rw-r--r-- | ui/routes/(app)/+layout.svelte | 40 | ||||
| -rw-r--r-- | ui/routes/(app)/ch/[channel]/+page.svelte | 45 | ||||
| -rw-r--r-- | ui/styles/messages.css | 5 | ||||
| -rw-r--r-- | ui/styles/variables.css | 1 |
14 files changed, 259 insertions, 41 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index cad8997..e682681 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -87,5 +87,21 @@ function responseError(err) { } function isRetryable(err) { - return !!err.request; + // See <https://axios-http.com/docs/handling_errors> for a breakdown of this logic. + + // Any error with a response is non-retryable. The server responded; we get to act on that + // response. We don't do anything special for 5xx-series responses yet, but they might one day be + // retryable too. + if (err.response) { + return false; + } + + // Any error with no response and a request is probably from the network side of things, and is + // retryable. + if (err.request) { + return true; + } + + // Anything with neither is unexpected enough that we should not try it. + return false; } diff --git a/ui/lib/components/ActiveChannel.svelte b/ui/lib/components/ActiveChannel.svelte deleted file mode 100644 index c38d10d..0000000 --- a/ui/lib/components/ActiveChannel.svelte +++ /dev/null @@ -1,15 +0,0 @@ -<script> - import MessageRun from './MessageRun.svelte'; - - let { messageRuns, deleteMessage = async (id) => {} } = $props(); - - $effect(() => { - // This is just to force it to track messageRuns. - const _ = messageRuns; - document.querySelector('.message-run:last-child .message:last-child')?.scrollIntoView(); - }); -</script> - -{#each messageRuns as { sender, ownMessage, messages }} - <MessageRun {sender} {ownMessage} {messages} {deleteMessage} /> -{/each} diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte index edd9d79..5d15d17 100644 --- a/ui/lib/components/Message.svelte +++ b/ui/lib/components/Message.svelte @@ -1,7 +1,15 @@ <script> import { DateTime } from 'luxon'; - let { id, at, body, renderedBody, editable = false, deleteMessage = async (id) => {} } = $props(); + let { + class: cssClass, + id, + at, + body, + renderedBody, + editable = false, + deleteMessage = async (id) => {} + } = $props(); let deleteArmed = $state(false); let atFormatted = $derived(at.toLocaleString(DateTime.DATETIME_SHORT)); @@ -20,10 +28,21 @@ } </script> -<div class="message" class:delete-armed={deleteArmed} role="article" data-at={at} {onmouseleave}> +<div + class={[ + 'message', + { + ['delete-armed']: deleteArmed + }, + cssClass + ]} + role="article" + data-at={at} + {onmouseleave} +> <div class="handle"> {atFormatted} - {#if editable} + {#if editable && id} <button onclick={ondelete}>🗑️</button> {/if} </div> diff --git a/ui/lib/components/MessageRun.svelte b/ui/lib/components/MessageRun.svelte index f1facd3..f6e7ee1 100644 --- a/ui/lib/components/MessageRun.svelte +++ b/ui/lib/components/MessageRun.svelte @@ -1,14 +1,10 @@ <script> - import Message from '$lib/components/Message.svelte'; - - let { sender, messages, ownMessage, deleteMessage = async (id) => {} } = $props(); + let { sender, children, class: cssClass } = $props(); </script> -<div class="message-run" class:own-message={ownMessage} class:other-message={!ownMessage}> +<div class={['message-run', cssClass]}> <span class="username"> @{sender}: </span> - {#each messages as message} - <Message {...message} editable={ownMessage} {deleteMessage} /> - {/each} + {@render children?.()} </div> diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js new file mode 100644 index 0000000..472c58b --- /dev/null +++ b/ui/lib/outbox.svelte.js @@ -0,0 +1,111 @@ +import { DateTime } from 'luxon'; +import * as msg from './state/remote/messages.svelte.js'; + +import * as api from './apiServer.js'; +import * as md from './markdown.js'; + +class PostToChannel { + constructor(channel, body) { + this.channel = channel; + this.body = body; + this.at = DateTime.now(); + this.renderedBody = md.render(body); + } + + toSkeleton(sender) { + return { + id: null, + at: this.at, + channel: this.channel, + sender, + body: this.body, + renderedBody: this.renderedBody + }; + } + + async send() { + return await api.retry(() => api.postToChannel(this.channel, this.body)); + } +} + +class DeleteMessage { + constructor(messageId) { + this.messageId = messageId; + } + + async send() { + return await api.retry(() => api.deleteMessage(this.messageId)); + } +} + +class CreateChannel { + constructor(name) { + this.name = name; + } + + async send() { + return await api.retry(() => api.createChannel(this.name)); + } +} + +export class Outbox { + pending = $state([]); + messages = $derived(this.pending.filter((operation) => operation instanceof PostToChannel)); + deleted = $derived(this.pending.filter((operation) => operation instanceof DeleteMessage)); + + static empty() { + return new Outbox([]); + } + + constructor(pending) { + this.pending = pending; + } + + enqueue(operation) { + this.pending.push(operation); + this.start(); + } + + createChannel(name) { + this.enqueue(new CreateChannel(name)); + } + + postToChannel(channel, body) { + this.enqueue(new PostToChannel(channel, body)); + } + + deleteMessage(messageId) { + this.enqueue(new DeleteMessage(messageId)); + } + + start() { + if (this.sending) { + return; + } + // This is a promise transform primarily to keep the management of `this.sending` in one place, + // rather than spreading it across multiple methods. + this.sending = this.drain().finally(() => { + this.sending = null; + + // If we encounter an exception processing the pending queue, it may have an operation left + // in it. If so, start over. The exception will still propagate out (though since nothing + // ever awaits the promise from this.sending, it'll ultimately leak out to the browser + // anyways). + if (this.pending.length > 0) { + this.start(); + } + }); + } + + async drain() { + while (this.pending.length > 0) { + const operation = this.pending[0]; + + try { + await operation.send(); + } finally { + this.pending.shift(); + } + } + } +} diff --git a/ui/lib/runs.js b/ui/lib/runs.js index f4e90be..de00e6a 100644 --- a/ui/lib/runs.js +++ b/ui/lib/runs.js @@ -1,4 +1,5 @@ import * as iter from './iterator.js'; +import { User } from './state/remote/users.svelte.js'; const RUN_COALESCE_MAX_INTERVAL = 10 /* min */ * 60 /* sec */ * 1000; /* ms */ @@ -21,5 +22,5 @@ function runKey(message) { } function continueRun([lastSender, lastAt], [newSender, newAt]) { - return lastSender === newSender && newAt - lastAt < RUN_COALESCE_MAX_INTERVAL; + return User.equal(lastSender, newSender) && newAt - lastAt < RUN_COALESCE_MAX_INTERVAL; } diff --git a/ui/lib/state/remote/messages.svelte.js b/ui/lib/state/remote/messages.svelte.js index 576a74e..c6d31f0 100644 --- a/ui/lib/state/remote/messages.svelte.js +++ b/ui/lib/state/remote/messages.svelte.js @@ -1,7 +1,7 @@ import { DateTime } from 'luxon'; import { render } from '$lib/markdown.js'; -class Message { +export class Message { static boot({ id, at, channel, sender, body }) { return new Message({ id, diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js index 29831a0..e00d55c 100644 --- a/ui/lib/state/remote/state.svelte.js +++ b/ui/lib/state/remote/state.svelte.js @@ -1,4 +1,4 @@ -import { Users } from './users.svelte.js'; +import { User, Users } from './users.svelte.js'; import { Channels } from './channels.svelte.js'; import { Messages } from './messages.svelte.js'; @@ -10,7 +10,7 @@ export class State { static boot({ currentUser, heartbeat, users, channels, messages, resumePoint }) { return new State({ - currentUser, + currentUser: User.boot(currentUser), heartbeat, users: Users.boot(users), channels: Channels.boot(channels), diff --git a/ui/lib/state/remote/users.svelte.js b/ui/lib/state/remote/users.svelte.js index 617084f..a15d1da 100644 --- a/ui/lib/state/remote/users.svelte.js +++ b/ui/lib/state/remote/users.svelte.js @@ -1,10 +1,25 @@ import { SvelteMap } from 'svelte/reactivity'; +export class User { + static equal(a, b) { + return a.id === b.id && a.name === b.name; + } + + static boot({ id, name }) { + return new User(id, name); + } + + constructor(id, name) { + this.id = id; + this.name = name; + } +} + export class Users { all = $state(); static boot(users) { - const all = new SvelteMap(users.map((user) => [user.id, user])); + const all = new SvelteMap(users.map((user) => [user.id, User.boot(user)])); return new Users({ all }); } @@ -13,6 +28,6 @@ export class Users { } add({ id, name }) { - this.all.set(id, { id, name }); + this.all.set(id, new User(id, name)); } } diff --git a/ui/routes/(app)/+layout.js b/ui/routes/(app)/+layout.js index 651bc8c..9c0afa8 100644 --- a/ui/routes/(app)/+layout.js +++ b/ui/routes/(app)/+layout.js @@ -1,8 +1,10 @@ import * as session from '$lib/session.svelte.js'; +import { Outbox } from '$lib/outbox.svelte.js'; export async function load() { let s = await session.boot(); return { + outbox: Outbox.empty(), session: s }; } diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 1ba3fa9..a4ae442 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -14,7 +14,7 @@ let gesture = null; const { data, children } = $props(); - const { session } = data; + const { session, outbox } = data; onMount(session.begin.bind(session)); onDestroy(session.end.bind(session)); @@ -87,10 +87,46 @@ }); async function createChannel(name) { - await api.createChannel(name); + outbox.createChannel(name); + } + + function onbeforeunload(event) { + if (outbox.pending.length > 0) { + // Prompt the user that they have unsaved (unsent) messages that may be lost. + event.preventDefault(); + } } </script> +<!-- + In theory, we [should be][bfcache-why] using an ephemeral event handler for this, rather than + leaving it hooked up at all times. Some browsers decide whether a page is eligible for + back/forward caching based on whether it has a beforeunload handler (among other factors), and + having the event handler registered can slow down navigation on those browsers by forcing a + network reload when the page could have been restored from memory. + + Most browsers _apparently_ no longer use that criterion, but I would have been inclined to follow + the advice regardless as I don't feel up to the task of cataloguing which browsers it applies to. + Unfortunately, it wouldn't matter if we did: SvelteKit itself registers beforeunload handlers, so + (at least as of this writing) any work we do to try to dynamically register or unregister + beforeunload handlers dynamically state would be wasted effort. + + For posterity, though, the appropriate code for doing so based on outbox state looks like + + $effect(() => { + if (outbox.pending.length > 0) { + window.addEventListener('beforeunload', onbeforeunload); + } + + return () => { + window.removeEventListener('beforeunload', onbeforeunload); + }; + }); + + [bfcache-why]: https://web.dev/articles/bfcache#beforeunload-caution +--> +<svelte:window {onbeforeunload} /> + <svelte:head> <!-- TODO: unread count? --> <title>pilcrow</title> diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index c8507cc..33a9bdf 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -1,18 +1,23 @@ <script> import { DateTime } from 'luxon'; import { page } from '$app/state'; - import ActiveChannel from '$lib/components/ActiveChannel.svelte'; import MessageInput from '$lib/components/MessageInput.svelte'; + import MessageRun from '$lib/components/MessageRun.svelte'; + import Message from '$lib/components/Message.svelte'; import { runs } from '$lib/runs.js'; - import * as api from '$lib/apiServer'; const { data } = $props(); - const { session } = data; + const { session, outbox } = 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)); + const unsent = $derived(outbox.messages.filter((message) => message.channel === channel)); + const deleted = $derived(outbox.deleted.map((message) => message.messageId)); + const unsentSkeletons = $derived( + unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser))) + ); + const messageRuns = $derived(runs(messages.concat(unsentSkeletons), session.currentUser)); function inView(parentElement, element) { const parRect = parentElement.getBoundingClientRect(); @@ -51,6 +56,12 @@ setLastRead(); }); + $effect(() => { + // This is just to force it to track messageRuns. + const _ = messageRuns; + document.querySelector('.message-run:last-child .message:last-child')?.scrollIntoView(); + }); + function handleKeydown(event) { if (event.key === 'Escape') { setLastRead(); // TODO: pass in "last message DT"? @@ -65,18 +76,38 @@ } async function sendMessage(message) { - await api.postToChannel(channel, message); + outbox.postToChannel(channel, message); } async function deleteMessage(id) { - await api.deleteMessage(id); + outbox.deleteMessage(id); } </script> <svelte:window onkeydown={handleKeydown} /> <div class="active-channel" {onscroll} bind:this={activeChannel}> - <ActiveChannel {messageRuns} {deleteMessage} /> + {#each messageRuns as { sender, ownMessage, messages }} + <MessageRun + {sender} + class={{ + ['own-message']: ownMessage, + ['other-message']: !ownMessage + }} + > + {#each messages as message} + <Message + {...message} + editable={ownMessage} + {deleteMessage} + class={{ + unsent: !message.id, + deleted: deleted.includes(message.id) + }} + /> + {/each} + </MessageRun> + {/each} </div> <div class="create-message"> <MessageInput {sendMessage} /> diff --git a/ui/styles/messages.css b/ui/styles/messages.css index eafe44d..ee946c5 100644 --- a/ui/styles/messages.css +++ b/ui/styles/messages.css @@ -47,6 +47,11 @@ text-decoration: underline; } +.message.unsent, +.message.deleted { + color: var(--colour-message-run-unsent-text); +} + .message.delete-armed, .message.delete-armed:hover { background-color: var(--colour-warn); diff --git a/ui/styles/variables.css b/ui/styles/variables.css index 211b236..ea0e9f5 100644 --- a/ui/styles/variables.css +++ b/ui/styles/variables.css @@ -58,6 +58,7 @@ --colour-message-run-self-text: var(--dark-text); --colour-message-run-other-text: var(--dark-text); --colour-message-run-link-text: var(--link-text); + --colour-message-run-unsent-text: var(--light-text); --colour-message-run-username-bg: color-mix(in srgb, var(--colour-base) 70%, white); --colour-message-run-username-border: color-mix(in srgb, var(--colour-base) 50%, black); |
