summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKit La Touche <kit@transneptune.net>2024-12-03 13:44:05 -0500
committerKit La Touche <kit@transneptune.net>2024-12-03 13:44:05 -0500
commitc6fbd21c27277dd76c4dbabf5f1bf24f58142a1a (patch)
tree94392c1cafc88e91b14ebbb5a1e39eb86df41bc8
parentd89d64a1bd71bbb0f545818794f574fc80046c0f (diff)
parent4e3ad13aca163e733724b205c250bdb67cc56c29 (diff)
Merge branch 'main' into wip/stylize
-rw-r--r--package-lock.json10
-rw-r--r--package.json1
-rw-r--r--ui/lib/apiServer.js4
-rw-r--r--ui/lib/components/Channel.svelte8
-rw-r--r--ui/lib/components/Message.svelte12
-rw-r--r--ui/lib/store.js2
-rw-r--r--ui/lib/store/channels.js36
-rw-r--r--ui/lib/store/channels.svelte.js62
-rw-r--r--ui/lib/store/messages.svelte.js7
-rw-r--r--ui/routes/(app)/+layout.svelte32
-rw-r--r--ui/routes/(app)/ch/[channel]/+page.svelte60
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}>&#x1F5D1;&#xFE0F;</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">