Add quotes

This commit is contained in:
Jonathan Staab 2023-03-30 14:02:51 -05:00
parent fa56bca422
commit ce07869656
16 changed files with 264 additions and 167 deletions

View File

@ -2,10 +2,10 @@
- [ ] Move blog to nostr
- [ ] Improve note rendering
- [ ] NIP 27
- [ ] Fix reactions and replies showing up
- [ ] Show all images in preview as slideshow or something
- [x] Show all images in preview as slideshow or something
- [ ] Extract nostr: links and bech32 entities, hover
- [ ] Add nostr: links and bech32 entities in compose to tags
- [ ] Linkify topics
- [ ] Fix extra newlines when composing
- [ ] Multiplexer

View File

@ -266,7 +266,7 @@
<NoteDetail {...$modal} invertColors />
{/key}
{:else if $modal.type === "note/create"}
<NoteCreate pubkey={$modal.pubkey} />
<NoteCreate pubkey={$modal.pubkey} nevent={$modal.nevent} />
{:else if $modal.type === "relay/add"}
<AddRelay />
{:else if $modal.type === "onboarding"}

View File

@ -1,15 +1,13 @@
import type {DisplayEvent} from "src/util/types"
import {omit, sortBy} from "ramda"
import {createMap, ellipsize} from "hurdak/lib/hurdak"
import {renderContent} from "src/util/html"
import {displayPerson, findReplyId} from "src/util/nostr"
import {createMap} from "hurdak/lib/hurdak"
import {findReplyId} from "src/util/nostr"
import {getUserFollows} from "src/agent/social"
import {getUserReadRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import network from "src/agent/network"
import keys from "src/agent/keys"
import listener from "src/app/listener"
import {routes, modal, toast} from "src/app/ui"
import {modal, toast} from "src/app/ui"
export const loadAppData = async pubkey => {
if (getUserReadRelays().length > 0) {
@ -28,32 +26,6 @@ export const login = (method, key) => {
modal.set({type: "login/connect", noEscape: true})
}
export const renderNote = (note, {showEntire = false}) => {
let content
// Ellipsize
content = note.content.length > 500 && !showEntire ? ellipsize(note.content, 500) : note.content
// Escape html, replace urls
content = renderContent(content)
// Mentions
content = content.replace(/#\[(\d+)\]/g, (tag, i) => {
if (!note.tags[parseInt(i)]) {
return tag
}
const pubkey = note.tags[parseInt(i)][1]
const person = getPersonWithFallback(pubkey)
const name = displayPerson(person)
const path = routes.person(pubkey)
return `@<a href="${path}" class="underline">${name}</a>`
})
return content
}
export const mergeParents = (notes: Array<DisplayEvent>) => {
const notesById = createMap("id", notes) as Record<string, DisplayEvent>
const childIds = []

View File

@ -1,6 +1,5 @@
<script lang="ts">
import {sortBy} from 'ramda'
import {quantify} from 'hurdak/lib/hurdak'
import {sortBy} from "ramda"
import {slide} from "svelte/transition"
import CarouselItem from "src/partials/CarouselItem.svelte"
import Content from "src/partials/Content.svelte"
@ -14,16 +13,16 @@
// Put previews last since we need to load them asynchronously
const annotated = sortBy(
({type}) => type === 'preview' ? 1 : 0,
({type}) => (type === "preview" ? 1 : 0),
links
.filter(url => !url.startsWith('ws'))
.filter(url => !url.startsWith("ws"))
.map(url => {
if (url.match(".(jpg|jpeg|png|gif)")) {
return {type: 'image', url}
return {type: "image", url}
} else if (url.match(".(mov|mp4)")) {
return {type: 'video', url}
return {type: "video", url}
} else {
return {type: 'preview', url}
return {type: "preview", url}
}
})
)
@ -33,33 +32,31 @@
hidden = true
}
const openModal = () => { showModal = true }
const closeModal = () => { showModal = false }
const openModal = () => {
showModal = true
}
const closeModal = () => {
showModal = false
}
</script>
{#if !hidden}
<div in:slide class="relative">
<CarouselItem link={annotated[0]} showLoading={false} />
<div
on:click|preventDefault={close}
class="absolute top-0 right-0 m-1 flex h-6 w-6 items-center justify-center
rounded-full border border-solid border-gray-6 bg-white text-black opacity-50 shadow">
<i class="fa fa-times" />
<div in:slide class="relative">
<CarouselItem link={annotated[0]} showLoading={false} onClose={close} />
{#if annotated.length > 1}
<p class="text-gray-500 py-4 text-center underline" on:click={openModal}>
<i class="fa fa-plus" /> Show {annotated.length} link previews
</p>
{/if}
</div>
{#if annotated.length > 1}
<p class="py-4 text-gray-500" on:click={openModal}>
<i class="fa fa-plus" /> Show all {annotated.length} link previews
</p>
{/if}
</div>
{/if}
{#if showModal}
<Modal onEscape={closeModal}>
<Content>
{#each annotated as link}
<CarouselItem {link} />
{/each}
</Content>
</Modal>
<Modal onEscape={closeModal}>
<Content>
{#each annotated as link}
<CarouselItem {link} />
{/each}
</Content>
</Modal>
{/if}

View File

@ -1,13 +1,14 @@
<script>
import cx from 'classnames'
import {ellipsize} from 'hurdak/lib/hurdak'
import {fly} from 'svelte/transition'
import cx from "classnames"
import {ellipsize} from "hurdak/lib/hurdak"
import {fly} from "svelte/transition"
import Anchor from "src/partials/Anchor.svelte"
import Spinner from "src/partials/Spinner.svelte"
import user from "src/agent/user"
export let link
export let onClick = null
export let onClose = null
export let showLoading = true
const loadPreview = async () => {
@ -35,35 +36,42 @@
href={onClick ? null : link.url}
on:click={onClick}
style="background-color: rgba(15, 15, 14, 0.5)"
class={cx(
"relative flex flex-col overflow-hidden rounded border-solid border-gray-6",
{border: showLoading || link.type !== 'preview'}
)}>
{#if link.type === 'image'}
class={cx("relative flex flex-col overflow-hidden rounded border-solid border-gray-6", {
border: showLoading || link.type !== "preview",
})}>
{#if link.type === "image"}
<img alt="Link preview" src={link.url} class="max-h-96 object-contain object-center" />
{:else if link.type === 'video'}
{:else if link.type === "video"}
<video controls src={link.url} class="max-h-96 object-contain object-center" />
{:else}
{#await loadPreview()}
{#if showLoading}
<Spinner />
<Spinner />
{/if}
{:then { title, description, image }}
{#if image}
<img alt="Link preview" src={image} class="max-h-96 object-contain object-center" />
{/if}
<div class="h-px bg-gray-6" />
{#if title}
<div class="flex flex-col bg-white px-4 py-2 text-black">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap">{title}</strong>
<small>{ellipsize(description, 140)}</small>
</div>
{/if}
{#if onClose}
<div
on:click|preventDefault={onClose}
class="absolute top-0 right-0 m-1 flex h-6 w-6 cursor-pointer items-center justify-center
rounded-full border border-solid border-gray-6 bg-white text-black opacity-50 shadow">
<i class="fa fa-times" />
</div>
{/if}
{:then {title, description, image}}
{#if image}
<img alt="Link preview" src={image} class="max-h-96 object-contain object-center" />
{/if}
<div class="h-px bg-gray-6" />
{#if title}
<div class="flex flex-col bg-white px-4 py-2 text-black">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap">{title}</strong>
<small>{ellipsize(description, 140)}</small>
</div>
{/if}
{:catch}
{#if showLoading}
<p class="mb-1 py-24 text-gray-5" in:fly={{y: 20}}>
Unable to load a preview for {link.url}
</p>
<p class="mb-1 py-24 px-12 text-center text-gray-5" in:fly={{y: 20}}>
Unable to load a preview for {link.url}
</p>
{/if}
{/await}
{/if}

View File

@ -142,6 +142,26 @@
autocomplete({person})
}
const createNewLines = (n = 1) => {
const div = document.createElement("div")
div.innerHTML = "<br>".repeat(n)
return div
}
export const nevent = text => {
const input = contenteditable.getInput()
const selection = window.getSelection()
const textNode = document.createTextNode(text)
const newLines = createNewLines(2)
selection.getRangeAt(0).insertNode(textNode)
selection.collapse(input, 1)
selection.getRangeAt(0).insertNode(newLines)
selection.collapse(input, 2)
}
export const parse = () => {
let {content, annotations} = contenteditable.parse()
const topics = pluck("value", annotations.filter(propEq("prefix", "#")))

View File

@ -0,0 +1,37 @@
<script lang="ts">
import {ellipsize} from "hurdak/lib/hurdak"
import {parseContent} from "src/util/html"
import {displayPerson} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte"
import {getPersonWithFallback} from "src/agent/tables"
export let person
export let truncate = false
const about = person?.kind0?.about || ""
const content = parseContent(truncate ? ellipsize(about, 140) : about)
</script>
<p class="overflow-hidden text-ellipsis">
{#each content as { type, value }}
{#if type === "br"}
{@html value}
{:else if type === "link"}
<Anchor external href={value}>
{value.replace(/https?:\/\/(www\.)?/, "")}
</Anchor>
{:else if type.startsWith("nostr:")}
<Anchor external href={"/" + value.entity}>
{#if value.pubkey}
{displayPerson(getPersonWithFallback(value.pubkey))}
{:else if value.id}
event {value.id}
{:else}
{value.entity.slice(0, 10) + "..."}
{/if}
</Anchor>
{:else}
{value}
{/if}
{/each}
</p>

View File

@ -1,11 +1,11 @@
<script lang="ts">
import {last} from "ramda"
import {fly} from "svelte/transition"
import {ellipsize} from "hurdak/lib/hurdak"
import {renderContent, noEvent} from "src/util/html"
import {noEvent} from "src/util/html"
import {displayPerson} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte"
import PersonCircle from "src/partials/PersonCircle.svelte"
import PersonAbout from "src/partials/PersonAbout.svelte"
import {routes} from "src/app/ui"
export let person
@ -40,7 +40,7 @@
{/if}
</div>
<p class="overflow-hidden text-ellipsis">
{@html renderContent(ellipsize(person.kind0?.about || "", 140))}
<PersonAbout truncate {person} />
</p>
</div>
</a>

View File

@ -6,6 +6,7 @@
import Channel from "src/partials/Channel.svelte"
import Badge from "src/partials/Badge.svelte"
import Anchor from "src/partials/Anchor.svelte"
import NoteContent from "src/views/notes/NoteContent.svelte"
import user from "src/agent/user"
import {getRelaysForEventChildren, sampleRelays} from "src/agent/relays"
import network from "src/agent/network"
@ -13,7 +14,6 @@
import cmd from "src/agent/cmd"
import {modal} from "src/app/ui"
import {lastChecked} from "src/app/listener"
import {renderNote} from "src/app"
export let entity
@ -85,7 +85,7 @@
</div>
{/if}
<div class="my-1 ml-6 overflow-hidden text-ellipsis">
{@html renderNote(message, {showEntire: true})}
<NoteContent showEntire note={message} />
</div>
</div>
</Channel>

View File

@ -7,6 +7,7 @@
import {Tags} from "src/util/nostr"
import Channel from "src/partials/Channel.svelte"
import Anchor from "src/partials/Anchor.svelte"
import NoteContent from "src/views/notes/NoteContent.svelte"
import {getAllPubkeyRelays, sampleRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
@ -16,7 +17,6 @@
import cmd from "src/agent/cmd"
import {routes} from "src/app/ui"
import {lastChecked} from "src/app/listener"
import {renderNote} from "src/app"
import PersonCircle from "src/partials/PersonCircle.svelte"
export let entity
@ -106,7 +106,7 @@
"rounded-bl-none bg-gray-7": message.person.pubkey !== user.getPubkey(),
})}>
<div class="break-words">
{@html renderNote(message, {showEntire: true})}
<NoteContent showEntire note={message} />
</div>
<small
class="mt-1"

View File

@ -5,7 +5,7 @@
import {fly, fade} from "svelte/transition"
import {navigate} from "svelte-routing"
import {log} from "src/util/logger"
import {renderContent, parseHex} from "src/util/html"
import {parseHex} from "src/util/html"
import {numberFmt} from "src/util/misc"
import {displayPerson, toHex} from "src/util/nostr"
import Tabs from "src/partials/Tabs.svelte"
@ -23,6 +23,7 @@
import {getPersonWithFallback} from "src/agent/tables"
import {routes, modal, theme, getThemeColor} from "src/app/ui"
import PersonCircle from "src/partials/PersonCircle.svelte"
import PersonAbout from "src/partials/PersonAbout.svelte"
export let npub
export let activeTab
@ -222,7 +223,7 @@
</div>
</div>
</div>
<p>{@html renderContent(person?.kind0?.about || "")}</p>
<PersonAbout {person} />
{#if person?.petnames}
<div class="flex gap-8" in:fly={{y: 20}}>
<button on:click={showFollows}>

View File

@ -1,5 +1,6 @@
import {uniq, last} from "ramda"
import {ellipsize, bytes} from "hurdak/lib/hurdak"
import {nip19} from "nostr-tools"
import {last} from "ramda"
import {bytes} from "hurdak/lib/hurdak"
export const copyToClipboard = text => {
const {activeElement} = document
@ -101,38 +102,20 @@ export const noEvent = f => e => {
f()
}
export const fromParentOffset = (element, offset): [HTMLElement, number] => {
for (const child of element.childNodes) {
if (offset <= child.textContent.length) {
return [child, offset]
}
offset -= child.textContent.length
}
throw new Error("Unable to find parent offset")
}
const urlRegex = /((http|ws)s?:\/\/)?[-a-z0-9@:%_\+~#=\.]+\.[a-z]{1,6}[-a-z0-9:%_\+~#\?!&\/=;\.]*/gi
export const extractUrls = content =>
// Skip stuff like 3.5 or U.S. and ellipses which have more than one dot in a row
(content.match(urlRegex) || []).filter(url => !url.match(/^[.\.]+$/) && !url.match(/\.{2}/))
export const parseContent = content => {
const text = escapeHtml(content.trim())
const result = []
let buffer = "",
i = 0
const push = (type, value) => {
const push = (type, text, value = null) => {
if (buffer) {
result.push({type: "text", value: buffer})
buffer = ""
}
result.push({type, value})
i += value.length
result.push({type, value: value || text})
i += text.length
}
for (; i < text.length; ) {
@ -159,6 +142,21 @@ export const parseContent = content => {
continue
}
const bech32Match = tail.match(/^(nostr:)?n(event|ote|profile|pub)1[\d\w]+/i)
if (bech32Match) {
try {
const entity = bech32Match[0].replace("nostr:", "")
const {type, data} = nip19.decode(entity) as {type: string; data: object}
push(`nostr:${type}`, bech32Match[0], {...data, entity})
continue
} catch (e) {
console.log(e)
// pass
}
}
const urlMatch = tail.match(
/^((http|ws)s?:\/\/)?[-a-z0-9@:%_\+~#=\.]+\.[a-z]{1,6}[-a-z0-9:%_\+~#\?!&\/=;\.]*/gi
)
@ -167,13 +165,16 @@ export const parseContent = content => {
if (urlMatch && !last(result)?.value.endsWith("/")) {
let url = urlMatch[0]
// It's common for punctuation to end a url, trim it off
if (url.match(/[\.\?,:]$/)) {
url = url.slice(0, -1)
}
// Skip ellipses
if (!url.match(/\.\./)) {
// It's common for punctuation to end a url, trim it off
if (url.match(/[\.\?,:]$/)) {
url = url.slice(0, -1)
}
push("link", url)
continue
push("link", urlMatch[0], url)
continue
}
}
// Instead of going character by character and re-running all the above regular expressions
@ -196,37 +197,6 @@ export const parseContent = content => {
return result
}
export const renderContent = content => {
/* eslint no-useless-escape: 0 */
// Escape html
content = escapeHtml(content)
// Extract urls
for (let url of uniq(extractUrls(content))) {
// It's common for a period to end a url, trim it off
if (url.endsWith(".")) {
url = url.slice(0, -1)
}
const href = url.includes("://") ? url : "https://" + url
const display = url.replace(/https?:\/\/(www\.)?/, "")
const escaped = url.replace(/([.*+?^${}()|[\]\\])/g, "\\$1")
const regex = new RegExp(`([^"]*)(${escaped})([^"]*)`, "g")
const $a = document.createElement("a")
$a.href = href
$a.target = "_blank"
$a.className = "underline"
$a.innerText = ellipsize(display, 50)
content = content.replace(regex, `$1${$a.outerHTML}$3`)
}
return content.trim()
}
export const isMobile = localStorage.mobile || window.navigator.maxTouchPoints > 1
export const parseHex = hex => {

View File

@ -8,7 +8,7 @@
import {quantify} from "hurdak/lib/hurdak"
import {Tags, findRootId, findReplyId, displayPerson, isLike} from "src/util/nostr"
import {formatTimestamp, now, tryJson, formatSats, fetchJson} from "src/util/misc"
import {isMobile} from "src/util/html"
import {isMobile, copyToClipboard} from "src/util/html"
import {invoiceAmount} from "src/util/lightning"
import QRCode from "src/partials/QRCode.svelte"
import ImageInput from "src/partials/ImageInput.svelte"
@ -297,6 +297,19 @@
}
}
const copyLink = () => {
const nevent = nip19.neventEncode({id: note.id, relays: [note.seen_on]})
copyToClipboard("nostr:" + nevent)
toast.show("info", "Copied to clipboard!")
}
const quote = () => {
const nevent = nip19.neventEncode({id: note.id, relays: [note.seen_on]})
modal.set({type: "note/create", nevent})
}
const onBodyClick = e => {
const target = e.target as HTMLElement
@ -451,6 +464,12 @@
<i class="fa fa-server" />
</Anchor>
{/if}
<Anchor type="button-circle" on:click={copyLink}>
<i class="fa fa-link" />
</Anchor>
<Anchor type="button-circle" on:click={quote}>
<i class="fa fa-quote-left" />
</Anchor>
{#if muted}
<Anchor type="button-circle" on:click={unmute}>
<i class="fa fa-microphone" />

View File

@ -1,17 +1,27 @@
<script lang="ts">
import {objOf} from "ramda"
import {navigate} from "svelte-routing"
import {fly} from "svelte/transition"
import {first} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger"
import {parseContent} from "src/util/html"
import {displayPerson, Tags} from "src/util/nostr"
import Carousel from "src/partials/Carousel.svelte"
import Card from "src/partials/Card.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Anchor from "src/partials/Anchor.svelte"
import PersonCircle from "src/partials/PersonCircle.svelte"
import {sampleRelays} from "src/agent/relays"
import user from "src/agent/user"
import network from "src/agent/network"
import {getPersonWithFallback} from "src/agent/tables"
import {routes} from "src/app/ui"
import {routes, modal} from "src/app/ui"
export let note
export let showEntire
const links = []
const entities = []
const shouldTruncate = !showEntire && note.content.length > 800
const content = parseContent(note.content)
@ -20,14 +30,18 @@
const {type, value} = content[i]
// Find links on their own line and remove them from content
if (type === "link") {
if (type === "link" || ["nostr:note", "nostr:nevent"].includes(type)) {
const prev = content[i - 1]
const next = content[i + 1]
links.push(value)
if (type === "link") {
links.push(value)
} else {
entities.push({type, value})
}
if ((!prev || prev.type === "br") && (!next || next.type === "br")) {
let n = 1
let n = 0
for (let j = i + 1; j < content.length; j++) {
if (content[j].type !== "br") {
@ -37,7 +51,8 @@
n++
}
content.splice(i, n)
content.splice(i, n + 1)
i = i - n
}
}
@ -53,7 +68,6 @@
const getMentionPubkey = text => {
const i = parseInt(first(text.match(/\d+/)))
console.log(note.tags, i)
// Some implementations count only p tags when calculating index
if (note.tags[i]?.[0] === "p") {
return note.tags[i][1]
@ -61,6 +75,23 @@
return Tags.from(note).type("p").values().nth(i)
}
}
const loadQuote = async ({id, relays}) => {
try {
const [event] = await network.load({
relays: sampleRelays((relays || []).map(objOf("url"))),
filter: [{ids: [id]}],
})
return event || Promise.reject()
} catch (e) {
warn(e)
}
}
const openQuote = id => {
modal.set({type: "note/detail", note: {id}})
}
</script>
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
@ -72,6 +103,16 @@
<Anchor external href={value}>
{value.replace(/https?:\/\/(www\.)?/, "")}
</Anchor>
{:else if type.startsWith("nostr:")}
<Anchor external href={"/" + value.entity}>
{#if value.pubkey}
{displayPerson(getPersonWithFallback(value.pubkey))}
{:else if value.id}
event {value.id}
{:else}
{value.entity.slice(0, 10) + "..."}
{/if}
</Anchor>
{:else if type === "mention"}
{@const pubkey = getMentionPubkey(value)}
{#if pubkey}
@ -87,8 +128,35 @@
{/each}
</p>
{#if user.getSetting("showMedia") && links.length > 0}
<button class="inline-block" on:click={e => e.stopPropagation()}>
<div on:click={e => e.stopPropagation()}>
<Carousel {links} />
</button>
</div>
{/if}
{#if entities.length > 0}
<div class="py-2" on:click={e => e.stopPropagation()}>
{#each entities as { value }}
<Card interactive invertColors on:click={() => openQuote(value.id)}>
{#await loadQuote(value)}
<Spinner />
{:then quote}
{@const person = getPersonWithFallback(quote.pubkey)}
<div class="mb-4 flex items-center gap-4">
<PersonCircle size={6} {person} />
<Anchor
type="unstyled"
class="flex items-center gap-2"
on:click={() => navigate(routes.person(quote.pubkey))}>
<h2 class="text-lg">{displayPerson(person)}</h2>
</Anchor>
</div>
<svelte:self note={quote} />
{:catch}
<p class="mb-1 py-24 text-center text-gray-5" in:fly={{y: 20}}>
Unable to load a preview for quoted event
</p>
{/await}
</Card>
{/each}
</div>
{/if}
</div>

View File

@ -23,6 +23,7 @@
import {publishWithToast} from "src/app"
export let pubkey = null
export let nevent = null
let image = null
let compose = null
@ -94,6 +95,10 @@
if (pubkey && pubkey !== user.getPubkey()) {
compose.mention(getPersonWithFallback(pubkey))
}
if (nevent) {
compose.nevent("nostr:" + nevent)
}
})
</script>

View File

@ -1,7 +1,6 @@
<script lang="ts">
import {last} from "ramda"
import {navigate} from "svelte-routing"
import {renderContent} from "src/util/html"
import {displayPerson} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte"
import user from "src/agent/user"
@ -10,6 +9,7 @@
import {watch} from "src/agent/storage"
import {routes} from "src/app/ui"
import PersonCircle from "src/partials/PersonCircle.svelte"
import PersonAbout from "src/partials/PersonAbout.svelte"
export let pubkey
@ -63,5 +63,5 @@
{/if}
</div>
</div>
<p>{@html renderContent($person?.kind0?.about || "")}</p>
<PersonAbout person={$person} />
</div>