summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ui/lib/apiServer.js18
-rw-r--r--ui/lib/components/ActiveChannel.svelte15
-rw-r--r--ui/lib/components/Message.svelte25
-rw-r--r--ui/lib/components/MessageRun.svelte10
-rw-r--r--ui/lib/outbox.svelte.js111
-rw-r--r--ui/lib/runs.js3
-rw-r--r--ui/lib/state/remote/messages.svelte.js2
-rw-r--r--ui/lib/state/remote/state.svelte.js4
-rw-r--r--ui/lib/state/remote/users.svelte.js19
-rw-r--r--ui/routes/(app)/+layout.js2
-rw-r--r--ui/routes/(app)/+layout.svelte40
-rw-r--r--ui/routes/(app)/ch/[channel]/+page.svelte45
-rw-r--r--ui/styles/messages.css5
-rw-r--r--ui/styles/variables.css1
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}>&#x1F5D1;&#xFE0F;</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);