Add custom emoji parsing and display

This commit is contained in:
Jon Staab
2025-05-12 15:10:24 -07:00
parent 58afb8fa0c
commit 263a803875
11 changed files with 119 additions and 66 deletions
+2 -2
View File
@@ -52,7 +52,7 @@
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^0.2.6",
"@vite-pwa/sveltekit": "^0.6.6", "@vite-pwa/sveltekit": "^0.6.6",
"@welshman/app": "^0.2.5", "@welshman/app": "^0.2.5",
"@welshman/content": "^0.2.1", "@welshman/content": "^0.2.2",
"@welshman/dvm": "^0.2.0", "@welshman/dvm": "^0.2.0",
"@welshman/editor": "^0.2.4", "@welshman/editor": "^0.2.4",
"@welshman/feeds": "^0.2.2", "@welshman/feeds": "^0.2.2",
@@ -62,7 +62,7 @@
"@welshman/router": "^0.2.0", "@welshman/router": "^0.2.0",
"@welshman/signer": "^0.2.3", "@welshman/signer": "^0.2.3",
"@welshman/store": "^0.2.0", "@welshman/store": "^0.2.0",
"@welshman/util": "^0.2.2", "@welshman/util": "^0.2.3",
"daisyui": "^4.12.10", "daisyui": "^4.12.10",
"date-picker-svelte": "^2.13.0", "date-picker-svelte": "^2.13.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
+4 -2
View File
@@ -380,10 +380,12 @@ export const publishReport = ({
export type ReactionParams = { export type ReactionParams = {
event: TrustedEvent event: TrustedEvent
content: string content: string
tags?: string[][]
} }
export const makeReaction = ({event, content}: ReactionParams) => { export const makeReaction = ({content, event, tags: paramTags = []}: ReactionParams) => {
const tags = tagEventForReaction(event) const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags) const groupTag = getTag("h", event.tags)
if (groupTag) { if (groupTag) {
+5 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app" import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -26,20 +26,15 @@
const editEvent = () => pushModal(CalendarEventEdit, {url, event}) const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2"> <div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
+11 -11
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib" import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay} from "@welshman/app" import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import TapTarget from "@lib/components/TapTarget.svelte" import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
@@ -41,15 +41,10 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<TapTarget <TapTarget
@@ -89,7 +84,12 @@
</div> </div>
</div> </div>
<div class="row-2 ml-10 mt-1"> <div class="row-2 ml-10 mt-1">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right" /> <ReactionSummary
{url}
{event}
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
</div> </div>
{#if !isMobile} {#if !isMobile}
<button <button
+6 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {type Instance} from "tippy.js" import {type Instance} from "tippy.js"
import {hash, formatTimestampAsTime} from "@welshman/lib" import {hash, formatTimestampAsTime} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app" import {thunks, pubkey, deriveProfile, deriveProfileDisplay, sendWrapped} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -36,12 +36,11 @@
const reply = () => replyTo(event) const reply = () => replyTo(event)
const onReactionClick = async (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) =>
const reaction = events.find(e => e.pubkey === $pubkey) sendWrapped({template: makeDelete({event}), pubkeys})
const template = reaction ? makeDelete({event: reaction}) : makeReaction({event, content})
await sendWrapped({template, pubkeys}) const createReaction = (template: EventContent) =>
} sendWrapped({template: makeReaction({event, ...template}), pubkeys})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
@@ -120,7 +119,7 @@
</div> </div>
</TapTarget> </TapTarget>
<div class="row-2 z-feature -mt-4 ml-4"> <div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {onReactionClick} noTooltip /> <ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
</div> </div>
</div> </div>
</div> </div>
+4
View File
@@ -6,6 +6,7 @@
truncate, truncate,
renderAsHtml, renderAsHtml,
isText, isText,
isEmoji,
isTopic, isTopic,
isCode, isCode,
isCashu, isCashu,
@@ -22,6 +23,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte" import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte" import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte" import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte" import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@@ -133,6 +135,8 @@
<ContentNewline value={parsed.value} /> <ContentNewline value={parsed.value} />
{:else if isTopic(parsed)} {:else if isTopic(parsed)}
<ContentTopic value={parsed.value} /> <ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isCode(parsed)} {:else if isCode(parsed)}
<ContentCode <ContentCode
value={parsed.value} value={parsed.value}
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type {ParsedEmojiValue} from "@welshman/content"
import {imgproxy} from "@app/state"
export let value: ParsedEmojiValue
const alt = `:${value.name}:`
</script>
{#if value.url}
<img
{alt}
src={imgproxy(value.url, {w: 24, h: 24})}
class="-mt-0.5 inline h-[1em] min-w-[1em] align-middle" />
{:else}
{alt}
{/if}
+5 -11
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared" import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
@@ -11,15 +10,10 @@
const {url, event} = $props() const {url, event} = $props()
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
const onEmoji = (emoji: NativeEmoji) => const onEmoji = (emoji: NativeEmoji) =>
publishReaction({event, content: emoji.unicode, relays: [url]}) publishReaction({event, content: emoji.unicode, relays: [url]})
@@ -28,7 +22,7 @@
<NoteCard {event} {url} class="card2 bg-alt"> <NoteCard {event} {url} class="card2 bg-alt">
<NoteContent {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-right"> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
<EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box"> <EmojiButton {onEmoji} class="btn btn-neutral btn-xs h-[26px] rounded-box">
<Icon icon="smile-circle" size={4} /> <Icon icon="smile-circle" size={4} />
</EmojiButton> </EmojiButton>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import {parse, isEmoji, renderAsHtml} from "@welshman/content"
import Icon from "@lib/components/Icon.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
export let event
</script>
{#if event.content === "+" || event.content === ""}
<Icon icon="heart" />
{:else if event.content === "-"}
<Icon icon="thumbs-down" />
{:else}
{#each parse(event) as parsed}
{#if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else}
{@html renderAsHtml(parsed)}
{/if}
{/each}
{/if}
+39 -12
View File
@@ -2,20 +2,29 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {groupBy, uniq, uniqBy, batch, displayList} from "@welshman/lib" import {groupBy, uniq, uniqBy, batch, displayList} from "@welshman/lib"
import {REACTION, getReplyFilters, getTag, REPORT, DELETE} from "@welshman/util" import {
import type {TrustedEvent} from "@welshman/util" REACTION,
getReplyFilters,
getEmojiTags,
getEmojiTag,
getTag,
REPORT,
DELETE,
} from "@welshman/util"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {deriveEvents} from "@welshman/store" import {deriveEvents} from "@welshman/store"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {pubkey, repository, displayProfileByPubkey} from "@welshman/app" import {pubkey, repository, displayProfileByPubkey} from "@welshman/app"
import {isMobile, preventDefault, stopPropagation} from "@lib/html" import {isMobile, preventDefault, stopPropagation} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Reaction from "@app/components/Reaction.svelte"
import EventReportDetails from "@app/components/EventReportDetails.svelte" import EventReportDetails from "@app/components/EventReportDetails.svelte"
import {displayReaction} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
interface Props { interface Props {
event: any event: TrustedEvent
onReactionClick: any deleteReaction: (event: TrustedEvent) => void
createReaction: (event: EventContent) => void
url?: string url?: string
reactionClass?: string reactionClass?: string
noTooltip?: boolean noTooltip?: boolean
@@ -24,7 +33,8 @@
const { const {
event, event,
onReactionClick, deleteReaction,
createReaction,
url = "", url = "",
reactionClass = "", reactionClass = "",
noTooltip = false, noTooltip = false,
@@ -39,14 +49,31 @@
filters: [{kinds: [REACTION], "#e": [event.id]}], filters: [{kinds: [REACTION], "#e": [event.id]}],
}) })
const onReactionClick = (events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) {
deleteReaction(reaction)
} else {
const [event] = events
createReaction({
content: event.content,
tags: getEmojiTags(event.content.replace(/:/g, ""), event.tags),
})
}
}
const onReportClick = () => pushModal(EventReportDetails, {url, event}) const onReportClick = () => pushModal(EventReportDetails, {url, event})
const reportReasons = $derived(uniq($reports.map(e => getTag("e", e.tags)?.[2]))) const reportReasons = $derived(uniq($reports.map(e => getTag("e", e.tags)?.[2])))
const getReactionKey = (e: TrustedEvent) => getEmojiTag(e.content, e.tags)?.join("") || e.content
const groupedReactions = $derived( const groupedReactions = $derived(
groupBy( groupBy(
e => e.content, getReactionKey,
uniqBy(e => e.pubkey + e.content, $reactions), uniqBy(e => `${e.pubkey}${getReactionKey(e)}`, $reactions),
), ),
) )
@@ -86,12 +113,12 @@
<span>{$reports.length}</span> <span>{$reports.length}</span>
</button> </button>
{/if} {/if}
{#each groupedReactions.entries() as [content, events]} {#each groupedReactions.entries() as [key, events]}
{@const pubkeys = events.map(e => e.pubkey)} {@const pubkeys = events.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)} {@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))} {@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} reacted ${displayReaction(content)}`} {@const tooltip = `${info} reacted`}
{@const onClick = () => onReactionClick(content, events)} {@const onClick = () => onReactionClick(events)}
<button <button
type="button" type="button"
data-tip={tooltip} data-tip={tooltip}
@@ -101,7 +128,7 @@
class:border-solid={isOwn} class:border-solid={isOwn}
class:border-primary={isOwn} class:border-primary={isOwn}
onclick={stopPropagation(preventDefault(onClick))}> onclick={stopPropagation(preventDefault(onClick))}>
<span>{displayReaction(content)}</span> <Reaction event={events[0]} />
{#if events.length > 1} {#if events.length > 1}
<span>{events.length}</span> <span>{events.length}</span>
{/if} {/if}
+5 -11
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte" import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte" import EventActivity from "@app/components/EventActivity.svelte"
@@ -18,20 +17,15 @@
const path = makeThreadPath(url, event.id) const path = makeThreadPath(url, event.id)
const onReactionClick = (content: string, events: TrustedEvent[]) => { const deleteReaction = (event: TrustedEvent) => publishDelete({relays: [url], event})
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) { const createReaction = (template: EventContent) =>
publishDelete({relays: [url], event: reaction}) publishReaction({...template, event, relays: [url]})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script> </script>
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2"> <div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {onReactionClick} reactionClass="tooltip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} /> <ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />