Add emoji reactions
This commit is contained in:
parent
0b8f80ed0e
commit
ce733e5743
10 changed files with 145 additions and 16 deletions
|
|
@ -4,7 +4,5 @@ A discord-like nostr client. WIP.
|
||||||
|
|
||||||
# Notes
|
# Notes
|
||||||
|
|
||||||
- Privacy, migrations, and content replication
|
- [ ] Delete events when leaving a space
|
||||||
- Allow relays to strip signatures based on auth'd user
|
- [ ] Add topic and event tags to compose
|
||||||
- Federated relays/admins can get signatures
|
|
||||||
- Other users have to opt-in to using the relay in trusted mode
|
|
||||||
|
|
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -32,6 +32,7 @@
|
||||||
"@welshman/util": "^0.0.33",
|
"@welshman/util": "^0.0.33",
|
||||||
"daisyui": "^4.12.10",
|
"daisyui": "^4.12.10",
|
||||||
"date-picker-svelte": "^2.13.0",
|
"date-picker-svelte": "^2.13.0",
|
||||||
|
"emoji-picker-element": "^1.22.8",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"nostr-editor": "^0.0.1",
|
"nostr-editor": "^0.0.1",
|
||||||
|
|
@ -2543,6 +2544,11 @@
|
||||||
"integrity": "sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==",
|
"integrity": "sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-picker-element": {
|
||||||
|
"version": "1.22.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.22.8.tgz",
|
||||||
|
"integrity": "sha512-EFgRjrlIcdA1ilyMH/f9KjB0Pi/vynrojNgMDZfU1Jv2YLrhdLJWx6xCehizPyxm4/NUuB8DfFvIT4v+1njjPQ=="
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@
|
||||||
"@welshman/util": "^0.0.33",
|
"@welshman/util": "^0.0.33",
|
||||||
"daisyui": "^4.12.10",
|
"daisyui": "^4.12.10",
|
||||||
"date-picker-svelte": "^2.13.0",
|
"date-picker-svelte": "^2.13.0",
|
||||||
|
"emoji-picker-element": "^1.22.8",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"nostr-editor": "^0.0.1",
|
"nostr-editor": "^0.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
|
import type {Emoji} from 'emoji-picker-element/shared'
|
||||||
import twColors from "tailwindcss/colors"
|
import twColors from "tailwindcss/colors"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {readable, derived} from "svelte/store"
|
import {readable, derived} from "svelte/store"
|
||||||
import {hash, groupBy, now} from "@welshman/lib"
|
import {hash, uniqBy, groupBy, now} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {deriveEvents} from "@welshman/store"
|
import {deriveEvents} from "@welshman/store"
|
||||||
import {PublishStatus} from "@welshman/net"
|
import {PublishStatus} from "@welshman/net"
|
||||||
|
|
@ -12,17 +13,25 @@
|
||||||
deriveProfile,
|
deriveProfile,
|
||||||
deriveProfileDisplay,
|
deriveProfileDisplay,
|
||||||
formatTimestampAsTime,
|
formatTimestampAsTime,
|
||||||
|
tagReactionTo,
|
||||||
|
tagEvent,
|
||||||
|
makeThunk,
|
||||||
|
publishThunk,
|
||||||
|
pubkey,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import type {PublishStatusData} from "@welshman/app"
|
import type {PublishStatusData} from "@welshman/app"
|
||||||
import {REACTION, ZAP_RESPONSE, displayRelayUrl, getAncestorTags} from "@welshman/util"
|
import {REACTION, DELETE, ZAP_RESPONSE, createEvent, displayRelayUrl, getAncestorTags} from "@welshman/util"
|
||||||
import {repository} from "@welshman/app"
|
import {repository} from "@welshman/app"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
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 Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import {REPLY, deriveEvent, displayReaction} from "@app/state"
|
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
|
||||||
|
import {ROOM, REPLY, deriveEvent, displayReaction} from "@app/state"
|
||||||
|
|
||||||
|
export let url
|
||||||
|
export let room
|
||||||
export let event: TrustedEvent
|
export let event: TrustedEvent
|
||||||
export let showPubkey: boolean
|
export let showPubkey: boolean
|
||||||
|
|
||||||
|
|
@ -62,6 +71,34 @@
|
||||||
const findStatus = ($ps: PublishStatusData[], statuses: PublishStatus[]) =>
|
const findStatus = ($ps: PublishStatusData[], statuses: PublishStatus[]) =>
|
||||||
$ps.find(({status}) => statuses.includes(status))
|
$ps.find(({status}) => statuses.includes(status))
|
||||||
|
|
||||||
|
const createReaction = (content: string) => {
|
||||||
|
const reaction = createEvent(REACTION, {
|
||||||
|
content,
|
||||||
|
tags: [
|
||||||
|
[ROOM, room, url],
|
||||||
|
...tagReactionTo(event),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
publishThunk(makeThunk({event: reaction, relays: [url]}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
||||||
|
const reaction = events.find(e => e.pubkey === $pubkey)
|
||||||
|
|
||||||
|
if (reaction) {
|
||||||
|
const deleteEvent = createEvent(DELETE, {
|
||||||
|
tags: [["k", String(reaction.kind)], ...tagEvent(reaction)],
|
||||||
|
})
|
||||||
|
|
||||||
|
publishThunk(makeThunk({event: deleteEvent, relays: [url]}))
|
||||||
|
} else {
|
||||||
|
createReaction(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEmoji = (emoji: Emoji) => createReaction(emoji.unicode)
|
||||||
|
|
||||||
$: parentPubkey = $parentEvent?.pubkey || replies[0]?.[4]
|
$: parentPubkey = $parentEvent?.pubkey || replies[0]?.[4]
|
||||||
$: parentProfile = deriveProfile(parentPubkey || "")
|
$: parentProfile = deriveProfile(parentPubkey || "")
|
||||||
$: parentProfileDisplay = deriveProfileDisplay(parentPubkey || "")
|
$: parentProfileDisplay = deriveProfileDisplay(parentPubkey || "")
|
||||||
|
|
@ -119,8 +156,8 @@
|
||||||
</div>
|
</div>
|
||||||
{#if $reactions.length > 0 || $zaps.length > 0}
|
{#if $reactions.length > 0 || $zaps.length > 0}
|
||||||
<div class="ml-12 text-xs">
|
<div class="ml-12 text-xs">
|
||||||
{#each groupBy(e => e.content, $reactions).entries() as [content, events]}
|
{#each groupBy(e => e.content, uniqBy(e => e.pubkey + e.content, $reactions)).entries() as [content, events]}
|
||||||
<Button class="flex-inline btn btn-neutral btn-xs mr-2 gap-1 rounded-full">
|
<Button class="flex-inline btn btn-neutral btn-xs mr-2 gap-1 rounded-full" on:click={() => onReactionClick(content, events)}>
|
||||||
<span>{displayReaction(content)}</span>
|
<span>{displayReaction(content)}</span>
|
||||||
{#if events.length > 1}
|
{#if events.length > 1}
|
||||||
<span>{events.length}</span>
|
<span>{events.length}</span>
|
||||||
|
|
@ -134,9 +171,7 @@
|
||||||
<button class="btn join-item btn-xs">
|
<button class="btn join-item btn-xs">
|
||||||
<Icon icon="reply" size={4} />
|
<Icon icon="reply" size={4} />
|
||||||
</button>
|
</button>
|
||||||
<button class="btn join-item btn-xs">
|
<ChatMessageEmojiButton onEmoji={onEmoji} />
|
||||||
<Icon icon="smile-circle" size={4} />
|
|
||||||
</button>
|
|
||||||
<button class="btn join-item btn-xs">
|
<button class="btn join-item btn-xs">
|
||||||
<Icon icon="menu-dots" size={4} />
|
<Icon icon="menu-dots" size={4} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
45
src/app/components/ChatMessageEmojiButton.svelte
Normal file
45
src/app/components/ChatMessageEmojiButton.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import tippy, {type Instance} from "tippy.js"
|
||||||
|
import type {Emoji} from 'emoji-picker-element/shared'
|
||||||
|
import {between} from '@welshman/lib'
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
|
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||||
|
|
||||||
|
export let onEmoji
|
||||||
|
|
||||||
|
const open = () => popover.show()
|
||||||
|
|
||||||
|
const onClick = (emoji: Emoji) => {
|
||||||
|
onEmoji(emoji)
|
||||||
|
popover.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseMove = ({clientX, clientY}: any) => {
|
||||||
|
const {x, y, width, height} = popover.popper.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (!between([x, x + width], clientX) || !between([y, y + height + 30], clientY)) {
|
||||||
|
popover.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let popover: Instance
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:document on:mousemove={onMouseMove} />
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<Button class="btn join-item btn-xs" on:click={open}>
|
||||||
|
<Icon icon="smile-circle" size={4} />
|
||||||
|
</Button>
|
||||||
|
<Tippy
|
||||||
|
bind:popover
|
||||||
|
component={EmojiPicker}
|
||||||
|
props={{onClick}}
|
||||||
|
params={{
|
||||||
|
trigger: "manual",
|
||||||
|
interactive: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
15
src/lib/components/EmojiPicker.svelte
Normal file
15
src/lib/components/EmojiPicker.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import 'emoji-picker-element'
|
||||||
|
import type {Emoji} from 'emoji-picker-element/shared'
|
||||||
|
import {onMount} from 'svelte'
|
||||||
|
|
||||||
|
export let onClick: (emoji: Emoji) => void
|
||||||
|
|
||||||
|
let element: Element
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
element.addEventListener('emoji-click', (event: any) => onClick(event.detail as Emoji))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<emoji-picker bind:this={element} />
|
||||||
30
src/lib/components/Tippy.svelte
Normal file
30
src/lib/components/Tippy.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte'
|
||||||
|
import type {SvelteComponent, ComponentType, ComponentProps} from 'svelte'
|
||||||
|
import tippy, {type Instance, type Props} from "tippy.js"
|
||||||
|
|
||||||
|
export let component: ComponentType
|
||||||
|
export let props: ComponentProps<any> = {}
|
||||||
|
export let params: Partial<Props> = {}
|
||||||
|
export let popover: Instance | undefined = undefined
|
||||||
|
export let instance: SvelteComponent | undefined = undefined
|
||||||
|
|
||||||
|
let element: Element
|
||||||
|
|
||||||
|
$: instance?.$set(props)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const target = document.createElement("div")
|
||||||
|
|
||||||
|
popover = tippy(element, {content: target, ...params})
|
||||||
|
|
||||||
|
instance = new component({target, props})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
popover?.destroy()
|
||||||
|
instance?.$destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={element} />
|
||||||
|
|
@ -51,7 +51,6 @@ export const createSuggestions = (options: SuggestionsOptions) =>
|
||||||
},
|
},
|
||||||
render: () => {
|
render: () => {
|
||||||
let popover: Instance[]
|
let popover: Instance[]
|
||||||
let target: HTMLElement
|
|
||||||
let suggestions: SvelteComponent
|
let suggestions: SvelteComponent
|
||||||
|
|
||||||
const mapProps = (props: any) => ({
|
const mapProps = (props: any) => ({
|
||||||
|
|
@ -64,7 +63,7 @@ export const createSuggestions = (options: SuggestionsOptions) =>
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: props => {
|
onStart: props => {
|
||||||
target = document.createElement("div")
|
const target = document.createElement("div")
|
||||||
|
|
||||||
popover = tippy("body", {
|
popover = tippy("body", {
|
||||||
getReferenceClientRect: props.clientRect as any,
|
getReferenceClientRect: props.clientRect as any,
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const kinds = [NOTE, MESSAGE, EVENT_DATE, EVENT_TIME, CLASSIFIED]
|
const kinds = [NOTE, MESSAGE, EVENT_DATE, EVENT_TIME, CLASSIFIED]
|
||||||
const sub = subscribe({filters: [{kinds, since: now() - 30}], relays: [url]})
|
const sub = subscribe({filters: [{kinds}], relays: [url]})
|
||||||
|
|
||||||
return () => sub.close()
|
return () => sub.close()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
{#if type === "date"}
|
{#if type === "date"}
|
||||||
<Divider>{value}</Divider>
|
<Divider>{value}</Divider>
|
||||||
{:else}
|
{:else}
|
||||||
<ChatMessage event={assertEvent(value)} {showPubkey} />
|
<ChatMessage {url} {room} event={assertEvent(value)} {showPubkey} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<p class="flex h-10 items-center justify-center py-20">
|
<p class="flex h-10 items-center justify-center py-20">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue