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
+}