feat(chat): group consecutive messages into message blocks for more consistent dom flow and fixing weird spacing #157

Closed
theAnuragMishra wants to merge 1 commits from theAnuragMishra/flotilla:dev into dev
4 changed files with 291 additions and 135 deletions
+145 -79
View File
@@ -37,6 +37,7 @@
event: TrustedEvent
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
isGrouped?: boolean
inert?: boolean
canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void
@@ -47,6 +48,7 @@
event,
replyTo = undefined,
showPubkey = false,
isGrouped = false,
inert = false,
canEdit,
onEdit,
@@ -74,89 +76,153 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<TapTarget
data-event={event.id}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Button onclick={openProfile} class="flex items-start">
<ProfileCircle
pubkey={event.pubkey}
class="border border-solid border-base-content"
size={8} />
</Button>
{:else}
<div class="w-8 min-w-8 max-w-8"></div>
{/if}
<div class="min-w-0 flex-grow pr-1">
{#if showPubkey}
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
{$profileDisplay}
</Button>
<span class="text-xs opacity-50">
{#if formatTimestampAsDate(event.created_at) === today}
Today
{:else}
{formatTimestampAsDate(event.created_at)}
{/if}
at {formatTimestampAsTime(event.created_at)}
</span>
{#if isGrouped}
<TapTarget
data-event={event.id}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col text-left hover:bg-base-100/50">
<div class="flex w-full gap-3 overflow-auto pr-1">
<div class="min-w-0 flex-grow">
<div>
<RoomItemContent {url} {event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
</div>
{/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} {event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
</div>
</div>
</div>
<div class="row-2 ml-10 mt-1 pl-1">
<ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
{#if path && $comments.length > 0}
{@const pubkeys = $comments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`}
<div data-tip={tooltip} class="tooltip tooltip-right flex">
<Link
href={path}
class={cx("btn btn-xs gap-1 rounded-full", {
"btn-neutral": !isOwn,
"btn-primary": isOwn,
})}>
<Icon icon={ReplyAlt} />
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
</Link>
</div>
<div class="row-2 mt-1">
<ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
{#if path && $comments.length > 0}
{@const pubkeys = $comments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`}
<div data-tip={tooltip} class="tooltip tooltip-right flex">
<Link
href={path}
class={cx("btn btn-xs gap-1 rounded-full", {
"btn-neutral": !isOwn,
"btn-primary": isOwn,
})}>
<Icon icon={ReplyAlt} />
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
</Link>
</div>
{/if}
</div>
{#if !isMobile}
<button
class="join absolute right-1 top-0 -translate-y-full border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} />
{/if}
<RoomItemEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon={Reply} size={4} />
</Button>
{/if}
{#if edit}
<Button class="btn join-item btn-xs" onclick={edit}>
<Icon icon={Pen} size={4} />
</Button>
{/if}
<RoomItemMenuButton {url} {event} />
</button>
{/if}
</div>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} />
{/if}
<RoomItemEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon={Reply} size={4} />
</TapTarget>
{:else}
<TapTarget
data-event={event.id}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Button onclick={openProfile} class="flex items-start">
<ProfileCircle
pubkey={event.pubkey}
class="border border-solid border-base-content"
size={8} />
</Button>
{:else}
<div class="w-8 min-w-8 max-w-8"></div>
{/if}
{#if edit}
<Button class="btn join-item btn-xs" onclick={edit}>
<Icon icon={Pen} size={4} />
</Button>
<div class="min-w-0 flex-grow pr-1">
{#if showPubkey}
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
{$profileDisplay}
</Button>
<span class="text-xs opacity-50">
{#if formatTimestampAsDate(event.created_at) === today}
Today
{:else}
{formatTimestampAsDate(event.created_at)}
{/if}
at {formatTimestampAsTime(event.created_at)}
</span>
</div>
{/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} {event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
</div>
</div>
</div>
<div class="row-2 mt-1">
<ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
{#if path && $comments.length > 0}
{@const pubkeys = $comments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`}
<div data-tip={tooltip} class="tooltip tooltip-right flex">
<Link
href={path}
class={cx("btn btn-xs gap-1 rounded-full", {
"btn-neutral": !isOwn,
"btn-primary": isOwn,
})}>
<Icon icon={ReplyAlt} />
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
</Link>
</div>
{/if}
<RoomItemMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
</div>
{#if !isMobile}
<button
class="join absolute right-1 top-0 -translate-y-full border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<RoomItemZapButton {url} {event} />
{/if}
<RoomItemEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon={Reply} size={4} />
</Button>
{/if}
{#if edit}
<Button class="btn join-item btn-xs" onclick={edit}>
<Icon icon={Pen} size={4} />
</Button>
{/if}
<RoomItemMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
{/if}
@@ -0,0 +1,60 @@
<script lang="ts">
import {hash, formatTimestampAsTime, formatTimestampAsDate, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {deriveProfileDisplay} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import {colors} from "@app/core/state"
import {pushModal} from "@app/util/modal"
interface Props {
url: string
events: TrustedEvent[]
replyTo?: (event: TrustedEvent) => void
inert?: boolean
canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void
}
const {url, events, replyTo = undefined, inert = false, canEdit, onEdit}: Props = $props()
const firstEvent = events[0]
const today = formatTimestampAsDate(now())
const profileDisplay = deriveProfileDisplay(firstEvent.pubkey, [url])
const [_, colorValue] = colors[hash(firstEvent.pubkey) % colors.length]
const openProfile = () => pushModal(ProfileDetail, {pubkey: firstEvent.pubkey, url})
</script>
<div class="relative flex w-full flex-col p-2 pb-3 text-left hover:bg-base-100/50">
<div class="flex w-full gap-3">
<Button onclick={openProfile} class="flex items-start">
<ProfileCircle
pubkey={firstEvent.pubkey}
class="border border-solid border-base-content"
size={8} />
</Button>
<div class="min-w-0 flex-grow">
<div class="flex items-center gap-2">
<Button onclick={openProfile} class="text-sm font-bold" style="color: {colorValue}">
{$profileDisplay}
</Button>
<span class="text-xs opacity-50">
{#if formatTimestampAsDate(firstEvent.created_at) === today}
Today
{:else}
{formatTimestampAsDate(firstEvent.created_at)}
{/if}
at {formatTimestampAsTime(firstEvent.created_at)}
</span>
</div>
<div class="flex flex-col">
{#each events as event (event.id)}
<RoomItem {url} {event} {replyTo} {inert} {canEdit} {onEdit} isGrouped />
{/each}
</div>
</div>
</div>
</div>
+44 -28
View File
@@ -24,7 +24,7 @@
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomDetail from "@app/components/RoomDetail.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomMessageBlock from "@app/components/RoomMessageBlock.svelte"
import RoomName from "@app/components/RoomName.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
@@ -242,6 +242,7 @@
let previousPubkey
let previousCreatedAt = 0
let newMessagesSeen = false
let currentBlock: TrustedEvent[] = []
if (events) {
const lastUserEvent = $events.find(e => e.pubkey === $pubkey)
@@ -250,6 +251,17 @@
const adjustedLastChecked =
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
const flushBlock = () => {
if (currentBlock.length > 0) {
elements.push({
type: "message-block",
id: currentBlock[0].id,
events: [...currentBlock],
})
currentBlock = []
}
}
for (const event of $events) {
if (seen.has(event.id)) {
continue
@@ -264,23 +276,26 @@
event.created_at > adjustedLastChecked &&
event.created_at < mounted
) {
flushBlock()
elements.push({type: "new-messages", id: "new-messages"})
newMessagesSeen = true
}
if (date !== previousDate) {
elements.push({type: "date", value: date, id: date, showPubkey: false})
flushBlock()
elements.push({type: "date", value: date, id: date})
}
elements.push({
id: event.id,
type: "note",
value: event,
showPubkey:
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
previousKind === ROOM_ADD_MEMBER,
})
const shouldStartNewBlock =
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
previousKind === ROOM_ADD_MEMBER
if (shouldStartNewBlock) {
flushBlock()
}
currentBlock.push(event)
previousDate = date
previousKind = event.kind
@@ -288,6 +303,8 @@
previousCreatedAt = event.created_at
seen.add(event.id)
}
flushBlock()
}
elements.reverse()
@@ -382,32 +399,31 @@
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
{#each elements as element (element.id)}
{#if element.type === "new-messages"}
<div
{id}
id={element.id}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px flex-grow bg-primary"></div>
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
<div class="h-px flex-grow bg-primary"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = $state.snapshot(value as TrustedEvent)}
{:else if element.type === "date"}
<Divider>{(element as any).value}</Divider>
{:else if element.type === "message-block"}
<div in:slide class="cv">
<RoomMessageBlock
{url}
events={(element as any).events}
{replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{:else if (element as any).value}
{@const event = $state.snapshot((element as any).value)}
{#if event.kind === ROOM_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div in:slide class="cv" class:-mt-1={!showPubkey}>
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{/if}
{/each}
+42 -28
View File
@@ -19,7 +19,7 @@
import ThunkToast from "@app/components/ThunkToast.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomMessageBlock from "@app/components/RoomMessageBlock.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
@@ -180,6 +180,7 @@
let previousPubkey
let previousCreatedAt = 0
let newMessagesSeen = false
let currentBlock: TrustedEvent[] = []
if (events) {
const lastUserEvent = $events.find(e => e.pubkey === $pubkey)
@@ -188,6 +189,17 @@
const adjustedLastChecked =
lastChecked && lastUserEvent ? Math.max(lastUserEvent.created_at, lastChecked) : lastChecked
const flushBlock = () => {
if (currentBlock.length > 0) {
elements.push({
type: "message-block",
id: currentBlock[0].id,
events: [...currentBlock],
})
currentBlock = []
}
}
for (const event of $events) {
if (seen.has(event.id)) {
continue
@@ -202,23 +214,26 @@
event.created_at > adjustedLastChecked &&
event.created_at < mounted
) {
flushBlock()
elements.push({type: "new-messages", id: "new-messages"})
newMessagesSeen = true
}
if (date !== previousDate) {
elements.push({type: "date", value: date, id: date, showPubkey: false})
flushBlock()
elements.push({type: "date", value: date, id: date})
}
elements.push({
id: event.id,
type: "note",
value: event,
showPubkey:
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
previousKind === RELAY_ADD_MEMBER,
})
const shouldStartNewBlock =
previousPubkey !== event.pubkey ||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
previousKind === RELAY_ADD_MEMBER
if (shouldStartNewBlock) {
flushBlock()
}
currentBlock.push(event)
previousDate = date
previousKind = event.kind
@@ -226,6 +241,8 @@
previousCreatedAt = event.created_at
seen.add(event.id)
}
flushBlock()
}
elements.reverse()
@@ -295,32 +312,29 @@
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
{#each elements as element (element.id)}
{#if element.type === "new-messages"}
<div
{id}
id={element.id}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px flex-grow bg-primary"></div>
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
<div class="h-px flex-grow bg-primary"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = $state.snapshot(value as TrustedEvent)}
{:else if element.type === "date"}
<Divider>{(element as any).value}</Divider>
{:else if element.type === "message-block"}
<RoomMessageBlock
{url}
events={(element as any).events}
{replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{:else if (element as any).value}
{@const event = $state.snapshot((element as any).value)}
{#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div class:-mt-1={!showPubkey}>
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{/if}
{/each}