2024-10-24 16:45:27 +00:00
|
|
|
<script lang="ts">
|
2025-10-30 19:53:21 +00:00
|
|
|
import cx from "classnames"
|
2024-11-15 22:03:54 +00:00
|
|
|
import {onMount} from "svelte"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {derived} from "svelte/store"
|
2025-02-05 21:02:51 +00:00
|
|
|
import type {Snippet} from "svelte"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {
|
|
|
|
|
batch,
|
|
|
|
|
call,
|
|
|
|
|
displayList,
|
|
|
|
|
groupBy,
|
|
|
|
|
map,
|
|
|
|
|
removeUndefined,
|
|
|
|
|
spec,
|
|
|
|
|
sum,
|
|
|
|
|
uniq,
|
|
|
|
|
uniqBy,
|
|
|
|
|
} from "@welshman/lib"
|
2025-05-12 22:10:24 +00:00
|
|
|
import {
|
2025-10-06 18:23:19 +00:00
|
|
|
REPORT,
|
2025-05-12 22:10:24 +00:00
|
|
|
REACTION,
|
2026-07-28 16:16:26 +00:00
|
|
|
ZAP_RECEIPT,
|
2025-05-12 22:10:24 +00:00
|
|
|
getReplyFilters,
|
2025-07-07 20:58:43 +00:00
|
|
|
fromMsats,
|
2026-07-28 16:16:26 +00:00
|
|
|
matchTag,
|
|
|
|
|
tagSpec,
|
|
|
|
|
userInbox,
|
2025-05-12 22:10:24 +00:00
|
|
|
DELETE,
|
|
|
|
|
} from "@welshman/util"
|
2026-07-28 16:16:26 +00:00
|
|
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
|
|
|
|
import {getEmojis} from "@welshman/domain"
|
|
|
|
|
import type {Zap} from "@welshman/domain"
|
|
|
|
|
import {Zappers} from "@welshman/app"
|
2026-06-23 23:30:54 +00:00
|
|
|
import {isMobile, stopPropagation} from "@lib/html"
|
2025-09-10 22:26:39 +00:00
|
|
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
2025-01-15 01:23:27 +00:00
|
|
|
import Icon from "@lib/components/Icon.svelte"
|
2026-06-24 20:56:53 +00:00
|
|
|
import Button from "@lib/components/Button.svelte"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {deriveEvents} from "@app/repository"
|
2025-05-12 22:10:24 +00:00
|
|
|
import Reaction from "@app/components/Reaction.svelte"
|
2025-11-14 19:35:19 +00:00
|
|
|
import ReportDetails from "@app/components/ReportDetails.svelte"
|
2026-07-15 21:23:43 +00:00
|
|
|
import ProfileList from "@app/components/ProfileList.svelte"
|
|
|
|
|
import ZapModal from "@app/components/Zap.svelte"
|
2026-06-08 22:33:38 +00:00
|
|
|
import {REACTION_KINDS} from "@app/content"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {app, network, profiles, router, user} from "@app/core"
|
|
|
|
|
import {deriveUserIsSpaceAdmin} from "@app/management"
|
2026-06-08 21:14:40 +00:00
|
|
|
import {pushModal} from "@app/modal"
|
2024-10-24 16:45:27 +00:00
|
|
|
|
2025-02-04 00:37:14 +00:00
|
|
|
interface Props {
|
2025-05-12 22:10:24 +00:00
|
|
|
event: TrustedEvent
|
|
|
|
|
deleteReaction: (event: TrustedEvent) => void
|
|
|
|
|
createReaction: (event: EventContent) => void
|
2025-02-04 00:37:14 +00:00
|
|
|
url?: string
|
|
|
|
|
reactionClass?: string
|
|
|
|
|
noTooltip?: boolean
|
2026-04-10 22:27:36 +00:00
|
|
|
innerEvent?: TrustedEvent
|
2025-02-05 21:02:51 +00:00
|
|
|
children?: Snippet
|
2025-02-04 00:37:14 +00:00
|
|
|
}
|
|
|
|
|
|
2025-02-04 01:21:46 +00:00
|
|
|
const {
|
2025-02-04 00:37:14 +00:00
|
|
|
event,
|
2025-05-12 22:10:24 +00:00
|
|
|
deleteReaction,
|
|
|
|
|
createReaction,
|
2026-08-21 20:47:06 +00:00
|
|
|
url,
|
2025-02-04 00:37:14 +00:00
|
|
|
reactionClass = "",
|
|
|
|
|
noTooltip = false,
|
2026-04-10 22:27:36 +00:00
|
|
|
innerEvent = undefined,
|
2025-02-04 00:37:14 +00:00
|
|
|
children,
|
|
|
|
|
}: Props = $props()
|
2024-10-24 16:45:27 +00:00
|
|
|
|
2026-04-10 22:27:36 +00:00
|
|
|
const eventIds = innerEvent ? [event.id, innerEvent.id] : [event.id]
|
|
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const reports = deriveEvents([{kinds: [REPORT], "#e": [event.id]}])
|
2025-01-15 01:23:27 +00:00
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const reactions = deriveEvents([{kinds: [REACTION], "#e": eventIds}])
|
2024-10-24 16:45:27 +00:00
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const receipts = deriveEvents([{kinds: [ZAP_RECEIPT], "#e": eventIds}])
|
|
|
|
|
|
|
|
|
|
// A receipt can be a zap of either the event or the one it wraps
|
|
|
|
|
const zaps = derived<typeof receipts, Zap[]>(
|
|
|
|
|
receipts,
|
|
|
|
|
($receipts, set) =>
|
|
|
|
|
derived(
|
|
|
|
|
removeUndefined([event, innerEvent]).map(
|
|
|
|
|
parent => $app.use(Zappers).validZapReceipts($receipts, parent).$,
|
|
|
|
|
),
|
|
|
|
|
(zapsByParent: Zap[][]) => uniqBy(zap => zap.response.id, zapsByParent.flat()),
|
|
|
|
|
).subscribe(set),
|
|
|
|
|
[],
|
2025-11-20 23:54:06 +00:00
|
|
|
)
|
2025-07-04 13:22:19 +00:00
|
|
|
|
2026-07-15 21:23:43 +00:00
|
|
|
const toggleReaction = (events: TrustedEvent[]) => {
|
2026-07-28 16:16:26 +00:00
|
|
|
const reaction = events.find(spec({pubkey: $user.pubkey}))
|
2025-05-12 22:10:24 +00:00
|
|
|
|
|
|
|
|
if (reaction) {
|
|
|
|
|
deleteReaction(reaction)
|
|
|
|
|
} else {
|
|
|
|
|
const [event] = events
|
|
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const shortcode = event.content.replace(/:/g, "")
|
|
|
|
|
|
2025-05-12 22:10:24 +00:00
|
|
|
createReaction({
|
|
|
|
|
content: event.content,
|
2026-07-28 16:16:26 +00:00
|
|
|
tags: getEmojis(event)
|
|
|
|
|
.filter(spec({shortcode}))
|
|
|
|
|
.map(emoji => ["emoji", emoji.shortcode, emoji.url]),
|
2025-05-12 22:10:24 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:23:43 +00:00
|
|
|
const showReactors = (pubkeys: string[], title: string, subtitle: string) =>
|
|
|
|
|
pushModal(ProfileList, {title, subtitle, pubkeys, url})
|
|
|
|
|
|
|
|
|
|
const onReactionClick = (events: TrustedEvent[], pubkeys: string[], info: string) => {
|
2026-08-17 22:25:53 +00:00
|
|
|
if (isMobile && !pubkeys.includes($user.pubkey)) {
|
2026-07-15 21:23:43 +00:00
|
|
|
showReactors(pubkeys, info.replace(" reacted", ""), "Reacted to this message")
|
2026-08-17 22:25:53 +00:00
|
|
|
} else {
|
|
|
|
|
toggleReaction(events)
|
2026-07-15 21:23:43 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onZapClick = (pubkeys: string[], info: string) => {
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
showReactors(pubkeys, info.replace(" zapped", ""), "Zapped this message")
|
|
|
|
|
} else {
|
|
|
|
|
pushModal(ZapModal, {url, pubkey: event.pubkey, eventId: event.id})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 23:54:20 +00:00
|
|
|
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
|
|
|
|
|
2025-11-14 19:35:19 +00:00
|
|
|
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
2025-01-15 01:23:27 +00:00
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const reportReasons = $derived(
|
|
|
|
|
uniq(map(e => matchTag(tagSpec("e"), e.tags)?.[2], $reports.values())),
|
|
|
|
|
)
|
2025-01-15 01:23:27 +00:00
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const getReactionKey = (e: TrustedEvent) => {
|
|
|
|
|
const shortcode = e.content.replace(/:/g, "")
|
|
|
|
|
const emoji = getEmojis(e).find(spec({shortcode}))
|
|
|
|
|
|
|
|
|
|
return emoji ? `emoji${emoji.shortcode}${emoji.url}` : e.content
|
|
|
|
|
}
|
2025-05-12 22:10:24 +00:00
|
|
|
|
2025-02-04 01:21:46 +00:00
|
|
|
const groupedReactions = $derived(
|
2025-02-04 00:37:14 +00:00
|
|
|
groupBy(
|
2025-05-12 22:10:24 +00:00
|
|
|
getReactionKey,
|
2025-11-20 23:54:06 +00:00
|
|
|
uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions.values()),
|
2025-02-04 00:37:14 +00:00
|
|
|
),
|
2024-10-24 16:58:43 +00:00
|
|
|
)
|
2024-11-15 22:03:54 +00:00
|
|
|
|
2025-11-20 23:54:06 +00:00
|
|
|
const groupedZaps = $derived(groupBy(e => getReactionKey(e.request), $zaps.values()))
|
2025-07-04 13:22:19 +00:00
|
|
|
|
2024-11-15 22:03:54 +00:00
|
|
|
onMount(() => {
|
2025-04-11 16:27:19 +00:00
|
|
|
const controller = new AbortController()
|
2026-07-28 16:16:26 +00:00
|
|
|
|
|
|
|
|
call(async () => {
|
|
|
|
|
const relays = url ? [url] : await $router.resolver.relays([userInbox()])
|
|
|
|
|
|
|
|
|
|
if (relays.length > 0) {
|
|
|
|
|
$network.load({
|
|
|
|
|
relays,
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
filters: getReplyFilters([event], {kinds: REACTION_KINDS}),
|
|
|
|
|
onEvent: batch(300, (events: TrustedEvent[]) => {
|
|
|
|
|
$network.load({
|
|
|
|
|
relays,
|
|
|
|
|
filters: getReplyFilters(events, {kinds: [DELETE]}),
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
2025-04-09 22:32:18 +00:00
|
|
|
|
|
|
|
|
return () => {
|
2025-04-11 16:27:19 +00:00
|
|
|
controller.abort()
|
2025-01-17 23:01:05 +00:00
|
|
|
}
|
2024-11-15 22:03:54 +00:00
|
|
|
})
|
2024-10-24 16:45:27 +00:00
|
|
|
</script>
|
|
|
|
|
|
2026-02-03 17:49:44 +00:00
|
|
|
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
|
2024-10-31 19:24:40 +00:00
|
|
|
<div class="flex min-w-0 flex-wrap gap-2">
|
2026-04-03 23:54:20 +00:00
|
|
|
{#if url && $reports.length > 0 && $userIsAdmin}
|
2026-06-24 20:56:53 +00:00
|
|
|
<Button
|
2025-05-13 21:31:34 +00:00
|
|
|
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
2026-06-24 20:56:53 +00:00
|
|
|
class={cx(
|
|
|
|
|
"button button-error button-xs tip-right flex items-center gap-1 rounded-full font-normal",
|
|
|
|
|
{
|
|
|
|
|
tip: !noTooltip && !isMobile,
|
|
|
|
|
},
|
|
|
|
|
)}
|
2026-06-23 23:30:54 +00:00
|
|
|
onclick={stopPropagation(onReportClick)}>
|
2025-09-10 22:26:39 +00:00
|
|
|
<Icon icon={Danger} />
|
2025-01-15 01:23:27 +00:00
|
|
|
<span>{$reports.length}</span>
|
2026-06-24 20:56:53 +00:00
|
|
|
</Button>
|
2025-01-15 01:23:27 +00:00
|
|
|
{/if}
|
2026-08-22 17:32:51 +00:00
|
|
|
{#each groupedZaps.entries() as [key, zaps] (key)}
|
2025-07-07 20:58:43 +00:00
|
|
|
{@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))}
|
|
|
|
|
{@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))}
|
2026-07-28 16:16:26 +00:00
|
|
|
{@const isOwn = pubkeys.includes($user.pubkey)}
|
2026-08-21 20:47:06 +00:00
|
|
|
{@const info = displayList(
|
|
|
|
|
pubkeys.map(pubkey => $profiles.display(pubkey, removeUndefined([url])).get()),
|
|
|
|
|
)}
|
2025-07-04 13:22:19 +00:00
|
|
|
{@const tooltip = `${info} zapped`}
|
2026-07-15 21:23:43 +00:00
|
|
|
{@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)}
|
2026-06-24 20:56:53 +00:00
|
|
|
<Button
|
2025-07-04 13:22:19 +00:00
|
|
|
data-tip={tooltip}
|
2025-10-30 19:53:21 +00:00
|
|
|
class={cx(
|
|
|
|
|
reactionClass,
|
2026-06-24 20:56:53 +00:00
|
|
|
"button button-xs flex-inline flex items-center gap-1 rounded-full text-xs font-normal",
|
2025-10-30 19:53:21 +00:00
|
|
|
{
|
2026-06-24 20:56:53 +00:00
|
|
|
tip: !noTooltip && !isMobile,
|
|
|
|
|
"button-primary": isOwn,
|
|
|
|
|
"button-neutral": !isOwn,
|
2025-10-30 19:53:21 +00:00
|
|
|
},
|
2026-07-15 21:23:43 +00:00
|
|
|
)}
|
|
|
|
|
onclick={stopPropagation(onZapClickHandler)}>
|
2025-07-04 13:22:19 +00:00
|
|
|
<Reaction event={zaps[0].request} />
|
|
|
|
|
<span>{amount}</span>
|
2026-06-24 20:56:53 +00:00
|
|
|
</Button>
|
2025-07-04 13:22:19 +00:00
|
|
|
{/each}
|
2026-08-22 17:32:51 +00:00
|
|
|
{#each groupedReactions.entries() as [key, events] (key)}
|
2024-11-18 23:41:58 +00:00
|
|
|
{@const pubkeys = events.map(e => e.pubkey)}
|
2026-07-28 16:16:26 +00:00
|
|
|
{@const isOwn = pubkeys.includes($user.pubkey)}
|
2026-08-21 20:47:06 +00:00
|
|
|
{@const info = displayList(
|
|
|
|
|
pubkeys.map(pubkey => $profiles.display(pubkey, removeUndefined([url])).get()),
|
|
|
|
|
)}
|
2025-05-12 22:10:24 +00:00
|
|
|
{@const tooltip = `${info} reacted`}
|
2026-07-15 21:23:43 +00:00
|
|
|
{@const onClick = () => onReactionClick(events, pubkeys, info)}
|
2026-06-24 20:56:53 +00:00
|
|
|
<Button
|
2024-11-18 23:41:58 +00:00
|
|
|
data-tip={tooltip}
|
2026-06-24 20:56:53 +00:00
|
|
|
class={cx(reactionClass, "button button-xs flex-inline gap-1 rounded-full font-normal", {
|
|
|
|
|
tip: !noTooltip && !isMobile,
|
|
|
|
|
"button-primary": isOwn,
|
|
|
|
|
"button-neutral": !isOwn,
|
|
|
|
|
})}
|
2026-06-23 23:30:54 +00:00
|
|
|
onclick={stopPropagation(onClick)}>
|
2025-05-12 22:10:24 +00:00
|
|
|
<Reaction event={events[0]} />
|
2024-10-29 21:02:51 +00:00
|
|
|
{#if events.length > 1}
|
|
|
|
|
<span>{events.length}</span>
|
|
|
|
|
{/if}
|
2026-06-24 20:56:53 +00:00
|
|
|
</Button>
|
2024-10-29 21:02:51 +00:00
|
|
|
{/each}
|
2025-02-04 00:37:14 +00:00
|
|
|
{@render children?.()}
|
2024-10-29 21:02:51 +00:00
|
|
|
</div>
|
|
|
|
|
{/if}
|