summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorojacobson <ojacobson@noreply.codeberg.org>2025-07-04 05:00:21 +0200
committerojacobson <ojacobson@noreply.codeberg.org>2025-07-04 05:00:21 +0200
commitc35be3ae29e77983f013c01260dda20208175f2b (patch)
treeabf0b9d993ef03a53903aae03f375b78473952da /ui
parent981cd3c0f4cf912c1d91ee5d9c39f5c1aa7afecf (diff)
parent9b38cb1a62ede4900fde4ba47a7b065db329e994 (diff)
Rename "channels" to "conversations."
The term "channel" for a conversational container has a long and storied history, but is mostly evocative of IRC and of other, ah, "nerd-centric" services. It does show up in more widespread contexts: Discord and Slack both refer to their primary conversational containers as "channels," for example. However, I think it's unnecessary jargon, and I'd like to do away with it. To that end, this change pervasively changes one term to the other wherever it appears, with the following exceptions: * A `channel` concept (unrelated to conversations) is also provided by an external library; we can't and shouldn't try to rename that. * The code to deal with the `pilcrow:channelData` and `pilcrow:lastActiveChannel` local storage properties is still present, to migrate existing data to new keys. It will be removed in a later change. This is a **breaking API change**. As we are not yet managing any API compatibility promises, this is formally not an issue, but it is something to be aware of practically. The major API changes are: * Paths beginning with `/api/channels` are now under `/api/conversations`, without other modifications. * Fields labelled with `channel…` terms are now labelled with `conversation…` terms. For example, a `message` `sent` event is now sent to a `conversation`, not a `channel`. This is also a **breaking UI change**. Specifically, any saved paths for `/ch/CHANNELID` will now lead to a 404. The corresponding paths are `/c/CONVERSATIONID`. While I've made an effort to migrate the location of stored data, I have not tried to provide adapters to fix this specific issue, because the disruption is short-lived and very easily addressed by opening a channel in the client UI. This change is obnoxiously large and difficult to review, for which I apologize. If this shows up in `git annotate`, please forgive me. These kinds of renamings are hard to carry out without a major disruption, especially when the concept ("channel" in this case) is used so pervasively throughout the system. I think it's worth making this change that pervasively so that we don't have an indefinitely-long tail of "well, it's a conversation in the docs, but the table is called `channel` for historical reasons" type issues. Merges conversations-not-channels into main.
Diffstat (limited to 'ui')
-rw-r--r--ui/app.css6
-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
-rw-r--r--ui/routes/(app)/+layout.svelte46
-rw-r--r--ui/routes/(app)/+page.svelte4
-rw-r--r--ui/routes/(app)/c/[conversation]/+page.svelte (renamed from ui/routes/(app)/ch/[channel]/+page.svelte)30
-rw-r--r--ui/styles/active-conversation.css (renamed from ui/styles/active-channel.css)2
-rw-r--r--ui/styles/overscroll.css11
-rw-r--r--ui/styles/sidebar.css12
-rw-r--r--ui/styles/variables.css4
-rw-r--r--ui/tests/lib/components/CreateChannelForm.svelte.test.js18
-rw-r--r--ui/tests/lib/components/MessageInput.svelte.test.js2
22 files changed, 267 insertions, 250 deletions
diff --git a/ui/app.css b/ui/app.css
index 79f36eb..34a74b7 100644
--- a/ui/app.css
+++ b/ui/app.css
@@ -5,14 +5,14 @@
@import url('styles/app-bar.css');
@import url('styles/app-layout.css');
@import url('styles/sidebar.css');
-@import url('styles/active-channel.css');
+@import url('styles/active-conversation.css');
@import url('styles/messages.css');
@import url('styles/textarea.css');
@import url('styles/forms.css');
@import url('styles/invites.css');
body {
- background-color: var(--colour-active-channel-bg);
+ background-color: var(--colour-active-conversation-bg);
color: var(--dark-text);
font-family: 'Roboto', sans-serif;
}
@@ -21,7 +21,7 @@ hr {
width: 90%;
}
-.no-active-channel {
+.no-active-conversation {
display: table;
height: 100%;
width: 100%;
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) {
diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte
index c7e1f22..658d966 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -7,9 +7,8 @@
import TinyGesture from 'tinygesture';
- import * as api from '$lib/apiServer.js';
- import ChannelList from '$lib/components/ChannelList.svelte';
- import CreateChannelForm from '$lib/components/CreateChannelForm.svelte';
+ import ConversationList from '$lib/components/ConversationList.svelte';
+ import CreateConversationForm from '$lib/components/CreateConversationForm.svelte';
let gesture = null;
@@ -20,9 +19,9 @@
onDestroy(session.end.bind(session));
let pageContext = getContext('page');
- let channel = $derived(page.params.channel);
+ let conversationId = $derived(page.params.conversation);
- let channels = $derived(session.channels);
+ let conversations = $derived(session.conversations);
function setUpGestures() {
if (!browser) {
@@ -46,28 +45,35 @@
}
});
- const STORE_KEY_LAST_ACTIVE = 'pilcrow:lastActiveChannel';
+ // Automatically migrate last-active-channel info now that we call them "conversations."
+ const STORE_KEY_LAST_ACTIVE = 'pilcrow:lastActiveConversation';
+ const STORE_KEY_LAST_ACTIVE_CHANNEL = 'pilcrow:lastActiveChannel';
- function getLastActiveChannel() {
- return browser && JSON.parse(localStorage.getItem(STORE_KEY_LAST_ACTIVE));
+ function getLastActiveConversation() {
+ const stored =
+ localStorage.getItem(STORE_KEY_LAST_ACTIVE) ??
+ localStorage.getItem(STORE_KEY_LAST_ACTIVE_CHANNEL);
+ return JSON.parse(stored);
}
- function setLastActiveChannel(channelId) {
- browser && localStorage.setItem(STORE_KEY_LAST_ACTIVE, JSON.stringify(channelId));
+ function setLastActiveConversation(conversationId) {
+ localStorage.setItem(STORE_KEY_LAST_ACTIVE, JSON.stringify(conversationId));
+ // Once we've saved to the new key, we no longer need the old one. Clean it up.
+ localStorage.removeItem(STORE_KEY_LAST_ACTIVE_CHANNEL);
}
afterNavigate(() => {
- const lastActiveChannel = getLastActiveChannel();
+ const conversationId = getLastActiveConversation();
const inRoot = page.url.pathname === '/';
- if (inRoot && lastActiveChannel) {
- goto(`/ch/${lastActiveChannel}`);
- } else if (channel) {
- setLastActiveChannel(channel || null);
+ if (inRoot && conversationId) {
+ goto(`/c/${conversationId}`);
+ } else if (conversationId) {
+ setLastActiveConversation(conversationId || null);
}
});
- async function createChannel(name) {
- outbox.createChannel(name);
+ async function createConversation(name) {
+ outbox.createConversation(name);
}
function onbeforeunload(event) {
@@ -114,9 +120,9 @@
<div id="interface">
<nav id="sidebar" data-expanded={pageContext.showMenu}>
- <ChannelList active={channel} {channels} />
- <div class="create-channel">
- <CreateChannelForm {createChannel} />
+ <ConversationList active={conversationId} {conversations} />
+ <div class="create-conversation">
+ <CreateConversationForm {createConversation} />
</div>
</nav>
<main>
diff --git a/ui/routes/(app)/+page.svelte b/ui/routes/(app)/+page.svelte
index 007c5c6..1db0eb2 100644
--- a/ui/routes/(app)/+page.svelte
+++ b/ui/routes/(app)/+page.svelte
@@ -1,3 +1,3 @@
-<div class="no-active-channel">
- <span class="vertical-aligner"> Please select or create a channel. </span>
+<div class="no-active-conversation">
+ <span class="vertical-aligner">Please select or create a conversation.</span>
</div>
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/c/[conversation]/+page.svelte
index 87918f7..e6cd845 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/c/[conversation]/+page.svelte
@@ -8,12 +8,18 @@
const { data } = $props();
const { session, outbox } = data;
- let activeChannel;
+ let activeConversation;
- const channelId = $derived(page.params.channel);
- const channel = $derived(session.channels.find((channel) => channel.id === channelId));
- const messages = $derived(session.messages.filter((message) => message.channel === channelId));
- const unsent = $derived(outbox.messages.filter((message) => message.channel === channelId));
+ const conversationId = $derived(page.params.conversation);
+ const conversation = $derived(
+ session.conversations.find((conversation) => conversation.id === conversationId),
+ );
+ const messages = $derived(
+ session.messages.filter((message) => message.conversation === conversationId),
+ );
+ const unsent = $derived(
+ outbox.messages.filter((message) => message.conversation === conversationId),
+ );
const deleted = $derived(outbox.deleted.map((message) => message.messageId));
const unsentSkeletons = $derived(
unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser))),
@@ -33,12 +39,12 @@
}
function getLastVisibleMessage() {
- if (activeChannel) {
- const childElements = activeChannel.getElementsByClassName('message');
+ if (activeConversation) {
+ const childElements = activeConversation.getElementsByClassName('message');
const lastInView = Array.from(childElements)
.reverse()
.find((el) => {
- return inView(activeChannel, el);
+ return inView(activeConversation, el);
});
return lastInView;
}
@@ -46,9 +52,9 @@
function setLastRead() {
const lastInView = getLastVisibleMessage();
- const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : channel?.at;
+ const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : conversation?.at;
if (!!at) {
- session.local.updateLastReadAt(channelId, at);
+ session.local.updateLastReadAt(conversationId, at);
}
}
@@ -77,7 +83,7 @@
}
async function sendMessage(message) {
- outbox.postToChannel(channelId, message);
+ outbox.sendToConversation(conversationId, message);
}
async function deleteMessage(id) {
@@ -87,7 +93,7 @@
<svelte:window onkeydown={handleKeydown} />
-<div class="active-channel" {onscroll} bind:this={activeChannel}>
+<div class="active-conversation" {onscroll} bind:this={activeConversation}>
{#each messageRuns as { sender, ownMessage, messages }}
<MessageRun
{sender}
diff --git a/ui/styles/active-channel.css b/ui/styles/active-conversation.css
index d6a9b42..981862b 100644
--- a/ui/styles/active-channel.css
+++ b/ui/styles/active-conversation.css
@@ -1,4 +1,4 @@
-.active-channel {
+.active-conversation {
padding-left: 1rem;
padding-right: 1rem;
overflow: auto;
diff --git a/ui/styles/overscroll.css b/ui/styles/overscroll.css
index 8898f9a..a54235c 100644
--- a/ui/styles/overscroll.css
+++ b/ui/styles/overscroll.css
@@ -1,8 +1,9 @@
-/* This should help minimize swipe-to-go-back behaviour, enabling our
-* swipe-to-reveal-channel-menu behaviour. It won't work in all cases; in iOS
-* Safari, when swiping from the screen edge, the OS gets th event and
-* handles it before the browser does.
-*/
+/*
+ * This should help minimize swipe-to-go-back behaviour, enabling our
+ * swipe-to-reveal-conversation-menu behaviour. It won't work in all cases; in
+ * iOS Safari, when swiping from the screen edge, the OS gets the event and
+ * handles it before the browser does.
+ */
html,
body {
overscroll-behavior-x: none;
diff --git a/ui/styles/sidebar.css b/ui/styles/sidebar.css
index b825545..aa0d53b 100644
--- a/ui/styles/sidebar.css
+++ b/ui/styles/sidebar.css
@@ -1,4 +1,4 @@
-/* Sidebar and channel selector */
+/* Sidebar and conversation selector */
#sidebar {
background-color: var(--colour-navbar-bg);
}
@@ -38,19 +38,19 @@
color: var(--colour-navbar-hover-text);
}
-/* create channel form */
-.create-channel {
+/* create conversation form */
+.create-conversation {
padding-left: 0.5rem;
}
-.create-channel form {
+.create-conversation form {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
}
-.create-channel input {
+.create-conversation input {
padding: 0.5rem;
border-radius: 0.5rem 0 0 0.5rem;
border: 1px solid var(--colour-input-border);
@@ -60,7 +60,7 @@
color: var(--colour-input-text);
}
-.create-channel button {
+.create-conversation button {
border-radius: 0 0.5rem 0.5rem 0;
border: 1px solid var(--colour-input-border);
background-color: var(--colour-input-bg);
diff --git a/ui/styles/variables.css b/ui/styles/variables.css
index 2758aa1..99705f2 100644
--- a/ui/styles/variables.css
+++ b/ui/styles/variables.css
@@ -40,8 +40,8 @@
--colour-input-border: color-mix(in srgb, var(--colour-input-bg) 50%, black);
--colour-input-text: var(--dark-text);
- /* Active channel */
- --colour-active-channel-bg: color-mix(in srgb, var(--colour-base) 25%, white);
+ /* Active conversation */
+ --colour-active-conversation-bg: color-mix(in srgb, var(--colour-base) 25%, white);
/* MessageRun */
--colour-message-run-self-bg: color-mix(in srgb, var(--colour-base) 30%, white);
diff --git a/ui/tests/lib/components/CreateChannelForm.svelte.test.js b/ui/tests/lib/components/CreateChannelForm.svelte.test.js
index 197cb6b..8c7b3fb 100644
--- a/ui/tests/lib/components/CreateChannelForm.svelte.test.js
+++ b/ui/tests/lib/components/CreateChannelForm.svelte.test.js
@@ -1,37 +1,37 @@
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, describe, it, vi } from 'vitest';
-import CreateChannelForm from '$lib/components/CreateChannelForm.svelte';
+import CreateConversationForm from '$lib/components/CreateConversationForm.svelte';
const user = userEvent.setup();
const mocks = vi.hoisted(() => ({
- createChannel: vi.fn(),
+ createConversation: vi.fn(),
}));
-describe('CreateChannelForm', async () => {
+describe('CreateConversationForm', async () => {
beforeEach(async () => {
- render(CreateChannelForm, {
- createChannel: mocks.createChannel,
+ render(CreateConversationForm, {
+ createConversation: mocks.createConversation,
});
});
- describe('creates channels', async () => {
+ describe('creates conversations', async () => {
it('with a non-empty name', async () => {
const input = screen.getByRole('textbox');
- await user.type(input, 'channel name');
+ await user.type(input, 'conversation name');
const create = screen.getByRole('button');
await user.click(create);
- expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith('channel name');
+ expect(mocks.createConversation).toHaveBeenCalledExactlyOnceWith('conversation name');
});
it('with an empty name', async () => {
const create = screen.getByRole('button');
await user.click(create);
- expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith('');
+ expect(mocks.createConversation).toHaveBeenCalledExactlyOnceWith('');
});
});
});
diff --git a/ui/tests/lib/components/MessageInput.svelte.test.js b/ui/tests/lib/components/MessageInput.svelte.test.js
index c32ce11..b459737 100644
--- a/ui/tests/lib/components/MessageInput.svelte.test.js
+++ b/ui/tests/lib/components/MessageInput.svelte.test.js
@@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({
sendMessage: vi.fn(),
}));
-describe('CreateChannelForm', async () => {
+describe('MessageInput', async () => {
beforeEach(async () => {
render(MessageInput, {
sendMessage: mocks.sendMessage,