diff --git a/src/ic_panda_frontend/src/lib/components/messages/ChannelMessages.svelte b/src/ic_panda_frontend/src/lib/components/messages/ChannelMessages.svelte index b989e9d..05bb2b2 100644 --- a/src/ic_panda_frontend/src/lib/components/messages/ChannelMessages.svelte +++ b/src/ic_panda_frontend/src/lib/components/messages/ChannelMessages.svelte @@ -13,9 +13,9 @@ encodeCBOR, type AesGcmKey } from '$lib/utils/crypto' - import { scrollOnBottom } from '$lib/utils/window' + import { scrollOnHooks } from '$lib/utils/window' import { - getCurrentTimestamp, + getCurrentTimeString, toDisplayUserInfo, type ChannelInfoEx, type MessageInfo, @@ -52,24 +52,43 @@ return msgs } - function addMessageInfo(info: MessageInfo) { + function addMessageInfos(infos: MessageInfo[]) { messageFeed.update((prev) => { - for (let i = 0; i < prev.length; i++) { - if (prev[i]!.id === info.id) { - prev[i] = info - return sortMessages([...prev]) + if (infos.length === 0) { + return prev + } + const rt: MessageInfo[] = [...prev] + for (const info of infos) { + let found = false + for (let i = 0; i < rt.length; i++) { + if (rt[i]!.id === info.id) { + rt[i] = info + found = true + break + } + } + if (!found) { + rt.push(info) } } - return sortMessages([...prev, info]) + return sortMessages(rt) }) } - function scrollChatBottom(behavior?: ScrollBehavior): void { - if (elemChat) { - elemChat.scrollTo({ - top: elemChat.scrollHeight, + function scrollIntoView( + messageId: number, + behavior: ScrollBehavior = 'instant', + block: ScrollLogicalPosition = 'center' + ): void { + const ele = document.getElementById( + `${canister.toText()}:${id}:${messageId}` + ) + + if (ele) { + ele.scrollIntoView({ + block, behavior - } as ScrollToOptions) + }) } } @@ -92,23 +111,25 @@ } const res = await channelAPI.add_message(input) - addMessageInfo({ - id: res.id, - reply_to: 0, - kind: res.kind, - created_by: myState.principal, - created_time: getCurrentTimestamp(res.created_at), - created_user: toDisplayUserInfo($myInfo), - canister: canister, - channel: id, - message: newMessage, - error: '' - } as MessageInfo) + addMessageInfos([ + { + id: res.id, + reply_to: 0, + kind: res.kind, + created_by: myState.principal, + created_time: getCurrentTimeString(res.created_at), + created_user: toDisplayUserInfo($myInfo), + canister: canister, + channel: id, + message: newMessage, + error: '' + } as MessageInfo + ]) newMessage = '' submitting = false - await tick() - scrollChatBottom('smooth') + await sleep(314) + scrollIntoView(res.id, 'smooth') }, toastStore).finally(() => { submitting = false }) @@ -123,7 +144,7 @@ const debouncedUpdateMyLastRead = debounce(async () => { await myState.updateMyLastRead(canister, id, lastRead) - }, 6000) + }, 1000) let topLoading = false async function loadPrevMessages(start: number, end: number) { @@ -132,7 +153,7 @@ } topLoading = true - const prevMessages = await myState.loadPrevMessages( + const prevMessages = await myState.loadMessages( canister, id, dek, @@ -140,7 +161,9 @@ end ) if (prevMessages.length > 0) { - messageFeed.update((prev) => sortMessages([...prevMessages, ...prev])) + addMessageInfos(prevMessages) + await tick() + scrollIntoView(prevMessages.at(-1)!.id, 'instant', 'start') } topLoading = false } @@ -152,7 +175,7 @@ } bottomLoading = true - const messages = await myState.loadPrevMessages( + const messages = await myState.loadMessages( canister, id, dek, @@ -162,22 +185,19 @@ let last = 0 if (messages.length > 0) { last = messages.at(-1)!.id - messageFeed.update((prev) => sortMessages([...prev, ...messages])) + addMessageInfos(messages) } bottomLoading = false if (last >= latestMessageId && !$latestMessage) { + latestMessageId = last latestMessage = await myState.loadLatestMessageStream( canister, id, dek, - last + 1 + latestMessageId + 1 ) } - - await tick() - await sleep(1000) - lastRead = last || lastRead } onMount(() => { @@ -194,18 +214,15 @@ dek = await myState.decryptChannelDEK(channelInfo) await loadPrevMessages(messageStart, lastRead + 1) await tick() - scrollChatBottom() + scrollIntoView(lastRead) await loadNextMessages(lastRead + 1) - await tick() - debouncedUpdateMyLastRead() - debouncedUpdateMyLastRead.trigger() } else { goto('/_/messages') } }, toastStore) - const abortScroll = scrollOnBottom(elemChat, { + const abortScroll = scrollOnHooks(elemChat, { onTop: () => { if (dek && !topLoading) { const front = $messageFeed[0] @@ -214,7 +231,6 @@ } } }, - onBottom: () => { if (dek && !bottomLoading) { const back = $messageFeed.at(-1) @@ -222,6 +238,16 @@ loadNextMessages(back.id) } } + }, + inMoveUpViewport: (els) => { + const [_canister, _channel, mid] = els.at(-1)!.id.split(':') + const messageId = parseInt(mid || '') + if (messageId > lastRead) { + lastRead = messageId + myState.freshMyChannelSetting(canister, id, { last_read: messageId }) + myState.informMyChannelsStream() + debouncedUpdateMyLastRead() + } } }) @@ -239,8 +265,7 @@ const info = $latestMessage if (info) { latestMessageId = info.id - addMessageInfo(info) - debouncedUpdateMyLastRead() + addMessageInfos([info]) } } diff --git a/src/ic_panda_frontend/src/lib/components/messages/Chat.svelte b/src/ic_panda_frontend/src/lib/components/messages/Chat.svelte index 2d37c60..4f3d033 100644 --- a/src/ic_panda_frontend/src/lib/components/messages/Chat.svelte +++ b/src/ic_panda_frontend/src/lib/components/messages/Chat.svelte @@ -26,11 +26,8 @@
-
- -
- {#if channelId} - {#key channelId} + {#key channelId} + {#if channelId} - {/key} - {:else} -
- -
- {/if} + {:else} +
+ +
+ {/if} + {/key}
diff --git a/src/ic_panda_frontend/src/lib/stores/message.ts b/src/ic_panda_frontend/src/lib/stores/message.ts index 8aa2bc6..610f66a 100644 --- a/src/ic_panda_frontend/src/lib/stores/message.ts +++ b/src/ic_panda_frontend/src/lib/stores/message.ts @@ -9,6 +9,7 @@ import { ChannelAPI, type ChannelBasicInfo, type ChannelInfo, + type ChannelSetting, type Message } from '$lib/canisters/messagechannel' import { @@ -70,8 +71,32 @@ const KVS = new KVStore('ICPanda', 1, [ const usersCacheExp = 2 * 3600 * 1000 -export function getCurrentTimestamp(ts: bigint): string { - return new Date(Number(ts)).toLocaleString() +export function getCurrentTimeString(ts: bigint): string { + const now = Date.now() + const t = Number(ts) + if (t >= now - 24 * 3600 * 1000) { + return new Date(t).toLocaleTimeString() + } else if (t >= now - 7 * 24 * 3600 * 1000) { + return new Date(t).toLocaleDateString(undefined, { weekday: 'long' }) + } + return new Date(t).toLocaleDateString() +} + +export function mergeMySetting( + old: ChannelSetting, + ncs: Partial +): ChannelSetting { + const lastRead = old.last_read + const rt = { ...old, ...ncs } + if (rt.last_read < lastRead) { + rt.last_read = lastRead + rt.unread = ncs.unread ? ncs.unread : rt.unread - (lastRead - rt.last_read) + if (rt.unread < 0) { + rt.unread = 0 + } + } + + return rt } type MessageCacheInfo = Message & { canister: Principal; channel: number } @@ -112,7 +137,7 @@ export type ChannelBasicInfoEx = ChannelBasicInfo & { latest_message_user: DisplayUserInfo } -type ChannelCacheInfo = ChannelInfo & { _get_by: string } +type ChannelModel = ChannelInfo & { _get_by: string } export type ChannelInfoEx = ChannelInfo & { _kek: Uint8Array | null @@ -128,7 +153,7 @@ export class MyMessageState { private _coseAPI: CoseAPI | null = null private _mks: MasterKey[] = [] private _ek: ECDHKey | null = null - private _myChannels = new Map() + private _myChannels = new Map() // keep the latest channel setting private _myChannelsStream = writable([]) private _channelDEKs = new Map() @@ -496,10 +521,7 @@ export class MyMessageState { mute: [], last_read: [] }) - const channel = this._myChannels.get(`${info.canister.toText()}:${info.id}`) - if (channel) { - channel.my_setting = setting - } + this.freshMyChannelSetting(info.canister, info.id, setting) } async acceptKEK(info: ChannelInfoEx): Promise { @@ -539,11 +561,8 @@ export class MyMessageState { mute: [], last_read: [] }) - const channel = this._myChannels.get(`${info.canister.toText()}:${info.id}`) - if (channel) { - channel.my_setting = setting - } + this.freshMyChannelSetting(info.canister, info.id, setting) info._kek = encryptedKEK } @@ -674,9 +693,37 @@ export class MyMessageState { } } + freshMyChannelSetting( + canister: Principal, + id: number, + setting?: Partial + ): ChannelSetting | null { + const channel = this._myChannels.get(`${canister.toText()}:${id}`) + if (channel && setting) { + channel.my_setting = mergeMySetting(channel.my_setting, setting) + return channel.my_setting + } + + return (setting as ChannelSetting) || null + } + + async informMyChannelsStream(save = true): Promise { + const channels = Array.from(this._myChannels.values()) + channels.sort(ChannelAPI.compareChannels) + if (save) { + await KVS.set('My', channels, `${this.id}:Channels`) + } + this._myChannelsStream.set(channels) + } + async addMyChannel(info: ChannelInfo): Promise { await this.initMyChannels() + info.my_setting = this.freshMyChannelSetting( + info.canister, + info.id, + info.my_setting + )! this._myChannels.set(`${info.canister.toText()}:${info.id}`, { id: info.id, gas: info.gas, @@ -691,35 +738,27 @@ export class MyMessageState { my_setting: info.my_setting }) - const channels = Array.from(this._myChannels.values()) - channels.sort(ChannelAPI.compareChannels) - await KVS.set('My', channels, `${this.id}:Channels`) - await KVS.set('Channels', { ...info, _get_by: this.id }) - this._myChannelsStream.set(channels) + await KVS.set('Channels', { ...info, _get_by: this.id }) + await this.informMyChannelsStream() } async removeMyChannel(canister: Principal, id: number): Promise { await this.initMyChannels() this._myChannels.delete(`${canister.toText()}:${id}`) - const channels = Array.from(this._myChannels.values()) - channels.sort(ChannelAPI.compareChannels) - await KVS.set('My', channels, `${this.id}:Channels`) + const key = [canister.toUint8Array(), id] await KVS.delete('Channels', key) await KVS.delete( 'Messages', IDBKeyRange.bound([...key, 0], [...key, 4294967295], false, true) ) - this._myChannelsStream.set(channels) + await this.informMyChannelsStream() } async refreshMyChannel(info: ChannelInfoEx): Promise { const api = await this.api.channelAPI(info.canister) - const ninfo = (await api.get_channel_if_update( - info.id, - 0n - )) as ChannelCacheInfo + const ninfo = (await api.get_channel_if_update(info.id, 0n)) as ChannelModel if (!ninfo) { throw new Error('Channel not found') } @@ -772,25 +811,16 @@ export class MyMessageState { let channels = await api.my_channels(latest_message_at) if (channels.length > 0) { for (const channel of channels) { - this._myChannels.set( - `${channel.canister.toText()}:${channel.id}`, - channel - ) + channel.my_setting = this.freshMyChannelSetting( + channel.canister, + channel.id, + channel.my_setting + )! + this._myChannels.set(`${prefix}:${channel.id}`, channel) } - - channels = Array.from(this._myChannels.values()) - channels.sort(ChannelAPI.compareChannels) - await KVS.set( - 'My', - channels, - `${this.id}:Channels` - ) - } else { - channels = Array.from(this._myChannels.values()) - channels.sort(ChannelAPI.compareChannels) } - this._myChannelsStream.set(channels) + await this.informMyChannelsStream(channels.length > 0) }) ) } @@ -804,7 +834,7 @@ export class MyMessageState { return { ...c, channelId: ChannelAPI.channelParam(c), - latest_message_time: getCurrentTimestamp(c.latest_message_at), + latest_message_time: getCurrentTimeString(c.latest_message_at), latest_message_user: toDisplayUserInfo(info) } as ChannelBasicInfoEx } @@ -948,7 +978,7 @@ export class MyMessageState { id: number ): Promise> { const api = await this.api.channelAPI(canister) - let info = await KVS.get('Channels', [ + let info = await KVS.get('Channels', [ canister.toUint8Array(), id ]) @@ -968,7 +998,7 @@ export class MyMessageState { let refresh = !!info if (!info) { - info = (await api.get_channel_if_update(id, 0n)) as ChannelCacheInfo + info = (await api.get_channel_if_update(id, 0n)) as ChannelModel if (!info) { throw new Error('Channel not found') } @@ -986,6 +1016,11 @@ export class MyMessageState { unread: 0, updated_at: 0n } + } else { + const channel = this._myChannels.get(`${canister.toText()}:${id}`) + if (channel) { + info.my_setting = mergeMySetting(info.my_setting, channel.my_setting) + } } let kek: Uint8Array | null = null @@ -1081,7 +1116,7 @@ export class MyMessageState { }) } - async loadPrevMessages( + async loadMessages( canister: Principal, channelId: number, dek: AesGcmKey, @@ -1102,6 +1137,8 @@ export class MyMessageState { start = end - 20 } + console.log('loadMessages', start, end) + let messages: MessageCacheInfo[] = [] const iter = await KVS.iterate( 'Messages', @@ -1110,7 +1147,7 @@ export class MyMessageState { let i = start for await (const cursor of iter) { - if (cursor.key !== i) { + if ((cursor.key as [Uint8Array, number, number])[2] !== i) { break } i += 1 @@ -1118,6 +1155,7 @@ export class MyMessageState { } if (i < end) { + console.log('loadMessages fetch', i, end) let items = (await api.list_messages( channelId, i, @@ -1151,10 +1189,7 @@ export class MyMessageState { last_read: [lastRead] }) - const channel = this._myChannels.get(`${canister.toText()}:${channelId}`) - if (channel) { - channel.my_setting = setting - } + this.freshMyChannelSetting(canister, channelId, setting) } async messagesToInfo( @@ -1184,7 +1219,7 @@ export class MyMessageState { reply_to: msg.reply_to, kind: msg.kind, created_by: msg.created_by, - created_time: getCurrentTimestamp(msg.created_at), + created_time: getCurrentTimeString(msg.created_at), created_user: toDisplayUserInfo(info), canister: canister, channel: channelId, @@ -1198,9 +1233,9 @@ export class MyMessageState { msg.kind == 1 ? (msg.payload as Uint8Array) : await coseA256GCMDecrypt0(dek, msg.payload as Uint8Array, aad) - m.message = decodeCBOR(payload) - } catch (e) { - m.error = 'Failed to decrypt message' + m.message = decodeMessage(payload) + } catch (err) { + m.error = `Failed to decrypt message: ${err}` } list.push(m) @@ -1504,3 +1539,13 @@ export function toDisplayUserInfo(info?: UserInfo) { src: info } } + +type MessagePayload = string | [string, number, Uint8Array] + +function decodeMessage(payload: Uint8Array): string { + const rt = decodeCBOR(payload) + if (Array.isArray(rt)) { + return rt[0] + } + return rt +} diff --git a/src/ic_panda_frontend/src/lib/utils/window.ts b/src/ic_panda_frontend/src/lib/utils/window.ts index 3cf05d4..ecc57e8 100644 --- a/src/ic_panda_frontend/src/lib/utils/window.ts +++ b/src/ic_panda_frontend/src/lib/utils/window.ts @@ -100,33 +100,76 @@ export function clickOutside(node: HTMLElement, callback: () => void = noop) { } } -export function scrollOnBottom( +export function scrollOnHooks( node: HTMLElement, { onTop, onBottom, onMoveUp, - onMoveDown + onMoveDown, + inMoveUpViewport, + inMoveDownViewport, + inViewportHasId = true, + inViewportHasClass = '' }: { onTop?: (() => void) | undefined onBottom?: (() => void) | undefined onMoveUp?: (() => void) | undefined onMoveDown?: (() => void) | undefined + inMoveUpViewport?: ((els: HTMLElement[]) => void) | undefined + inMoveDownViewport?: ((els: HTMLElement[]) => void) | undefined + inViewportHasId?: boolean + inViewportHasClass?: string } ) { - const callTop = onTop && debounce(onTop, 618, { immediate: true }) - const callBottom = onBottom && debounce(onBottom, 618, { immediate: true }) - const callMoveUp = onMoveUp && debounce(onMoveUp, 618, { immediate: true }) + const callTop = onTop && debounce(onTop, 618, { immediate: false }) + const callBottom = onBottom && debounce(onBottom, 618, { immediate: false }) + const callMoveUp = onMoveUp && debounce(onMoveUp, 618, { immediate: false }) const callMoveDown = - onMoveDown && debounce(onMoveDown, 618, { immediate: true }) + onMoveDown && debounce(onMoveDown, 618, { immediate: false }) + const callInMoveUpViewport = + inMoveUpViewport && debounce(inMoveUpViewport, 618, { immediate: false }) + const callInMoveDownViewport = + inMoveDownViewport && + debounce(inMoveDownViewport, 618, { immediate: false }) let lastScrollTop = 0 const handler = (ev: Event) => { const target = ev.currentTarget as HTMLElement if (target.scrollTop > lastScrollTop) { callMoveUp && callMoveUp() + if (callInMoveUpViewport) { + let children = Array.from(target.children) as HTMLElement[] + if (inViewportHasId || inViewportHasClass) { + children = children.filter((el) => { + if (inViewportHasId) { + return !!el.id + } + return el.classList.contains(inViewportHasClass) + }) + } + const els = elementsInViewport(target, children) + if (els.length > 0) { + callInMoveUpViewport(els) + } + } } else { callMoveDown && callMoveDown() + if (callInMoveDownViewport) { + let children = Array.from(target.children) as HTMLElement[] + if (inViewportHasId || inViewportHasClass) { + children = children.filter((el) => { + if (inViewportHasId) { + return !!el.id + } + return el.classList.contains(inViewportHasClass) + }) + } + const els = elementsInViewport(target, children) + if (els.length > 0) { + callInMoveDownViewport(els) + } + } } if (target.scrollTop < lastScrollTop && target.scrollTop <= 5) { @@ -147,5 +190,25 @@ export function scrollOnBottom( callBottom && callBottom.clear() callMoveUp && callMoveUp.clear() callMoveDown && callMoveDown.clear() + callInMoveUpViewport && callInMoveUpViewport.clear() } } + +export function elementsInViewport( + container: HTMLElement, + els: HTMLElement[] +): HTMLElement[] { + const containerRect = container.getBoundingClientRect() + const rt: HTMLElement[] = [] + for (const el of els) { + const rect = el.getBoundingClientRect() + if ( + (rect.top >= containerRect.top && rect.top < containerRect.bottom) || + (rect.bottom <= containerRect.bottom && rect.bottom > containerRect.top) + ) { + rt.push(el) + } + } + + return rt +}