From fe8abb9efbffbbba1741dbfd6e99b8af63a9afac Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 20 Aug 2024 13:20:45 -0700 Subject: [PATCH] Show active links in compose --- package-lock.json | 14 ++ package.json | 1 + src/app.css | 4 + src/app/components/GroupCompose.svelte | 55 ++++++- src/app/components/GroupComposeLink.svelte | 7 +- .../GroupComposeProfileSuggestion.svelte | 7 + .../components/GroupComposeSuggestions.svelte | 80 ++++++++++ .../GroupComposeTopicSuggestion.svelte | 7 + src/app/components/GroupNote.svelte | 11 +- src/app/state.ts | 58 ++++++- src/lib/tiptap/LinkExtension.ts | 4 + src/lib/tiptap/Mention.ts | 3 + src/lib/tiptap/Topic.ts | 3 + src/lib/tiptap/common.ts | 149 ++++++++++++++++++ src/lib/tiptap/index.ts | 4 +- 15 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 src/app/components/GroupComposeProfileSuggestion.svelte create mode 100644 src/app/components/GroupComposeSuggestions.svelte create mode 100644 src/app/components/GroupComposeTopicSuggestion.svelte create mode 100644 src/lib/tiptap/Mention.ts create mode 100644 src/lib/tiptap/Topic.ts create mode 100644 src/lib/tiptap/common.ts diff --git a/package-lock.json b/package-lock.json index 03fe3525..7293cf8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@noble/hashes": "^1.4.0", "@poppanator/sveltekit-svg": "^4.2.1", "@tiptap/starter-kit": "^2.6.4", + "@tiptap/suggestion": "^2.6.4", "@types/throttle-debounce": "^5.0.2", "@welshman/lib": "^0.0.14", "@welshman/net": "^0.0.18", @@ -1490,6 +1491,19 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.6.4.tgz", + "integrity": "sha512-t4GOEcsVSCwTlugHjZdK5Swe6or/tBej5E3ZWYOFHxkNLDod76Q7hvAeBPYrLeDo6m3sPnxrazfdqSeVclk72g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.6.4", + "@tiptap/pm": "^2.6.4" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", diff --git a/package.json b/package.json index 37864f17..1db93ddf 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@noble/hashes": "^1.4.0", "@poppanator/sveltekit-svg": "^4.2.1", "@tiptap/starter-kit": "^2.6.4", + "@tiptap/suggestion": "^2.6.4", "@types/throttle-debounce": "^5.0.2", "@welshman/lib": "^0.0.14", "@welshman/net": "^0.0.18", diff --git a/src/app.css b/src/app.css index 1b015dd6..52fc1b6f 100644 --- a/src/app.css +++ b/src/app.css @@ -91,3 +91,7 @@ .link-content { @apply text-sm rounded px-1 bg-neutral text-neutral-content no-underline; } + +.link-content.link-content-selected { + @apply bg-primary text-primary-content; +} diff --git a/src/app/components/GroupCompose.svelte b/src/app/components/GroupCompose.svelte index 910a91ed..7851043a 100644 --- a/src/app/components/GroupCompose.svelte +++ b/src/app/components/GroupCompose.svelte @@ -5,19 +5,23 @@ import StarterKit from '@tiptap/starter-kit' import {NostrExtension} from 'nostr-editor' import type {StampedEvent} from '@welshman/util' - import {LinkExtension} from '@lib/tiptap' + import {LinkExtension, Mention, Topic} from '@lib/tiptap' import GroupComposeMention from '@app/components/GroupComposeMention.svelte' import GroupComposeEvent from '@app/components/GroupComposeEvent.svelte' import GroupComposeImage from '@app/components/GroupComposeImage.svelte' import GroupComposeBolt11 from '@app/components/GroupComposeBolt11.svelte' import GroupComposeVideo from '@app/components/GroupComposeVideo.svelte' import GroupComposeLink from '@app/components/GroupComposeLink.svelte' + import GroupComposeSuggestions from '@app/components/GroupComposeSuggestions.svelte' + import GroupComposeTopicSuggestion from '@app/components/GroupComposeTopicSuggestion.svelte' + import GroupComposeProfileSuggestion from '@app/components/GroupComposeProfileSuggestion.svelte' import {signer} from '@app/base' + import {searchProfiles, searchTopics, displayProfileByPubkey} from '@app/state' let editor: Readable const asInline = (extend: Record) => - ({inline: true, group: 'inline', draggable: false, ...extend}) + ({inline: true, group: 'inline', ...extend}) onMount(() => { editor = createEditor({ @@ -56,12 +60,57 @@ onDrop() { // setPending(true) }, - onComplete(currentEditor: Editor) { + onComplete(currentEditor: any) { console.log('Upload Completed', currentEditor.getText()) // setPending(false) }, }, }), + Mention.configure( + (() => { + let suggestions: GroupComposeSuggestions + + const mapProps = (props: any) => ({ + term: props.query, + select: (id: string) => props.command({id}), + search: searchProfiles, + component: GroupComposeProfileSuggestion, + }) + + return { + getLabel: displayProfileByPubkey, + tippyOptions: {arrow: false, theme: "transparent"}, + onStart: ({target, props}) => { + suggestions = new GroupComposeSuggestions({target, props: mapProps(props)}) + }, + onUpdate: ({props}) => suggestions.$set(mapProps(props)), + onKeyDown: ({event}) => suggestions.onKeyDown(event), + onExit: () => suggestions.$destroy(), + } + })(), + ), + Topic.configure( + (() => { + let suggestions: GroupComposeSuggestions + + const mapProps = (props: any) => ({ + term: props.query, + select: (id: string) => props.command({id}), + search: searchTopics, + component: GroupComposeTopicSuggestion, + }) + + return { + tippyOptions: {arrow: false, theme: "transparent"}, + onStart: ({target, props}) => { + suggestions = new GroupComposeSuggestions({target, props: mapProps(props)}) + }, + onUpdate: ({props}) => suggestions.$set(mapProps(props)), + onKeyDown: ({event}) => suggestions.onKeyDown(event), + onExit: () => suggestions.$destroy(), + } + })(), + ), ], content: '', onUpdate: () => { diff --git a/src/app/components/GroupComposeLink.svelte b/src/app/components/GroupComposeLink.svelte index 145031ea..48409079 100644 --- a/src/app/components/GroupComposeLink.svelte +++ b/src/app/components/GroupComposeLink.svelte @@ -2,17 +2,18 @@ import cx from 'classnames' import type {NodeViewProps} from '@tiptap/core' import {NodeViewWrapper} from 'svelte-tiptap' - import {stripProtocol} from '@welshman/lib' + import {displayUrl} from '@welshman/lib' import Icon from '@lib/components/Icon.svelte' import Link from '@lib/components/Link.svelte' export let node: NodeViewProps['node'] + export let selected: NodeViewProps['selected'] - + - {stripProtocol(node.attrs.url)} + {displayUrl(node.attrs.url)} diff --git a/src/app/components/GroupComposeProfileSuggestion.svelte b/src/app/components/GroupComposeProfileSuggestion.svelte new file mode 100644 index 00000000..a8758cd5 --- /dev/null +++ b/src/app/components/GroupComposeProfileSuggestion.svelte @@ -0,0 +1,7 @@ + + +
+ @{value} +
diff --git a/src/app/components/GroupComposeSuggestions.svelte b/src/app/components/GroupComposeSuggestions.svelte new file mode 100644 index 00000000..d3ba72fd --- /dev/null +++ b/src/app/components/GroupComposeSuggestions.svelte @@ -0,0 +1,80 @@ + + + + +{#if items.length > 0} +
+ {#each items as value, i (value)} + + {/each} +
+ {#if loading} +
+
+ +
+ Loading more options... +
+ {/if} +{/if} diff --git a/src/app/components/GroupComposeTopicSuggestion.svelte b/src/app/components/GroupComposeTopicSuggestion.svelte new file mode 100644 index 00000000..1b6ad76c --- /dev/null +++ b/src/app/components/GroupComposeTopicSuggestion.svelte @@ -0,0 +1,7 @@ + + +
+ #{value} +
diff --git a/src/app/components/GroupNote.svelte b/src/app/components/GroupNote.svelte index b1c2dff8..5c8040f5 100644 --- a/src/app/components/GroupNote.svelte +++ b/src/app/components/GroupNote.svelte @@ -3,11 +3,11 @@ import {readable} from "svelte/store" import {hash} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {GROUP_REPLY, getAncestorTags, displayProfile, displayPubkey} from "@welshman/util" + import {GROUP_REPLY, getAncestorTags, displayPubkey} from "@welshman/util" import {fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" import Avatar from "@lib/components/Avatar.svelte" - import {deriveProfile, deriveEvent} from "@app/state" + import {deriveProfile, deriveProfileDisplay, deriveEvent} from "@app/state" export let event: TrustedEvent export let showPubkey: boolean @@ -35,6 +35,7 @@ ] const profile = deriveProfile(event.pubkey) + const profileDisplay = deriveProfileDisplay(event.pubkey) const {replies} = getAncestorTags(event.tags) const parentId = replies[0]?.[1] const parentHints = [replies[0]?.[2]].filter(Boolean) @@ -43,6 +44,7 @@ $: parentPubkey = $parentEvent?.pubkey || replies[0]?.[4] $: parentProfile = deriveProfile(parentPubkey) + $: parentProfileDisplay = deriveProfileDisplay(parentPubkey)
@@ -50,7 +52,7 @@
-

{displayProfile($parentProfile, displayPubkey(parentPubkey))}

+

{$parentProfileDisplay}

@@ -67,8 +69,7 @@ {/if}

{#if showPubkey} - {displayProfile($profile, displayPubkey(event.pubkey))} + {$profileDisplay} {/if}

{event.content}

diff --git a/src/app/state.ts b/src/app/state.ts index c5f4496d..b6992cbb 100644 --- a/src/app/state.ts +++ b/src/app/state.ts @@ -1,3 +1,4 @@ +import {throttle} from 'throttle-debounce' import type {Readable} from "svelte/store" import type {FuseResult} from "fuse.js" import {get, writable, readable, derived} from "svelte/store" @@ -16,6 +17,7 @@ import { indexBy, now, Worker, + inc, } from "@welshman/lib" import { getIdFilters, @@ -32,6 +34,8 @@ import { readProfile, readList, asDecryptedEvent, + displayProfile, + displayPubkey, GROUP_JOIN, GROUP_ADD_USER, } from "@welshman/util" @@ -39,7 +43,7 @@ import type {SignedEvent, HashedEvent, EventTemplate, TrustedEvent, PublishedPro import type {SubscribeRequest, PublishRequest} from "@welshman/net" import {publish as basePublish, subscribe as baseSubscribe, PublishStatus} from "@welshman/net" import {decrypt, stamp, own, hash} from "@welshman/signer" -import {deriveEvents, deriveEventsMapped, getter, withGetter} from "@welshman/store" +import {custom, deriveEvents, deriveEventsMapped, getter, withGetter} from "@welshman/store" import {createSearch} from "@lib/util" import type {Handle, Relay} from "@app/types" import {INDEXER_RELAYS, DUFFLEPUD_URL, repository, pk, getSession, getSigner} from "@app/base" @@ -244,6 +248,43 @@ export const ensurePlaintext = async (e: TrustedEvent) => { return getPlaintext(e) } +// Topics + +export type Topic = { + name: string + count: number +} + +export const topics = custom(setter => { + const getTopics = () => { + const topics = new Map() + for (const tagString of repository.eventsByTag.keys()) { + if (tagString.startsWith('t:')) { + const topic = tagString.slice(2).toLowerCase() + + topics.set(topic, inc(topics.get(topic))) + } + } + + return Array.from(topics.entries()).map(([name, count]) => ({name, count})) + } + + setter(getTopics()) + + const onUpdate = throttle(3000, () => setter(getTopics())) + + repository.on("update", onUpdate) + + return () => repository.off("update", onUpdate) +}) + +export const searchTopics = derived(topics, $topics => + createSearch($topics, { + getValue: (topic: Topic) => topic.name, + fuseOptions: {keys: ["name"]}, + }), +) + // Relay info export const relays = writable([]) @@ -331,6 +372,21 @@ export const { }), }) +export const searchProfiles = derived(profiles, $profiles => + createSearch($profiles, { + getValue: (profile: PublishedProfile) => profile.event.pubkey, + fuseOptions: { + keys: ["name", "display_name", {name: "about", weight: 0.3}], + }, + }), +) + +export const displayProfileByPubkey = (pubkey: string, profile?: PublishedProfile) => + displayProfile(profile, pubkey ? displayPubkey(pubkey) : undefined) + +export const deriveProfileDisplay = (pubkey: string) => + derived(deriveProfile(pubkey), $profile => displayProfileByPubkey(pubkey, $profile)) + // Relay selections export const getReadRelayUrls = (event?: TrustedEvent): string[] => diff --git a/src/lib/tiptap/LinkExtension.ts b/src/lib/tiptap/LinkExtension.ts index 62832fca..f992b55d 100644 --- a/src/lib/tiptap/LinkExtension.ts +++ b/src/lib/tiptap/LinkExtension.ts @@ -30,6 +30,10 @@ export const LinkExtension = Node.create({ inline: true, + selectable: true, + + draggable: true, + priority: 1000, addAttributes() { diff --git a/src/lib/tiptap/Mention.ts b/src/lib/tiptap/Mention.ts new file mode 100644 index 00000000..54ef96f0 --- /dev/null +++ b/src/lib/tiptap/Mention.ts @@ -0,0 +1,3 @@ +import {createPopoverNode} from '@lib/tiptap/common' + +export const Mention = createPopoverNode('mention', '@') diff --git a/src/lib/tiptap/Topic.ts b/src/lib/tiptap/Topic.ts new file mode 100644 index 00000000..6ea31ef7 --- /dev/null +++ b/src/lib/tiptap/Topic.ts @@ -0,0 +1,3 @@ +import {createPopoverNode} from '@lib/tiptap/common' + +export const Topic = createPopoverNode('topic', '#') diff --git a/src/lib/tiptap/common.ts b/src/lib/tiptap/common.ts new file mode 100644 index 00000000..742adc37 --- /dev/null +++ b/src/lib/tiptap/common.ts @@ -0,0 +1,149 @@ +import tippy, {type Instance} from 'tippy.js' +import {mergeAttributes, Node} from '@tiptap/core' +import {PluginKey} from '@tiptap/pm/state' +import Suggestion from '@tiptap/suggestion' + +export type PopoverOptions = { + tippyOptions: Record + getLabel?: (id: string) => string + onStart?: (opts: any) => void + onUpdate?: (opts: any) => void + onKeyDown?: (opts: any) => boolean | undefined + onExit?: () => void +} + +export const createPopoverNode = (name: string, char: string) => { + const pluginKey = new PluginKey(name) + + return Node.create({ + name, + group: 'inline', + inline: true, + selectable: false, + atom: true, + addAttributes: () => ({ + id: { + default: null, + parseHTML: el => el.getAttribute('data-id'), + renderHTML: ({id}) => id ? {'data-id': id} : {}, + }, + }), + parseHTML() { + return [{tag: `span[data-type="${this.name}"]`}] + }, + renderHTML({node, HTMLAttributes}) { + const label = this.options.getLabel?.(node.attrs.id) || node.attrs.id + + return ['span', mergeAttributes({'data-type': this.name}, HTMLAttributes), `${char}${label}`] + }, + renderText({node}) { + return `${char}${this.options.getLabel?.(node.attrs.id) || node.attrs.id}` + }, + addKeyboardShortcuts() { + return { + Backspace: () => this.editor.commands.command(({ tr, state }) => { + let isMention = false + const { selection } = state + const { empty, anchor } = selection + + if (!empty) { + return false + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isMention = true + tr.insertText('', pos, pos + node.nodeSize) + + return false + } + }) + + return isMention + }), + } + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + char, + pluginKey, + editor: this.editor, + command: ({editor, range, props}) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter + const overrideSpace = nodeAfter?.text?.startsWith(' ') + + if (overrideSpace) { + range.to += 1 + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + {type: this.name, attrs: props}, + {type: 'text', text: ' '}, + ]) + .run() + + window.getSelection()?.collapseToEnd() + }, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from) + const type = state.schema.nodes[this.name] + const allow = !!$from.parent.type.contentMatch.matchType(type) + + return allow + }, + render: () => { + let popover: Instance[] + let target: HTMLElement + + return { + onStart: props => { + target = document.createElement("div") + + popover = tippy('body', { + getReferenceClientRect: props.clientRect as any, + appendTo: document.body, + content: target, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + ...this.options.tippyOptions, + }) + + this.options.onStart?.({target, props}) + }, + onUpdate: props => { + this.options.onUpdate?.({props}) + + if (props.clientRect) { + popover[0].setProps({ + getReferenceClientRect: props.clientRect as any, + }) + } + }, + onKeyDown: props => { + if (props.event.key === "Escape") { + popover[0].hide() + + return true + } + + return Boolean(this.options.onKeyDown?.(props)) + }, + onExit: () => { + popover[0].destroy() + this.options.onExit?.() + }, + } + }, + }), + ] + }, + }) +} diff --git a/src/lib/tiptap/index.ts b/src/lib/tiptap/index.ts index 60ccdaad..7d3fe346 100644 --- a/src/lib/tiptap/index.ts +++ b/src/lib/tiptap/index.ts @@ -1 +1,3 @@ -export * from '@lib/tiptap/LinkExtension' +export {LinkExtension} from '@lib/tiptap/LinkExtension' +export {Mention} from '@lib//tiptap/Mention' +export {Topic} from '@lib//tiptap/Topic'