Add new content editable component

This commit is contained in:
Jonathan Staab 2023-03-23 09:40:36 -05:00
parent d7d05c2802
commit d0ccdf4888
9 changed files with 373 additions and 239 deletions

View File

@ -1,9 +1,5 @@
# Current
- [ ] Fix follows list modal
- [ ] Fix compose, topics
- [ ] Fix onboarding workflow w/forced relays (skip relays step)
- [ ] Fix iOS/safari/firefox
- [ ] https://github.com/staab/coracle/issues/42
- [ ] Multiplex, charge past a certain usage level based on bandwidth
@ -22,6 +18,7 @@
# Custom views
- [ ] Add suggestion list for topics on compose so people know there are suggestions
- [ ] Badges link to https://badges.page/p/97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
- [ ] Link/embed good chat/DM micro-apps
- [ ] Add QR code that pre-fills follows and relays for a new user

View File

@ -1,7 +1,7 @@
import {pick, last, prop, uniqBy} from "ramda"
import {map, pick, last, uniqBy} from "ramda"
import {get} from "svelte/store"
import {roomAttrs, displayPerson, findReplyId, findRootId} from "src/util/nostr"
import {getPubkeyWriteRelays, getRelayForPersonHint, sampleRelays} from "src/agent/relays"
import {getRelayForPersonHint} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import pool from "src/agent/pool"
import sync from "src/agent/sync"
@ -47,18 +47,47 @@ const createDirectMessage = (pubkey, content) =>
new PublishableEvent(4, {content, tags: [["p", pubkey]]})
const createNote = (content, mentions = [], topics = []) => {
mentions = mentions.map(pubkey => {
const name = displayPerson(getPersonWithFallback(pubkey))
const [{url}] = sampleRelays(getPubkeyWriteRelays(pubkey))
const tags = processMentions(mentions).concat(topics.map(t => ["t", t]))
return ["p", pubkey, url, name]
})
topics = topics.map(t => ["t", t])
return new PublishableEvent(1, {content, tags: mentions.concat(topics)})
return new PublishableEvent(1, {content, tags})
}
const createReaction = (note, content) =>
new PublishableEvent(7, {content, tags: getReplyTags(note)})
const createReply = (note, content, mentions = [], topics = []) => {
// Mentions have to come first so interpolation works
const tags = tagsFromParent(note, processMentions(mentions).concat(topics.map(t => ["t", t])))
return new PublishableEvent(1, {content, tags})
}
const requestZap = (relays, content, pubkey, eventId, amount, lnurl) => {
const tags = [
["relays", ...relays],
["amount", amount.toString()],
["lnurl", lnurl],
["p", pubkey],
]
if (eventId) {
tags.push(["e", eventId])
}
return new PublishableEvent(9734, {content, tags})
}
const deleteEvent = ids => new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
// Utils
const processMentions = map(pubkey => {
const name = displayPerson(getPersonWithFallback(pubkey))
const relay = getRelayForPersonHint(pubkey)
return ["p", pubkey, relay?.url || '', name]
})
const getReplyTags = n => {
const {url} = getRelayForPersonHint(n.pubkey, n)
const rootId = findRootId(n) || findReplyId(n) || n.id
@ -93,40 +122,6 @@ const tagsFromParent = (n, newTags = []) => {
)
}
const createReaction = (note, content) =>
new PublishableEvent(7, {content, tags: getReplyTags(note)})
const createReply = (note, content, mentions = [], topics = []) => {
// Mentions have to come first so interpolation works
const tags = tagsFromParent(
note,
mentions
.map(pk => ["p", pk, prop("url", getRelayForPersonHint(pk, note))])
.concat(topics.map(t => ["t", t]))
)
return new PublishableEvent(1, {content, tags})
}
const requestZap = (relays, content, pubkey, eventId, amount, lnurl) => {
const tags = [
["relays", ...relays],
["amount", amount.toString()],
["lnurl", lnurl],
["p", pubkey],
]
if (eventId) {
tags.push(["e", eventId])
}
return new PublishableEvent(9734, {content, tags})
}
const deleteEvent = ids => new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
// Utils
class PublishableEvent {
event: Record<string, any>
constructor(kind, {content = "", tags = []}) {

View File

@ -119,8 +119,19 @@ export const getRelaysForEventChildren = event => {
export const getRelayForEventHint = event => ({url: event.seen_on, score: 1})
export const getRelayForPersonHint = (pubkey, event) =>
first(getPubkeyWriteRelays(pubkey)) || getRelayForEventHint(event)
export const getRelayForPersonHint = (pubkey, event = null) => {
let relays = getPubkeyWriteRelays(pubkey)
if (relays.length === 0 && event) {
relays = [getRelayForEventHint(event)]
}
if (relays.length === 0) {
relays = getPubkeyReadRelays(pubkey)
}
return first(relays)
}
// If we're replying or reacting to an event, we want the author to know, as well as
// anyone else who is tagged in the original event or the reply. Get everyone's read

View File

@ -224,10 +224,14 @@ export const listener = (() => {
}
})()
type WatchStore<T> = Writable<T> & {
refresh: () => void
}
export const watch = (names, f) => {
names = ensurePlural(names)
const store = writable(null)
const store = writable(null) as WatchStore<any>
const tables = names.map(name => registry[name])
// Initialize synchronously if possible

View File

@ -1,237 +1,172 @@
<script lang="ts">
import {prop, repeat, reject, sortBy, last} from "ramda"
import {onMount} from "svelte"
import {ensurePlural} from "hurdak/lib/hurdak"
import {fly} from "svelte/transition"
import {nip19} from "nostr-tools"
import {last, pluck, propEq} from "ramda"
import {fuzzy} from "src/util/misc"
import {displayPerson} from "src/util/nostr"
import {fromParentOffset} from "src/util/html"
import Badge from "src/partials/Badge.svelte"
import {people} from "src/agent/tables"
import ContentEditable from "src/partials/ContentEditable.svelte"
import Suggestions from "src/partials/Suggestions.svelte"
import {watch} from "src/agent/storage"
import {getPubkeyWriteRelays} from "src/agent/relays"
export let onSubmit
let index = 0
let mentions = []
let suggestions = []
let input = null
let prevContent = ""
let contenteditable, suggestions
const search = fuzzy(people.all({"kind0.name:!nil": null}), {
keys: ["kind0.name", "pubkey"],
const pubkeyEncoder = {
encode: pubkey => {
const relays = pluck("url", getPubkeyWriteRelays(pubkey))
const nprofile = nip19.nprofileEncode({pubkey, relays})
return "nostr:" + nprofile
},
decode: link => {
return nip19.decode(last(link.split(":"))).data.pubkey
},
}
const searchPeople = watch("people", t => {
return fuzzy(t.all({"kind0.name:!nil": null}), {keys: ["kind0.name", "pubkey"]})
})
const getText = () => {
const selection = document.getSelection()
const range = selection.getRangeAt(0)
range.setStartBefore(input)
const text = range.cloneContents().textContent
range.collapse()
return text
const applySearch = word => {
suggestions.setData(word.startsWith("@") ? $searchPeople(word.slice(1)).slice(0, 5) : [])
}
const getWord = () => {
return last(getText().split(/[\s\u200B]+/))
const getInfo = () => {
const selection = window.getSelection()
const {focusNode: node, focusOffset: offset} = selection
const textBeforeCursor = node.textContent.slice(0, offset)
const word = last(textBeforeCursor.trim().split(/\s+/))
return {selection, node, offset, word}
}
const highlightWord = (prefix, chars, content) => {
const text = getText()
const selection = document.getSelection()
const {focusNode, focusOffset} = selection
const prefixElement = document.createTextNode(prefix)
const span = document.createElement("span")
const autocomplete = ({person}) => {
const {selection, node, offset, word} = getInfo()
// Space includes a zero-width space to avoid having the cursor end up inside
// mention span on backspace, and a space for convenience in composition.
const space = document.createTextNode("\u200B\u00a0")
const annotate = (prefix, text, value) => {
const adjustedOffset = offset - word.length + prefix.length
span.classList.add("underline")
span.innerText = content
// Space includes a zero-width space to avoid having the cursor end up inside
// mention span on backspace, and a space for convenience in composition.
const space = document.createTextNode("\u200B\u00A0")
const span = document.createElement("span")
// Remove our partial mention text
selection.setBaseAndExtent(
...fromParentOffset(input, text.length - chars),
focusNode,
focusOffset
)
selection.deleteFromDocument()
span.classList.add("underline")
span.dataset.coracle = JSON.stringify({prefix, value})
span.innerText = text
// Add the prefix, decorated text, and a trailing space
selection.getRangeAt(0).insertNode(prefixElement)
selection.collapse(prefixElement, 1)
selection.getRangeAt(0).insertNode(span)
selection.collapse(span.nextSibling, 0)
selection.getRangeAt(0).insertNode(space)
selection.collapse(space, 2)
}
// Remove our partial mention text
selection.setBaseAndExtent(node, adjustedOffset, node, offset)
selection.deleteFromDocument()
const pickSuggestion = person => {
const display = displayPerson(person)
// Add the span and space
selection.getRangeAt(0).insertNode(span)
selection.collapse(span.nextSibling, 0)
selection.getRangeAt(0).insertNode(space)
selection.collapse(space, 2)
}
highlightWord("@", getWord().length, display)
// Mentions
if (word.length > 1 && word.startsWith("@")) {
annotate("@", displayPerson(person).trim(), pubkeyEncoder.encode(person.pubkey))
}
mentions.push({
pubkey: person.pubkey,
length: display.length + 1,
end: getText().length - 2,
})
// Topics
if (word.length > 1 && word.startsWith("#")) {
annotate("#", word.slice(1), word.slice(1))
}
index = 0
suggestions = []
suggestions.setData([])
}
const onKeyDown = e => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
if (e.code === "Enter" && (e.ctrlKey || e.metaKey)) {
return onSubmit()
}
if (e.key === "Escape" && suggestions[index]) {
index = 0
suggestions = []
// Don't close a modal, submit the form, or lose focus
if (["Escape", "Tab"].includes(e.code)) {
e.preventDefault()
e.stopPropagation()
}
if (["Enter", "Tab", "ArrowUp", "ArrowDown", " "].includes(e.key) && suggestions[index]) {
// If we have suggestions, re-route keyboard commands
if (suggestions.get() && ["Enter", "ArrowUp", "ArrowDown"].includes(e.code)) {
e.preventDefault()
}
// Enter adds a newline, so do it on key down
if (["Enter"].includes(e.code)) {
autocomplete({person: suggestions.get()})
}
}
const onKeyUp = e => {
if (["Enter", "Tab", " "].includes(e.key) && suggestions[index]) {
pickSuggestion(suggestions[index])
const {word, trailingSpaces} = getInfo()
// Populate search data
applySearch(word)
if (["Tab"].includes(e.code)) {
autocomplete({person: suggestions.get()})
}
if (e.key === "ArrowUp" && suggestions[index - 1]) {
index -= 1
if (["Escape", "Space"].includes(e.code)) {
suggestions.clear()
}
if (e.key === "ArrowDown" && suggestions[index + 1]) {
index += 1
if (e.code === "ArrowUp") {
suggestions.prev()
}
if (input.innerText !== prevContent) {
const text = getText()
const word = getWord()
if (!text.match(/\s$/) && word.startsWith("@")) {
suggestions = search(word.slice(1)).slice(0, 5)
} else {
index = 0
suggestions = []
}
if (input.innerText.length < prevContent.length) {
const delta = prevContent.length - input.innerText.length
const text = getText()
for (const mention of mentions) {
if (mention.end - mention.length > text.length) {
mention.end -= delta
} else if (mention.end > text.length) {
mention.invalid = true
}
}
}
if (e.code === "ArrowDown") {
suggestions.next()
}
if (input.innerText.length > prevContent.length) {
const topic = getText().match(/#([-\w]+\s)$/)
if (topic) {
highlightWord("#", topic[0].length, topic[1].trim())
}
}
prevContent = input.innerText
}
export const trigger = events => {
ensurePlural(events).forEach(onKeyUp)
}
export const mention = person => {
const input = contenteditable.getInput()
const selection = window.getSelection()
const textNode = document.createTextNode("@")
const spaceNode = document.createTextNode(" ")
export const type = text => {
input.innerText += text
// Insert the text node, then an extra node so we don't break stuff in annotate
selection.getRangeAt(0).insertNode(textNode)
selection.collapse(input, 1)
selection.getRangeAt(0).insertNode(spaceNode)
selection.collapse(input, 1)
for (const c of Array.from(text)) {
onKeyUp({key: c})
}
const selection = document.getSelection()
const extent = fromParentOffset(input, input.textContent.length)
selection.setBaseAndExtent(...extent, ...extent)
autocomplete({person})
}
export const parse = () => {
// Interpolate mentions
let offset = 0
let {content, annotations} = contenteditable.parse()
const topics = pluck("value", annotations.filter(propEq("prefix", "#")))
// For whatever reason the textarea gives us 2x - 1 line breaks
let content = input.innerText.replace(/(\n+)/g, x =>
repeat("\n", Math.round(x.length / 2)).join("")
)
// Remove zero-width and non-breaking spaces
content = content.replace(/[\u200B\u00A0]/g, ' ').trim()
const validMentions = sortBy(prop("end"), reject(prop("invalid"), mentions))
for (const [i, {end, length}] of validMentions.entries()) {
const offsetEnd = end - offset
const start = offsetEnd - length
const tag = `#[${i}]`
// We're still using old style mention interpolation until NIP-27
// gets merged https://github.com/nostr-protocol/nips/pull/381/files
const mentions = annotations.filter(propEq("prefix", "@")).map(({value}, index) => {
content = content.replace("@" + value, `#[${index}]`)
content = content.slice(0, start) + tag + content.slice(offsetEnd)
offset += length - tag.length
}
// Remove our zero-length spaces
content = content.replace(/\u200B/g, "").trim()
return {
content,
topics: content.match(/#[-\w]+/g) || [],
mentions: validMentions.map(prop("pubkey")),
}
}
onMount(() => {
input.addEventListener("paste", e => {
e.preventDefault()
const selection = window.getSelection()
if (selection.rangeCount) {
selection.deleteFromDocument()
}
type((e.clipboardData || (window as any).clipboardData).getData("text"))
return pubkeyEncoder.decode(value)
})
})
return {content, topics, mentions}
}
</script>
<div class="flex">
<div
class="w-full min-w-0 p-2 text-gray-3 outline-0"
autofocus
contenteditable
bind:this={input}
on:keydown={onKeyDown}
on:keyup={onKeyUp} />
<ContentEditable bind:this={contenteditable} on:keydown={onKeyDown} on:keyup={onKeyUp} />
<slot name="addon" />
</div>
{#if suggestions.length > 0}
<div class="mt-2 flex flex-col rounded border border-solid border-gray-6" in:fly={{y: 20}}>
{#each suggestions as person, i (person.pubkey)}
<button
class="cursor-pointer border-l-2 border-solid border-black py-2 px-4 text-white"
class:bg-gray-8={index !== i}
class:bg-gray-7={index === i}
class:border-accent={index === i}
on:click={() => pickSuggestion(person)}>
<Badge inert {person} />
</button>
{/each}
<Suggestions bind:this={suggestions} select={person => autocomplete({person})}>
<div slot="item" let:item>
<Badge inert person={item} />
</div>
{/if}
</Suggestions>

View File

@ -0,0 +1,86 @@
<script lang="ts">
let input = null
// Line breaks are wrapped in divs sometimes
const isLineBreak = node => {
if (node.tagName === "BR") {
return true
}
if (node.tagName === "DIV") {
return true
}
return false
}
const isFancy = node => node instanceof Element && !isLineBreak(node)
const onInput = e => {
for (const node of input.childNodes) {
if (isLineBreak(node)) {
continue
}
// Remove gunk that gets copy/pasted, or bold/italic tags that can be added with hotkeys
if (node instanceof Element && !node.dataset.coracle) {
node.replaceWith(document.createTextNode(node.textContent))
}
}
const selection = window.getSelection()
const {focusNode: node, focusOffset: offset} = selection
// If we're editing something we've already linked, un-link it
if (node !== input && node.parentNode !== input && isFancy(node.parentNode)) {
node.parentNode.replaceWith(node)
selection.collapse(node, offset)
input.normalize()
}
}
export const getInput = () => input
const parseNode = node => {
let content = ""
let annotations = []
for (const child of node.childNodes) {
const lineBreaks = child.querySelectorAll?.("br") || []
// Line breaks may be bare brs or divs wrapping brs
if (lineBreaks.length > 0) {
content += "\n".repeat(lineBreaks.length)
} else if (isLineBreak(child)) {
content += "\n"
}
if (child.dataset?.coracle) {
const {prefix, value} = JSON.parse(child.dataset.coracle)
content += value
annotations = annotations.concat({prefix, value})
} else if (child instanceof Text) {
content += child.textContent
} else {
const info = parseNode(child)
content += info.content
annotations = annotations.concat(info.annotations)
}
}
return {content, annotations}
}
export const parse = () => parseNode(input)
</script>
<div
class="w-full min-w-0 p-2 text-gray-3 outline-0"
autofocus
contenteditable
bind:this={input}
on:input={onInput}
on:keydown
on:keyup />

View File

@ -0,0 +1,48 @@
<script lang="ts">
import {fly} from "svelte/transition"
export let select
let data = []
let index = 0
export const setData = d => {
data = d
if (!data[index]) {
index = 0
}
}
export const clear = () => {
index = 0
data = []
}
export const prev = () => {
index = Math.max(0, index - 1)
}
export const next = () => {
index = Math.min(data.length - 1, index + 1)
}
export const get = () => {
return data[index]
}
</script>
{#if data.length > 0}
<div class="mt-2 flex flex-col rounded border border-solid border-gray-6" in:fly={{y: 20}}>
{#each data as item, i}
<button
class="cursor-pointer border-l-2 border-solid border-black py-2 px-4 text-white"
class:bg-gray-8={index !== i}
class:bg-gray-7={index === i}
class:border-accent={index === i}
on:click={() => select(item)}>
<slot name="item" item={item} />
</button>
{/each}
</div>
{/if}

View File

@ -73,6 +73,12 @@ export const blobToString = async blob =>
export const blobToFile = blob => new File([blob], blob.name, {type: blob.type})
export const stripHtml = html => {
const doc = new DOMParser().parseFromString(html, "text/html")
return doc.body.textContent || ""
}
export const escapeHtml = html => {
const div = document.createElement("div")
@ -156,3 +162,58 @@ export const parseHex = hex => {
return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
}
export const getTextOffset = (root, element, elementOffset) => {
let textOffset = 0
for (const child of root.childNodes) {
if (child === element) {
return textOffset + elementOffset
}
if (child instanceof Text) {
textOffset += child.textContent.length
}
if (child instanceof Element) {
textOffset += getTextOffset(child, element, elementOffset)
if (child.contains(element)) {
return textOffset
}
}
}
return textOffset
}
export const getElementOffset = (root, offset) => {
if (offset === 0) {
return [root, offset]
}
for (const child of root.childNodes) {
let newOffset = offset
if (child instanceof Text) {
newOffset = offset - child.textContent.length
}
if (child instanceof Element) {
const [match, childOffset] = getElementOffset(child, offset)
if (match) {
return [match, childOffset]
}
newOffset = childOffset
}
if (newOffset <= 0) {
return [child, offset]
}
offset = newOffset
}
return [null, offset]
}

View File

@ -5,7 +5,6 @@
import {last, reject, pluck, propEq} from "ramda"
import {fly} from "svelte/transition"
import {fuzzy} from "src/util/misc"
import {displayPerson} from "src/util/nostr"
import Button from "src/partials/Button.svelte"
import Compose from "src/partials/Compose.svelte"
import ImageInput from "src/partials/ImageInput.svelte"
@ -19,13 +18,14 @@
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
import cmd from "src/agent/cmd"
import user from "src/agent/user"
import {toast, modal} from "src/app/ui"
import {publishWithToast} from "src/app"
export let pubkey = null
let image = null
let input = null
let compose = null
let relays = getUserWriteRelays()
let showSettings = false
let q = ""
@ -43,14 +43,14 @@
}
const onSubmit = async () => {
let {content, mentions, topics} = input.parse()
let {content, mentions, topics} = compose.parse()
if (image) {
content = (content + "\n" + image).trim()
content = content + "\n" + image
}
if (content) {
const thunk = cmd.createNote(content, mentions, topics)
const thunk = cmd.createNote(content.trim(), mentions, topics)
const [event, promise] = await publishWithToast(relays, thunk)
promise.then(() =>
@ -91,11 +91,8 @@
}
onMount(() => {
if (pubkey) {
const person = getPersonWithFallback(pubkey)
input.type("@" + displayPerson(person))
input.trigger({key: "Enter"})
if (pubkey && pubkey !== user.getPubkey()) {
compose.mention(getPersonWithFallback(pubkey))
}
})
</script>
@ -107,7 +104,7 @@
<div class="flex flex-col gap-2">
<strong>What do you want to say?</strong>
<div class="border-l-2 border-solid border-gray-6 pl-4">
<Compose bind:this={input} {onSubmit} />
<Compose bind:this={compose} {onSubmit} />
</div>
</div>
{#if image}