feat(chat): group consecutive messages into message blocks for more consistent dom flow and fixing weird spacing #157
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user