summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/lib/apiServer.js6
-rw-r--r--ui/lib/components/ActiveChannel.svelte43
-rw-r--r--ui/lib/components/ChangePassword.svelte60
-rw-r--r--ui/lib/components/Invite.svelte5
-rw-r--r--ui/lib/components/Invites.svelte13
-rw-r--r--ui/lib/components/LogOut.svelte18
-rw-r--r--ui/lib/components/MessageInput.svelte5
-rw-r--r--ui/lib/components/MessageRun.svelte2
-rw-r--r--ui/lib/store.js2
-rw-r--r--ui/lib/store/messages.js40
-rw-r--r--ui/lib/store/messages.svelte.js74
-rw-r--r--ui/routes/(app)/+layout.svelte4
-rw-r--r--ui/routes/(app)/ch/[channel]/+page.svelte4
-rw-r--r--ui/routes/(app)/me/+page.svelte84
-rw-r--r--ui/routes/+layout.svelte4
15 files changed, 194 insertions, 170 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js
index a6fdaa6..6ada0f7 100644
--- a/ui/lib/apiServer.js
+++ b/ui/lib/apiServer.js
@@ -111,7 +111,11 @@ function onMessageEvent(data) {
switch (data.event) {
case 'sent':
messages.update((value) =>
- value.addMessage(data.channel, data.id, data.at, data.sender, data.body)
+ value.addMessage(data.channel, data.id, {
+ at: data.at,
+ sender: data.sender,
+ body: data.body
+ })
);
break;
case 'deleted':
diff --git a/ui/lib/components/ActiveChannel.svelte b/ui/lib/components/ActiveChannel.svelte
index a4ccd24..ba62d6c 100644
--- a/ui/lib/components/ActiveChannel.svelte
+++ b/ui/lib/components/ActiveChannel.svelte
@@ -1,42 +1,11 @@
<script>
- import { messages } from '$lib/store';
import MessageRun from './MessageRun.svelte';
- let { channel } = $props();
- let messageList = $derived(channel !== null ? $messages.inChannel(channel) : []);
-
- function* chunkBy(xs, fn) {
- let chunk;
- let key;
- for (let x of xs) {
- let newKey = fn(x);
- if (key !== newKey) {
- if (chunk !== undefined) {
- yield [key, chunk];
- }
-
- chunk = [x];
- key = newKey;
- } else {
- chunk.push(x);
- }
- }
- if (chunk !== undefined) {
- yield [key, chunk];
- }
- }
+ let { messageRuns } = $props();
</script>
-<div class="container">
- {#each chunkBy(messageList, (msg) => msg.sender) as [sender, messages]}
- <div>
- <MessageRun {sender} {messages} />
- </div>
- {/each}
-</div>
-
-<style>
- .container {
- overflow: auto;
- }
-</style>
+{#each messageRuns as { sender, messages }}
+ <div>
+ <MessageRun {sender} {messages} />
+ </div>
+{/each}
diff --git a/ui/lib/components/ChangePassword.svelte b/ui/lib/components/ChangePassword.svelte
new file mode 100644
index 0000000..1e48bee
--- /dev/null
+++ b/ui/lib/components/ChangePassword.svelte
@@ -0,0 +1,60 @@
+<script>
+ import { changePassword } from '$lib/apiServer.js';
+
+ let currentPassword = $state(''),
+ newPassword = $state(''),
+ confirmPassword = $state(''),
+ pending = $state(false),
+ form;
+ let valid = $derived(newPassword === confirmPassword && newPassword !== currentPassword);
+ let disabled = $derived(pending || !valid);
+
+ async function onsubmit(event) {
+ event.preventDefault();
+ pending = true;
+ let response = await changePassword(currentPassword, newPassword);
+ switch (response.status) {
+ case 200:
+ form.reset();
+ break;
+ }
+ pending = false;
+ }
+</script>
+
+<form {onsubmit} bind:this={form}>
+ <label
+ >current password
+ <input
+ class="input"
+ name="currentPassword"
+ type="password"
+ placeholder="password"
+ bind:value={currentPassword}
+ />
+ </label>
+
+ <label
+ >new password
+ <input
+ class="input"
+ name="newPassword"
+ type="password"
+ placeholder="password"
+ bind:value={newPassword}
+ />
+ </label>
+
+ <label
+ >confirm new password
+ <input
+ class="input"
+ name="confirmPassword"
+ type="password"
+ placeholder="password"
+ bind:value={confirmPassword}
+ />
+ </label>
+
+ <button class="btn bg-orange-500 mt-4" type="submit" {disabled}>change password</button>
+</form>
diff --git a/ui/lib/components/Invite.svelte b/ui/lib/components/Invite.svelte
index 35e00b4..937c911 100644
--- a/ui/lib/components/Invite.svelte
+++ b/ui/lib/components/Invite.svelte
@@ -5,8 +5,5 @@
let inviteUrl = $derived(new URL(`/invite/${id}`, document.location));
</script>
-<button
- class="border-slate-500 border-solid border-2 font-bold p-1 rounded"
- use:clipboard={inviteUrl}>Copy</button
->
+<button class="btn bg-secondary-500" use:clipboard={inviteUrl}>copy</button>
<span data-clipboard="inviteUrl">{inviteUrl}</span>
diff --git a/ui/lib/components/Invites.svelte b/ui/lib/components/Invites.svelte
index cc14f3b..493bf1c 100644
--- a/ui/lib/components/Invites.svelte
+++ b/ui/lib/components/Invites.svelte
@@ -4,7 +4,7 @@
let invites = $state([]);
- async function onSubmit(event) {
+ async function onsubmit(event) {
event.preventDefault();
let response = await createInvite();
if (response.status == 200) {
@@ -13,11 +13,12 @@
}
</script>
-<ul>
+<form {onsubmit}>
+ <button class="btn bg-primary-500" type="submit">create invitation</button>
+</form>
+
+<ul class="mt-4">
{#each invites as invite}
- <li><Invite id={invite.id} /></li>
+ <li class="my-1"><Invite id={invite.id} /></li>
{/each}
</ul>
-<form onsubmit={onSubmit}>
- <button class="btn variant-filled" type="submit"> Create Invitation </button>
-</form>
diff --git a/ui/lib/components/LogOut.svelte b/ui/lib/components/LogOut.svelte
new file mode 100644
index 0000000..25dd5e9
--- /dev/null
+++ b/ui/lib/components/LogOut.svelte
@@ -0,0 +1,18 @@
+<script>
+ import { goto } from '$app/navigation';
+ import { logOut } from '$lib/apiServer.js';
+ import { currentUser } from '$lib/store';
+
+ async function onsubmit(event) {
+ event.preventDefault();
+ const response = await logOut();
+ if (200 <= response.status && response.status < 300) {
+ currentUser.update(() => null);
+ goto('/login');
+ }
+ }
+</script>
+
+<form {onsubmit}>
+ <button class="btn bg-orange-400" type="submit">log out</button>
+</form>
diff --git a/ui/lib/components/MessageInput.svelte b/ui/lib/components/MessageInput.svelte
index 907391c..26521e1 100644
--- a/ui/lib/components/MessageInput.svelte
+++ b/ui/lib/components/MessageInput.svelte
@@ -18,7 +18,8 @@
}
function onKeyDown(event) {
- if (!event.altKey && event.key === 'Enter') {
+ let modifier = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
+ if (!modifier && event.key === 'Enter') {
onSubmit(event);
}
}
@@ -30,7 +31,7 @@
bind:value
{disabled}
type="search"
- class="flex-auto h-6 input rounded-r-none"
+ class="flex-auto h-6 py-0 input rounded-r-none text-nowrap"
></textarea>
<button
color="primary variant-filled-secondary"
diff --git a/ui/lib/components/MessageRun.svelte b/ui/lib/components/MessageRun.svelte
index b71e972..2e8c613 100644
--- a/ui/lib/components/MessageRun.svelte
+++ b/ui/lib/components/MessageRun.svelte
@@ -9,7 +9,7 @@
</script>
<div
- class="card m-4 px-4 py-1 relative"
+ class="card my-4 px-4 py-1 relative"
class:own-message={ownMessage}
class:other-message={!ownMessage}
>
diff --git a/ui/lib/store.js b/ui/lib/store.js
index ae17ffa..3b20e05 100644
--- a/ui/lib/store.js
+++ b/ui/lib/store.js
@@ -1,6 +1,6 @@
import { writable } from 'svelte/store';
import { Channels } from '$lib/store/channels';
-import { Messages } from '$lib/store/messages';
+import { Messages } from '$lib/store/messages.svelte.js';
import { Logins } from '$lib/store/logins';
export const currentUser = writable(null);
diff --git a/ui/lib/store/messages.js b/ui/lib/store/messages.js
deleted file mode 100644
index 62c567a..0000000
--- a/ui/lib/store/messages.js
+++ /dev/null
@@ -1,40 +0,0 @@
-export class Messages {
- constructor() {
- this.channels = {};
- }
-
- inChannel(channel) {
- return (this.channels[channel] = this.channels[channel] || []);
- }
-
- addMessage(channel, id, at, sender, body) {
- this.updateChannel(channel, (messages) => [...messages, { id, at, sender, body }]);
- return this;
- }
-
- setMessages(messages) {
- this.channels = {};
- for (let { channel, id, at, sender, body } of messages) {
- this.inChannel(channel).push({ id, at, sender, body });
- }
- return this;
- }
-
- deleteMessage(message) {
- for (let channel in this.channels) {
- this.updateChannel(channel, (messages) => messages.filter((msg) => msg.id != message));
- }
- return this;
- }
-
- deleteChannel(id) {
- delete this.channels[id];
- return this;
- }
-
- updateChannel(channel, callback) {
- let messages = callback(this.inChannel(channel));
- messages.sort((a, b) => a.at - b.at);
- this.channels[channel] = messages;
- }
-}
diff --git a/ui/lib/store/messages.svelte.js b/ui/lib/store/messages.svelte.js
new file mode 100644
index 0000000..c0db71b
--- /dev/null
+++ b/ui/lib/store/messages.svelte.js
@@ -0,0 +1,74 @@
+const RUN_COALESCE_MAX_INTERVAL = 10 /* min */ * 60 /* sec */ * 1000; /* ms */
+
+export class Messages {
+ channels = $state({});
+
+ inChannel(channel) {
+ return this.channels[channel];
+ }
+
+ addMessage(channel, id, { at, sender, body }) {
+ let parsedAt = new Date(at);
+ const message = { id, at: parsedAt, body };
+
+ // 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.svelte b/ui/routes/(app)/+layout.svelte
index 84090e7..84c71ec 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -91,7 +91,7 @@
</script>
<svelte:head>
- <title>understory</title>
+ <title>pilcrow</title>
</svelte:head>
{#if loading}
@@ -106,7 +106,7 @@
<CreateChannelForm />
</div>
</nav>
- <main>
+ <main class="pl-4">
{@render children?.()}
</main>
</div>
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index 49c1c29..0961665 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -2,12 +2,14 @@
import { page } from '$app/stores';
import ActiveChannel from '$lib/components/ActiveChannel.svelte';
import MessageInput from '$lib/components/MessageInput.svelte';
+ import { messages } from '$lib/store';
let channel = $derived($page.params.channel);
+ let messageRuns = $derived($messages.inChannel(channel));
</script>
<div class="active-channel">
- <ActiveChannel {channel} />
+ <ActiveChannel {messageRuns} />
</div>
<div class="create-message max-h-full">
<MessageInput {channel} />
diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte
index 8d24a61..aded292 100644
--- a/ui/routes/(app)/me/+page.svelte
+++ b/ui/routes/(app)/me/+page.svelte
@@ -1,79 +1,17 @@
<script>
- import { goto } from '$app/navigation';
- import { changePassword, logOut } from '$lib/apiServer.js';
- import { currentUser } from '$lib/store';
-
+ import LogOut from '$lib/components/LogOut.svelte';
import Invites from '$lib/components/Invites.svelte';
-
- let currentPassword = $state(''),
- newPassword = $state(''),
- confirmPassword = $state(''),
- passwordForm;
- let pending = $state(false);
- let valid = $derived(newPassword === confirmPassword && newPassword !== currentPassword);
- let disabled = $derived(pending || !valid);
-
- async function onLogOut(event) {
- event.preventDefault();
- const response = await logOut();
- if (200 <= response.status && response.status < 300) {
- currentUser.update(() => null);
- goto('/login');
- }
- }
-
- async function onPasswordChange(event) {
- event.preventDefault();
- pending = true;
- let response = await changePassword(currentPassword, newPassword);
- switch (response.status) {
- case 200:
- passwordForm.reset();
- break;
- }
- pending = false;
- }
+ import ChangePassword from '$lib/components/ChangePassword.svelte';
</script>
-<form onsubmit={onLogOut}>
- <button class="btn variant-filled" type="submit">log out</button>
-</form>
-
-<form onsubmit={onPasswordChange} bind:this={passwordForm}>
- <label
- >current password
- <input
- class="input"
- name="currentPassword"
- type="password"
- placeholder="password"
- bind:value={currentPassword}
- />
- </label>
-
- <label
- >new password
- <input
- class="input"
- name="newPassword"
- type="password"
- placeholder="password"
- bind:value={newPassword}
- />
- </label>
-
- <label
- >confirm new password
- <input
- class="input"
- name="confirmPassword"
- type="password"
- placeholder="password"
- bind:value={confirmPassword}
- />
- </label>
+<div class="mb-4">
+ <ChangePassword />
+</div>
- <button class="btn variant-filled" type="submit" {disabled}> change password </button>
-</form>
+<div class="mb-4">
+ <Invites />
+</div>
-<Invites />
+<div>
+ <LogOut />
+</div>
diff --git a/ui/routes/+layout.svelte b/ui/routes/+layout.svelte
index ef3e823..8940659 100644
--- a/ui/routes/+layout.svelte
+++ b/ui/routes/+layout.svelte
@@ -30,10 +30,10 @@
<img class="w-8 h-8" alt="logo" src={logo} />
</button>
</svelte:fragment>
- <a href="/">understory</a>
+ <a href="/">pilcrow</a>
<svelte:fragment slot="trail">
{#if $currentUser}
- <div class="rounded-full bg-secondary-400 px-2 py-1">
+ <div class="rounded-full bg-secondary-400 px-3 py-1">
<a href="/me">@{$currentUser.username}</a>
</div>
{/if}