mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-19 11:43:35 +00:00
Add new content editable component
This commit is contained in:
parent
d7d05c2802
commit
d0ccdf4888
@ -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
|
||||
|
@ -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 = []}) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
86
src/partials/ContentEditable.svelte
Normal file
86
src/partials/ContentEditable.svelte
Normal 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 />
|
48
src/partials/Suggestions.svelte
Normal file
48
src/partials/Suggestions.svelte
Normal 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}
|
@ -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]
|
||||
}
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user