summaryrefslogtreecommitdiff
path: root/ui/lib
diff options
context:
space:
mode:
Diffstat (limited to 'ui/lib')
-rw-r--r--ui/lib/apiServer.js9
-rw-r--r--ui/lib/components/ChannelList.svelte13
-rw-r--r--ui/lib/components/Conversation.svelte (renamed from ui/lib/components/Channel.svelte)2
-rw-r--r--ui/lib/components/ConversationList.svelte17
-rw-r--r--ui/lib/components/CreateConversationForm.svelte (renamed from ui/lib/components/CreateChannelForm.svelte)6
-rw-r--r--ui/lib/outbox.svelte.js24
-rw-r--r--ui/lib/session.svelte.js26
-rw-r--r--ui/lib/state/local/channels.svelte.js118
-rw-r--r--ui/lib/state/local/conversations.svelte.js119
-rw-r--r--ui/lib/state/remote/conversations.svelte.js (renamed from ui/lib/state/remote/channels.svelte.js)10
-rw-r--r--ui/lib/state/remote/messages.svelte.js12
-rw-r--r--ui/lib/state/remote/state.svelte.js26
12 files changed, 193 insertions, 189 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js
index 397638c..ac707a5 100644
--- a/ui/lib/apiServer.js
+++ b/ui/lib/apiServer.js
@@ -1,7 +1,6 @@
import axios from 'axios';
import * as r from './retry.js';
-import { timedDelay } from './retry.js';
export const apiServer = axios.create({
baseURL: '/api/',
@@ -27,12 +26,12 @@ export async function changePassword(password, to) {
return await apiServer.post('/password', { password, to }).catch(responseError);
}
-export async function createChannel(name) {
- return await apiServer.post('/channels', { name }).catch(responseError);
+export async function createConversation(name) {
+ return await apiServer.post('/conversations', { name }).catch(responseError);
}
-export async function postToChannel(channelId, body) {
- return await apiServer.post(`/channels/${channelId}`, { body }).catch(responseError);
+export async function sendToConversation(conversationId, body) {
+ return await apiServer.post(`/conversations/${conversationId}`, { body }).catch(responseError);
}
export async function deleteMessage(messageId) {
diff --git a/ui/lib/components/ChannelList.svelte b/ui/lib/components/ChannelList.svelte
deleted file mode 100644
index 51dd6cf..0000000
--- a/ui/lib/components/ChannelList.svelte
+++ /dev/null
@@ -1,13 +0,0 @@
-<script>
- import Channel from './Channel.svelte';
-
- let { channels, active } = $props();
-</script>
-
-<nav class="list-nav">
- <ul>
- {#each channels as channel}
- <Channel {...channel} active={active === channel.id} />
- {/each}
- </ul>
-</nav>
diff --git a/ui/lib/components/Channel.svelte b/ui/lib/components/Conversation.svelte
index 4f908d2..9004e50 100644
--- a/ui/lib/components/Channel.svelte
+++ b/ui/lib/components/Conversation.svelte
@@ -3,7 +3,7 @@
</script>
<li class:active>
- <a href="/ch/{id}">
+ <a href="/c/{id}">
{#if hasUnreads}
<span class="badge has-unreads">❦</span>
{:else}
diff --git a/ui/lib/components/ConversationList.svelte b/ui/lib/components/ConversationList.svelte
new file mode 100644
index 0000000..71332e0
--- /dev/null
+++ b/ui/lib/components/ConversationList.svelte
@@ -0,0 +1,17 @@
+<script>
+ import Conversation from './Conversation.svelte';
+
+ let { conversations, active } = $props();
+
+ function isActive(conversation) {
+ return active === conversation.id;
+ }
+</script>
+
+<nav class="list-nav">
+ <ul>
+ {#each conversations as conversation}
+ <Conversation {...conversation} active={isActive(conversation)} />
+ {/each}
+ </ul>
+</nav>
diff --git a/ui/lib/components/CreateChannelForm.svelte b/ui/lib/components/CreateConversationForm.svelte
index 471c2b7..e390a78 100644
--- a/ui/lib/components/CreateChannelForm.svelte
+++ b/ui/lib/components/CreateConversationForm.svelte
@@ -1,5 +1,5 @@
<script>
- let { createChannel = async (name) => {} } = $props();
+ let { createConversation = async (name) => {} } = $props();
let name = $state('');
let disabled = $state(false);
@@ -8,7 +8,7 @@
event.preventDefault();
disabled = true;
try {
- await createChannel(name);
+ await createConversation(name);
event.target.reset();
} finally {
disabled = false;
@@ -17,6 +17,6 @@
</script>
<form {onsubmit}>
- <input type="text" placeholder="create channel" bind:value={name} {disabled} />
+ <input type="text" placeholder="start a conversation" bind:value={name} {disabled} />
<button type="submit">&#x2795;</button>
</form>
diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js
index 183f8ff..c4e2324 100644
--- a/ui/lib/outbox.svelte.js
+++ b/ui/lib/outbox.svelte.js
@@ -4,9 +4,9 @@ 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;
+class SendToConversation {
+ constructor(conversation, body) {
+ this.conversation = conversation;
this.body = body;
this.at = DateTime.now();
this.renderedBody = md.render(body);
@@ -16,7 +16,7 @@ class PostToChannel {
return {
id: null,
at: this.at,
- channel: this.channel,
+ conversation: this.conversation,
sender,
body: this.body,
renderedBody: this.renderedBody,
@@ -24,7 +24,7 @@ class PostToChannel {
}
async send() {
- return await api.retry(() => api.postToChannel(this.channel, this.body));
+ return await api.retry(() => api.sendToConversation(this.conversation, this.body));
}
}
@@ -38,19 +38,19 @@ class DeleteMessage {
}
}
-class CreateChannel {
+class CreateConversation {
constructor(name) {
this.name = name;
}
async send() {
- return await api.retry(() => api.createChannel(this.name));
+ return await api.retry(() => api.createConversation(this.name));
}
}
export class Outbox {
pending = $state([]);
- messages = $derived(this.pending.filter((operation) => operation instanceof PostToChannel));
+ messages = $derived(this.pending.filter((operation) => operation instanceof SendToConversation));
deleted = $derived(this.pending.filter((operation) => operation instanceof DeleteMessage));
static empty() {
@@ -66,12 +66,12 @@ export class Outbox {
this.start();
}
- createChannel(name) {
- this.enqueue(new CreateChannel(name));
+ createConversation(name) {
+ this.enqueue(new CreateConversation(name));
}
- postToChannel(channel, body) {
- this.enqueue(new PostToChannel(channel, body));
+ sendToConversation(conversationId, body) {
+ this.enqueue(new SendToConversation(conversationId, body));
}
deleteMessage(messageId) {
diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js
index 838401c..4430e8a 100644
--- a/ui/lib/session.svelte.js
+++ b/ui/lib/session.svelte.js
@@ -4,20 +4,20 @@ import { goto } from '$app/navigation';
import * as api from './apiServer.js';
import * as r from './state/remote/state.svelte.js';
-import * as l from './state/local/channels.svelte.js';
+import * as l from './state/local/conversations.svelte.js';
import { Watchdog } from './watchdog.js';
import { DateTime } from 'luxon';
-class Channel {
+class Conversation {
static fromRemote({ at, id, name }, messages, meta) {
const sentAt = messages
- .filter((message) => message.channel === id)
+ .filter((message) => message.conversation === id)
.map((message) => message.at);
const lastEventAt = DateTime.max(at, ...sentAt);
const lastReadAt = meta.get(id)?.lastReadAt;
const hasUnreads = lastReadAt === undefined || lastEventAt > lastReadAt;
- return new Channel({ at, id, name, hasUnreads });
+ return new Conversation({ at, id, name, hasUnreads });
}
constructor({ at, id, name, hasUnreads }) {
@@ -29,21 +29,21 @@ class Channel {
}
class Message {
- static fromRemote({ id, at, channel, sender, body, renderedBody }, users) {
+ static fromRemote({ id, at, conversation, sender, body, renderedBody }, users) {
return new Message({
id,
at,
- channel,
+ conversation,
sender: users.get(sender),
body,
renderedBody,
});
}
- constructor({ id, at, channel, sender, body, renderedBody }) {
+ constructor({ id, at, conversation, sender, body, renderedBody }) {
this.id = id;
this.at = at;
- this.channel = channel;
+ this.conversation = conversation;
this.sender = sender;
this.body = body;
this.renderedBody = renderedBody;
@@ -58,9 +58,9 @@ class Session {
messages = $derived(
this.remote.messages.all.map((message) => Message.fromRemote(message, this.users)),
);
- channels = $derived(
- this.remote.channels.all.map((channel) =>
- Channel.fromRemote(channel, this.messages, this.local.all),
+ conversations = $derived(
+ this.remote.conversations.all.map((conversation) =>
+ Conversation.fromRemote(conversation, this.messages, this.local.all),
),
);
@@ -71,7 +71,7 @@ class Session {
heartbeat,
events,
});
- const local = l.Channels.fromLocalStorage();
+ const local = l.Conversations.fromLocalStorage();
return new Session(remote, local);
}
@@ -109,7 +109,7 @@ class Session {
onMessage(message) {
const event = JSON.parse(message.data);
this.remote.onEvent(event);
- this.local.retainChannels(this.remote.channels.all);
+ this.local.retainConversations(this.remote.conversations.all);
this.watchdog.reset(this.heartbeatMillis());
}
diff --git a/ui/lib/state/local/channels.svelte.js b/ui/lib/state/local/channels.svelte.js
deleted file mode 100644
index 669aa1e..0000000
--- a/ui/lib/state/local/channels.svelte.js
+++ /dev/null
@@ -1,118 +0,0 @@
-import { DateTime } from 'luxon';
-import { SvelteMap } from 'svelte/reactivity';
-
-import * as iter from '$lib/iterator.js';
-
-export const STORE_KEY_CHANNELS_DATA = 'pilcrow:channelsData';
-
-class Channel {
- draft = $state();
- lastReadAt = $state(null);
- scrollPosition = $state(null);
-
- static fromStored({ draft, lastReadAt, scrollPosition }) {
- return new Channel({
- draft,
- lastReadAt: lastReadAt == null ? null : DateTime.fromISO(lastReadAt),
- scrollPosition,
- });
- }
-
- constructor({ draft = '', lastReadAt = null, scrollPosition = null } = {}) {
- this.draft = draft;
- this.lastReadAt = lastReadAt;
- this.scrollPosition = scrollPosition;
- }
-
- toStored() {
- const { draft, lastReadAt, scrollPosition } = this;
- return {
- draft,
- lastReadAt: lastReadAt?.toISO(),
- scrollPosition,
- };
- }
-}
-
-export class Channels {
- // Store channelId -> { draft = '', lastReadAt = null, scrollPosition = null }
- all = $state();
-
- static fromLocalStorage() {
- const stored = localStorage.getItem(STORE_KEY_CHANNELS_DATA);
- if (stored !== null) {
- return Channels.fromStored(JSON.parse(stored));
- }
- return Channels.empty();
- }
-
- static fromStored(stored) {
- const loaded = Object.keys(stored).map((channelId) => [
- channelId,
- Channel.fromStored(stored[channelId]),
- ]);
- const all = new SvelteMap(loaded);
- return new Channels({ all });
- }
-
- static empty() {
- return new Channels({ all: new SvelteMap() });
- }
-
- constructor({ all }) {
- this.all = all;
- }
-
- channel(channelId) {
- let channel = this.all.get(channelId);
- if (channel === undefined) {
- channel = new Channel();
- this.all.set(channelId, channel);
- }
- return channel;
- }
-
- updateLastReadAt(channelId, at) {
- const channel = this.channel(channelId);
- // Do it this way, rather than with Math.max tricks, to avoid assignment
- // when we don't need it, to minimize reactive changes:
- if (channel.lastReadAt === null || at > channel.lastReadAt) {
- channel.lastReadAt = at;
- this.save();
- }
- }
-
- retainChannels(channels) {
- const channelIds = channels.map((channel) => channel.id);
- const retain = new Set(channelIds);
- for (const channelId of Array.from(this.all.keys())) {
- if (!retain.has(channelId)) {
- this.all.delete(channelId);
- }
- }
- this.save();
- }
-
- toStored() {
- return iter.reduce(
- this.all.entries(),
- (stored, [channelId, channel]) => ({
- ...stored,
- [channelId]: channel.toStored(),
- }),
- {},
- );
- }
-
- save() {
- let stored = this.toStored();
- localStorage.setItem(STORE_KEY_CHANNELS_DATA, JSON.stringify(stored));
- }
-}
-
-function objectMap(object, mapFn) {
- return Object.keys(object).reduce((result, key) => {
- result[key] = mapFn(object[key]);
- return result;
- }, {});
-}
diff --git a/ui/lib/state/local/conversations.svelte.js b/ui/lib/state/local/conversations.svelte.js
new file mode 100644
index 0000000..835c237
--- /dev/null
+++ b/ui/lib/state/local/conversations.svelte.js
@@ -0,0 +1,119 @@
+import { DateTime } from 'luxon';
+import { SvelteMap } from 'svelte/reactivity';
+
+import * as iter from '$lib/iterator.js';
+
+// Conversations were called "channels" in previous iterations. Support loading
+// data saved under that name to prevent the change from resetting everyone's
+// unread tracking.
+export const STORE_KEY_CHANNELS = 'pilcrow:channelsData';
+export const STORE_KEY_CONVERSATIONS = 'pilcrow:conversations';
+
+class Conversation {
+ draft = $state();
+ lastReadAt = $state(null);
+ scrollPosition = $state(null);
+
+ static fromStored({ draft, lastReadAt, scrollPosition }) {
+ return new Conversation({
+ draft,
+ lastReadAt: lastReadAt == null ? null : DateTime.fromISO(lastReadAt),
+ scrollPosition,
+ });
+ }
+
+ constructor({ draft = '', lastReadAt = null, scrollPosition = null } = {}) {
+ this.draft = draft;
+ this.lastReadAt = lastReadAt;
+ this.scrollPosition = scrollPosition;
+ }
+
+ toStored() {
+ const { draft, lastReadAt, scrollPosition } = this;
+ return {
+ draft,
+ lastReadAt: lastReadAt?.toISO(),
+ scrollPosition,
+ };
+ }
+}
+
+export class Conversations {
+ // Store conversationId -> { draft = '', lastReadAt = null, scrollPosition = null }
+ all = $state();
+
+ static fromLocalStorage() {
+ const stored =
+ localStorage.getItem(STORE_KEY_CONVERSATIONS) ?? localStorage.getItem(STORE_KEY_CHANNELS);
+ if (stored !== null) {
+ return Conversations.fromStored(JSON.parse(stored));
+ }
+ return Conversations.empty();
+ }
+
+ static fromStored(stored) {
+ const loaded = Object.keys(stored).map((conversationId) => [
+ conversationId,
+ Conversation.fromStored(stored[conversationId]),
+ ]);
+ const all = new SvelteMap(loaded);
+ return new Conversations({ all });
+ }
+
+ static empty() {
+ return new Conversations({ all: new SvelteMap() });
+ }
+
+ constructor({ all }) {
+ this.all = all;
+ }
+
+ conversation(conversationId) {
+ let conversation = this.all.get(conversationId);
+ if (conversation === undefined) {
+ conversation = new Conversation();
+ this.all.set(conversationId, conversation);
+ }
+ return conversation;
+ }
+
+ updateLastReadAt(conversationId, at) {
+ const conversation = this.conversation(conversationId);
+ // Do it this way, rather than with Math.max tricks, to avoid assignment
+ // when we don't need it, to minimize reactive changes:
+ if (conversation.lastReadAt === null || at > conversation.lastReadAt) {
+ conversation.lastReadAt = at;
+ this.save();
+ }
+ }
+
+ retainConversations(conversations) {
+ const conversationIds = conversations.map((conversation) => conversation.id);
+ const retain = new Set(conversationIds);
+ for (const conversationId of Array.from(this.all.keys())) {
+ if (!retain.has(conversationId)) {
+ this.all.delete(conversationId);
+ }
+ }
+ this.save();
+ }
+
+ toStored() {
+ return iter.reduce(
+ this.all.entries(),
+ (stored, [conversationId, conversation]) => ({
+ ...stored,
+ [conversationId]: conversation.toStored(),
+ }),
+ {},
+ );
+ }
+
+ save() {
+ let stored = this.toStored();
+ localStorage.setItem(STORE_KEY_CONVERSATIONS, JSON.stringify(stored));
+ // If we were able to save the data under `pilcrow:conversations`, then remove the old data;
+ // it is no longer needed and wouldn't be loaded anyways.
+ localStorage.removeItem(STORE_KEY_CHANNELS);
+ }
+}
diff --git a/ui/lib/state/remote/channels.svelte.js b/ui/lib/state/remote/conversations.svelte.js
index 1e40075..79868f4 100644
--- a/ui/lib/state/remote/channels.svelte.js
+++ b/ui/lib/state/remote/conversations.svelte.js
@@ -1,8 +1,8 @@
import { DateTime } from 'luxon';
-class Channel {
+class Conversation {
static boot({ at, id, name }) {
- return new Channel({
+ return new Conversation({
at: DateTime.fromISO(at),
id,
name,
@@ -16,14 +16,14 @@ class Channel {
}
}
-export class Channels {
+export class Conversations {
all = $state([]);
add({ at, id, name }) {
- this.all.push(Channel.boot({ at, id, name }));
+ this.all.push(Conversation.boot({ at, id, name }));
}
remove(id) {
- this.all = this.all.filter((channel) => channel.id !== id);
+ this.all = this.all.filter((conversation) => conversation.id !== id);
}
}
diff --git a/ui/lib/state/remote/messages.svelte.js b/ui/lib/state/remote/messages.svelte.js
index 1be001b..852f29e 100644
--- a/ui/lib/state/remote/messages.svelte.js
+++ b/ui/lib/state/remote/messages.svelte.js
@@ -2,21 +2,21 @@ import { DateTime } from 'luxon';
import { render } from '$lib/markdown.js';
class Message {
- static boot({ id, at, channel, sender, body }) {
+ static boot({ id, at, conversation, sender, body }) {
return new Message({
id,
at: DateTime.fromISO(at),
- channel,
+ conversation,
sender,
body,
renderedBody: render(body),
});
}
- constructor({ id, at, channel, sender, body, renderedBody }) {
+ constructor({ id, at, conversation, sender, body, renderedBody }) {
this.id = id;
this.at = at;
- this.channel = channel;
+ this.conversation = conversation;
this.sender = sender;
this.body = body;
this.renderedBody = renderedBody;
@@ -26,8 +26,8 @@ class Message {
export class Messages {
all = $state([]);
- add({ id, at, channel, sender, body }) {
- const message = Message.boot({ id, at, channel, sender, body });
+ add({ id, at, conversation, sender, body }) {
+ const message = Message.boot({ id, at, conversation, sender, body });
this.all.push(message);
}
diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js
index fb46489..3d65e4a 100644
--- a/ui/lib/state/remote/state.svelte.js
+++ b/ui/lib/state/remote/state.svelte.js
@@ -1,11 +1,11 @@
import { User, Users } from './users.svelte.js';
-import { Channels } from './channels.svelte.js';
+import { Conversations } from './conversations.svelte.js';
import { Messages } from './messages.svelte.js';
export class State {
currentUser = $state();
users = $state(new Users());
- channels = $state(new Channels());
+ conversations = $state(new Conversations());
messages = $state(new Messages());
static boot({ currentUser, heartbeat, resumePoint, events }) {
@@ -30,8 +30,8 @@ export class State {
// Heartbeats are actually completely ignored here. They're handled in `Session`, but not as a
// special case; _any_ event is a heartbeat event.
switch (event.type) {
- case 'channel':
- return this.onChannelEvent(event);
+ case 'conversation':
+ return this.onConversationEvent(event);
case 'user':
return this.onUserEvent(event);
case 'message':
@@ -39,23 +39,23 @@ export class State {
}
}
- onChannelEvent(event) {
+ onConversationEvent(event) {
switch (event.event) {
case 'created':
- return this.onChannelCreated(event);
+ return this.onConversationCreated(event);
case 'deleted':
- return this.onChannelDeleted(event);
+ return this.onConversationDeleted(event);
}
}
- onChannelCreated(event) {
+ onConversationCreated(event) {
const { id, name } = event;
- this.channels.add({ id, name });
+ this.conversations.add({ id, name });
}
- onChannelDeleted(event) {
+ onConversationDeleted(event) {
const { id } = event;
- this.channels.remove(id);
+ this.conversations.remove(id);
}
onUserEvent(event) {
@@ -80,8 +80,8 @@ export class State {
}
onMessageSent(event) {
- const { id, at, channel, sender, body } = event;
- this.messages.add({ id, at, channel, sender, body });
+ const { id, at, conversation, sender, body } = event;
+ this.messages.add({ id, at, conversation, sender, body });
}
onMessageDeleted(event) {