diff options
| author | Kit La Touche <kit@transneptune.net> | 2024-12-03 13:44:05 -0500 |
|---|---|---|
| committer | Kit La Touche <kit@transneptune.net> | 2024-12-03 13:44:05 -0500 |
| commit | c6fbd21c27277dd76c4dbabf5f1bf24f58142a1a (patch) | |
| tree | 94392c1cafc88e91b14ebbb5a1e39eb86df41bc8 | |
| parent | d89d64a1bd71bbb0f545818794f574fc80046c0f (diff) | |
| parent | 4e3ad13aca163e733724b205c250bdb67cc56c29 (diff) | |
Merge branch 'main' into wip/stylize
| -rw-r--r-- | package-lock.json | 10 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | ui/lib/apiServer.js | 4 | ||||
| -rw-r--r-- | ui/lib/components/Channel.svelte | 8 | ||||
| -rw-r--r-- | ui/lib/components/Message.svelte | 12 | ||||
| -rw-r--r-- | ui/lib/store.js | 2 | ||||
| -rw-r--r-- | ui/lib/store/channels.js | 36 | ||||
| -rw-r--r-- | ui/lib/store/channels.svelte.js | 62 | ||||
| -rw-r--r-- | ui/lib/store/messages.svelte.js | 7 | ||||
| -rw-r--r-- | ui/routes/(app)/+layout.svelte | 32 | ||||
| -rw-r--r-- | ui/routes/(app)/ch/[channel]/+page.svelte | 60 |
11 files changed, 185 insertions, 49 deletions
diff --git a/package-lock.json b/package-lock.json index a7aff66..7d8be51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.7.7", "dompurify": "^3.1.7", + "luxon": "^3.5.0", "marked": "^14.1.3", "tinygesture": "^3.0.0" }, @@ -2644,6 +2645,15 @@ "dev": true, "license": "ISC" }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.12", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", diff --git a/package.json b/package.json index 64b8453..961b44b 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "dependencies": { "axios": "^1.7.7", "dompurify": "^3.1.7", + "luxon": "^3.5.0", "marked": "^14.1.3", "tinygesture": "^3.0.0" } diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index fee1a81..e52daff 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -54,9 +54,9 @@ export async function acceptInvite(inviteId, username, password) { return apiServer.post(`/invite/${inviteId}`, data); } -export function subscribeToEvents(resume_point) { +export function subscribeToEvents(resumePoint) { const eventsUrl = new URL('/api/events', window.location); - eventsUrl.searchParams.append('resume_point', resume_point); + eventsUrl.searchParams.append('resume_point', resumePoint); const evtSource = new EventSource(eventsUrl.toString()); // TODO: this should process all incoming events and store them. // TODO: eventually we'll need to handle expiring old info, so as not to use diff --git a/ui/lib/components/Channel.svelte b/ui/lib/components/Channel.svelte index e84c6d0..01b1c87 100644 --- a/ui/lib/components/Channel.svelte +++ b/ui/lib/components/Channel.svelte @@ -1,10 +1,14 @@ <script> - let { id, name, active } = $props(); + let { id, name, active, hasUnreads } = $props(); </script> <li class="rounded-full" class:bg-slate-400={active}> <a href="/ch/{id}"> - <span class="badge bg-primary-500">#</span> + {#if hasUnreads} + <span class="badge bg-warning-500">❦</span> + {:else} + <span class="badge bg-primary-500">¶</span> + {/if} <span class="flex-auto">{name}</span> </a> </li> diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte index c3e6b16..aa9e3e9 100644 --- a/ui/lib/components/Message.svelte +++ b/ui/lib/components/Message.svelte @@ -1,4 +1,5 @@ <script> + import { DateTime } from 'luxon'; import { deleteMessage } from '$lib/apiServer'; function scroll(message) { @@ -7,6 +8,7 @@ let { id, at, body, renderedBody, editable = false } = $props(); let deleteArmed = $state(false); + let atFormatted = $derived(at.toLocaleString(DateTime.DATETIME_SHORT)); function onDelete(event) { event.preventDefault(); @@ -23,9 +25,15 @@ } </script> -<div class="message relative" class:bg-warning-800={deleteArmed} {onmouseleave} role="article"> +<div + class="message relative" + class:bg-warning-800={deleteArmed} + role="article" + data-at={at} + {onmouseleave} + > <div class="handle chip bg-surface-700 absolute -top-6 right-0"> - {at} + {atFormatted} {#if editable} <button onclick={onDelete}>🗑️</button> {/if} diff --git a/ui/lib/store.js b/ui/lib/store.js index 3b20e05..47ebbc2 100644 --- a/ui/lib/store.js +++ b/ui/lib/store.js @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { Channels } from '$lib/store/channels'; +import { Channels } from '$lib/store/channels.svelte.js'; import { Messages } from '$lib/store/messages.svelte.js'; import { Logins } from '$lib/store/logins'; diff --git a/ui/lib/store/channels.js b/ui/lib/store/channels.js deleted file mode 100644 index 37dc673..0000000 --- a/ui/lib/store/channels.js +++ /dev/null @@ -1,36 +0,0 @@ -export class Channels { - constructor() { - this.channels = []; - } - - setChannels(channels) { - this.channels = [...channels]; - this.sort(); - return this; - } - - addChannel(id, name) { - this.channels = [...this.channels, { id, name }]; - 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; - }); - } -} diff --git a/ui/lib/store/channels.svelte.js b/ui/lib/store/channels.svelte.js new file mode 100644 index 0000000..8919be0 --- /dev/null +++ b/ui/lib/store/channels.svelte.js @@ -0,0 +1,62 @@ +import { DateTime } from 'luxon'; +const EPOCH_STRING = "1970-01-01T00:00:00Z"; + +// For reasons unclear to me, a straight up class definition with a constructor +// doesn't seem to work, reactively. So we resort to this. +// Owen suggests that this sentence in the Svelte docs should make the reason +// clear: +// > If $state is used with an array or a simple object, the result is a deeply +// > reactive state proxy. +// Emphasis on "simple object". +// --Kit +function makeChannelObject({ id, name, draft = '', lastReadAt = null, scrollPosition = null }) { + return { + id, + name, + lastReadAt: lastReadAt || DateTime.fromISO(EPOCH_STRING), + draft, + scrollPosition, + }; +} + +export class Channels { + channels = $state([]); + + getChannel(channelId) { + return this.channels.filter((ch) => ch.id === channelId)[0] || null; + } + + setChannels(channels) { + this.channels = channels.map(makeChannelObject); + this.sort(); + return this; + } + + addChannel(id, name) { + this.channels = [ + ...this.channels, + makeChannelObject({ id, name }), + ]; + 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; + }); + } +} diff --git a/ui/lib/store/messages.svelte.js b/ui/lib/store/messages.svelte.js index 0ceba54..ba4c895 100644 --- a/ui/lib/store/messages.svelte.js +++ b/ui/lib/store/messages.svelte.js @@ -1,17 +1,18 @@ +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({}); + channels = $state({}); // Mapping<ChannelId, Message> inChannel(channel) { - return this.channels[channel]; + return this.channels[channel] || []; } addMessage(channel, id, { at, sender, body }) { - let parsedAt = new Date(at); + let parsedAt = DateTime.fromISO(at); let renderedBody = DOMPurify.sanitize(marked.parse(body, { breaks: true })); const message = { id, at: parsedAt, body, renderedBody }; diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 7d3a9eb..a82d302 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -19,6 +19,35 @@ let loading = $state(true); let channel = $derived($page.params.channel); + let rawChannels; + channelsList.subscribe((val) => { + rawChannels = val.channels; + }); + let rawMessages; + messages.subscribe((val) => { + rawMessages = val; + }); + + let enrichedChannels = $derived.by(() => { + const channels = rawChannels; + const messages = rawMessages; + 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 > ch.lastReadAt; + enrichedChannels.push({ + ...ch, + hasUnreads, + }); + } + } + return enrichedChannels; + }); + function onBooted(boot) { currentUser.set({ id: boot.login.id, @@ -90,6 +119,7 @@ <svelte:window on:beforeunload={beforeUnload}/> <svelte:head> + <!-- TODO: unread count? --> <title>pilcrow</title> </svelte:head> @@ -99,7 +129,7 @@ <div id="interface" class="p-2"> <nav id="sidebar" data-expanded={pageContext.showMenu}> <div class="channel-list"> - <ChannelList active={channel} channels={$channelsList.channels} /> + <ChannelList active={channel} channels={enrichedChannels} /> </div> <div class="create-channel"> <CreateChannelForm /> diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index 6348e5c..d4e04fe 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -1,14 +1,70 @@ <script> + import { DateTime } from 'luxon'; import { page } from '$app/stores'; import ActiveChannel from '$lib/components/ActiveChannel.svelte'; import MessageInput from '$lib/components/MessageInput.svelte'; - import { messages } from '$lib/store'; + import { channelsList, messages } from '$lib/store'; let channel = $derived($page.params.channel); let messageRuns = $derived($messages.inChannel(channel)); + let activeChannel; + + function inView(parentElement, element) { + const parRect = parentElement.getBoundingClientRect(); + const parentTop = parRect.top; + const parentBottom = parRect.bottom; + + const elRect = element.getBoundingClientRect(); + const elementTop = elRect.top; + const elementBottom = elRect.bottom; + + return ((parentTop < elementTop) && (parentBottom > elementBottom)); + } + + function getLastVisibleMessage() { + const parentElement = activeChannel; + const childElements = parentElement.getElementsByClassName('message'); + const lastInView = Array.from(childElements).reverse().find((el) => { + return inView(parentElement, el); + }); + return lastInView; + } + + function setLastRead() { + const channelObject = $channelsList.getChannel(channel); + const lastInView = getLastVisibleMessage(); + if (!channelObject || !lastInView) { + return; + } + const at = DateTime.fromISO(lastInView.dataset.at); + // 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; + } + } + + $effect(() => { + const _ = $messages.inChannel(channel); + setLastRead(); + }); + + function handleKeydown(event) { + if (event.key === 'Escape') { + setLastRead(); // TODO: pass in "last message DT"? + } + } + + let lastReadCallback = null; + function handleScroll() { + clearTimeout(lastReadCallback); // Fine if lastReadCallback is null still. + lastReadCallback = setTimeout(setLastRead, 2 * 1000); + } </script> -<div class="active-channel"> +<svelte:window onkeydown={handleKeydown} /> + +<div class="active-channel" on:scroll={handleScroll} bind:this={activeChannel}> <ActiveChannel {messageRuns} /> </div> <div class="create-message max-h-full"> |
