diff options
| -rw-r--r-- | ui/lib/constants.js | 4 | ||||
| -rw-r--r-- | ui/lib/store.js | 12 | ||||
| -rw-r--r-- | ui/lib/store/channels.svelte.js | 102 | ||||
| -rw-r--r-- | ui/routes/(app)/+layout.svelte | 7 | ||||
| -rw-r--r-- | ui/routes/(app)/ch/[channel]/+page.svelte | 31 | ||||
| -rw-r--r-- | ui/routes/+layout.svelte | 30 |
6 files changed, 141 insertions, 45 deletions
diff --git a/ui/lib/constants.js b/ui/lib/constants.js new file mode 100644 index 0000000..5456414 --- /dev/null +++ b/ui/lib/constants.js @@ -0,0 +1,4 @@ +export const EPOCH_STRING = '1970-01-01T00:00:00Z'; + +export const STORE_KEY_CHANNELS_DATA = 'pilcrow:channelsData'; +export const STORE_KEY_LAST_ACTIVE = 'pilcrow:lastActiveChannel'; diff --git a/ui/lib/store.js b/ui/lib/store.js index c179dac..afced4c 100644 --- a/ui/lib/store.js +++ b/ui/lib/store.js @@ -1,11 +1,19 @@ import { writable } from 'svelte/store'; -import { Channels } from '$lib/store/channels.svelte.js'; +import { browser } from '$app/environment'; +import { Channels, ChannelsMeta } from '$lib/store/channels.svelte.js'; import { Messages } from '$lib/store/messages.svelte.js'; import { Logins } from '$lib/store/logins'; +import { STORE_KEY_CHANNELS_DATA } from '$lib/constants'; + +// Get channelsList content from the local storage +const channelsMetaData = ( + browser && JSON.parse(localStorage.getItem(STORE_KEY_CHANNELS_DATA)) +) || {}; export const currentUser = writable(null); export const logins = writable(new Logins()); -export const channelsList = writable(new Channels()); +export const channelsMetaList = writable(new ChannelsMeta({ channelsMetaData })); +export const channelsList = writable(new Channels({ channelsMetaList })); export const messages = writable(new Messages()); export function onEvent(event) { diff --git a/ui/lib/store/channels.svelte.js b/ui/lib/store/channels.svelte.js index c82f9aa..31e9098 100644 --- a/ui/lib/store/channels.svelte.js +++ b/ui/lib/store/channels.svelte.js @@ -1,39 +1,32 @@ 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 - }; -} +import { get } from 'svelte/store' +import { STORE_KEY_CHANNELS_DATA, EPOCH_STRING } from '$lib/constants'; + export class Channels { channels = $state([]); + constructor({ channelsMetaList }) { + // This is the state wrapper around the channels meta object. Dammit. + this.channelsMetaList = channelsMetaList; + } + getChannel(channelId) { return this.channels.filter((ch) => ch.id === channelId)[0] || null; } setChannels(channels) { - this.channels = channels.map(makeChannelObject); + // Because this is called at initialization, we need to initialize the matching meta: + get(this.channelsMetaList).ensureChannels(channels); + this.channels = channels; this.sort(); return this; } addChannel(id, name) { - this.channels = [...this.channels, makeChannelObject({ id, name })]; + const newChannel = { id, name } + this.channels = [...this.channels, newChannel]; + get(this.channelsMetaList).initializeChannel(newChannel); this.sort(); return this; } @@ -57,3 +50,70 @@ export class Channels { }); } } + +export class ChannelsMeta { + // Store channelId -> { draft = '', lastReadAt = null, scrollPosition = null } + channelsMeta = $state({}); + + constructor({ channelsMetaData }) { + const channelsMeta = objectMap(channelsMetaData, (ch) => { + let lastReadAt = ch.lastReadAt; + if (typeof lastReadAt === 'string') { + lastReadAt = DateTime.fromISO(lastReadAt); + } + if (!Boolean(lastReadAt)) { + lastReadAt = DateTime.fromISO(EPOCH_STRING); + } + return { + ...ch, + lastReadAt, + }; + }); + this.channelsMeta = channelsMeta; + } + + writeOutToLocalStorage() { + localStorage.setItem( + STORE_KEY_CHANNELS_DATA, + JSON.stringify(this.channelsMeta) + ); + } + + updateLastReadAt(channelId, at) { + const channelObject = this.getChannel(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 (at > channelObject?.lastReadAt) { + channelObject.lastReadAt = at; + this.writeOutToLocalStorage(); + } + } + + ensureChannels(channelsList) { + channelsList.forEach(({ id }) => { + this.initializeChannel(id); + }); + } + + initializeChannel(channelId) { + if (!this.channelsMeta[channelId]) { + const channelData = { + lastReadAt: null, + draft: '', + scrollPosition: null, + }; + this.channelsMeta[channelId] = channelData; + } + } + + getChannel(channelId) { + return this.channelsMeta[channelId] || null; + } +} + +function objectMap(object, mapFn) { + return Object.keys(object).reduce((result, key) => { + result[key] = mapFn(object[key]) + return result + }, {}); +} diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 6339abd..888a185 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -6,7 +6,7 @@ import TinyGesture from 'tinygesture'; import { boot, subscribeToEvents } from '$lib/apiServer'; - import { channelsList, currentUser, logins, messages, onEvent } from '$lib/store'; + import { channelsList, channelsMetaList, currentUser, logins, messages, onEvent } from '$lib/store'; import ChannelList from '$lib/components/ChannelList.svelte'; import CreateChannelForm from '$lib/components/CreateChannelForm.svelte'; @@ -20,11 +20,14 @@ let channel = $derived(page.params.channel); let rawChannels = $derived($channelsList.channels); + let rawChannelsMeta = $derived($channelsMetaList.channelsMeta); let rawMessages = $derived($messages); let enrichedChannels = $derived.by(() => { const channels = rawChannels; + const channelsMeta = rawChannelsMeta; const messages = rawMessages; + const enrichedChannels = []; if (channels && messages) { for (let ch of channels) { @@ -32,7 +35,7 @@ let lastRun = runs?.slice(-1)[0]; let lastMessage = lastRun?.messages.slice(-1)[0]; let lastMessageAt = lastMessage?.at; - let hasUnreads = lastMessageAt > ch.lastReadAt; + let hasUnreads = lastMessageAt > channelsMeta[ch.id]?.lastReadAt; enrichedChannels.push({ ...ch, hasUnreads diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index 84cb0ae..54ebda7 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/state'; import ActiveChannel from '$lib/components/ActiveChannel.svelte'; import MessageInput from '$lib/components/MessageInput.svelte'; - import { channelsList, messages } from '$lib/store'; + import { channelsMetaList, messages } from '$lib/store'; let channel = $derived(page.params.channel); let messageRuns = $derived($messages.inChannel(channel)); @@ -22,27 +22,22 @@ } function getLastVisibleMessage() { - const parentElement = activeChannel; - const childElements = parentElement.getElementsByClassName('message'); - const lastInView = Array.from(childElements) - .reverse() - .find((el) => { - return inView(parentElement, el); - }); - return lastInView; + if (activeChannel) { + const childElements = activeChannel.getElementsByClassName('message'); + const lastInView = Array.from(childElements) + .reverse() + .find((el) => { + return inView(activeChannel, 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; + if (lastInView) { + const at = DateTime.fromISO(lastInView.dataset.at); + $channelsMetaList.updateLastReadAt(channel, at); } } diff --git a/ui/routes/+layout.svelte b/ui/routes/+layout.svelte index 750f1f8..18c0541 100644 --- a/ui/routes/+layout.svelte +++ b/ui/routes/+layout.svelte @@ -1,11 +1,15 @@ <script> import { setContext } from 'svelte'; - import { onNavigate } from '$app/navigation'; + import { browser } from '$app/environment'; + import { goto, onNavigate, afterNavigate } from '$app/navigation'; + import { page } from '$app/stores'; import '../app.css'; import logo from '$lib/assets/logo.png'; - + import { STORE_KEY_LAST_ACTIVE } from '$lib/constants'; import { currentUser } from '$lib/store'; + let activeChannel = $derived($page.params.channel); + let pageContext = $state({ showMenu: false }); @@ -16,6 +20,28 @@ pageContext.showMenu = !pageContext.showMenu; } + function getLastActiveChannel() { + return ( + browser && JSON.parse(localStorage.getItem(STORE_KEY_LAST_ACTIVE)) + ); + } + + function setLastActiveChannel(channelId) { + browser && localStorage.setItem( + STORE_KEY_LAST_ACTIVE, + JSON.stringify(channelId) + ); + } + + afterNavigate(() => { + const lastActiveChannel = getLastActiveChannel(); + if (!activeChannel && lastActiveChannel) { + goto(`/ch/${lastActiveChannel}`); + } else { + setLastActiveChannel(activeChannel || null); + } + }); + onNavigate(() => { pageContext.showMenu = false; }); |
