Refactor NoteContent

This commit is contained in:
Jonathan Staab 2023-06-16 17:19:07 -07:00
parent 61f44e340a
commit 7c9c2ee692
15 changed files with 257 additions and 191 deletions

View File

@ -1,5 +1,7 @@
# Current
- [ ] Support other kinds
- Fix note truncation
- [ ] Feeds load forever if a modal is open
- [ ] Support other list types than 30001
- [ ] Fix connection management stuff. Have GPT help

View File

@ -97,7 +97,7 @@
onChange(newFilter)
}
const applySearch = debounce(200, applyFilter)
const applySearch = debounce(400, applyFilter)
const clearSearch = () => {
_filter.search = ""

View File

@ -1,21 +1,20 @@
<script lang="ts">
import {objOf, reverse} from "ramda"
import {fly} from "svelte/transition"
import {splice, switcher, switcherFn} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger"
import {pluck, without} from "ramda"
import {switcher, switcherFn} from "hurdak/lib/hurdak"
import {displayPerson, getLabelQuality, displayRelay, Tags} from "src/util/nostr"
import {parseContent} from "src/util/notes"
import {parseContent, truncateContent, LINK, INVOICE, NEWLINE, TOPIC} from "src/util/notes"
import {modal} from "src/partials/state"
import MediaSet from "src/partials/MediaSet.svelte"
import QRCode from "src/partials/QRCode.svelte"
import Card from "src/partials/Card.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Rating from "src/partials/Rating.svelte"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import {sampleRelays} from "src/agent/relays"
import NoteContentNewline from "src/app/shared/NoteContentNewline.svelte"
import NoteContentTopic from "src/app/shared/NoteContentTopic.svelte"
import NoteContentLink from "src/app/shared/NoteContentLink.svelte"
import NoteContentPerson from "src/app/shared/NoteContentPerson.svelte"
import NoteContentQuote from "src/app/shared/NoteContentQuote.svelte"
import NoteContentEntity from "src/app/shared/NoteContentEntity.svelte"
import user from "src/agent/user"
import network from "src/agent/network"
import {getPersonWithFallback} from "src/agent/db"
export let note
@ -24,107 +23,26 @@
export let showEntire = false
export let showMedia = user.getSetting("showMedia")
const truncateAt = maxLength * 0.6
const shouldTruncate = !showEntire && note.content.length > maxLength
let content = parseContent(note)
let rating = note.kind === 1985 ? getLabelQuality("review/relay", note) : null
const links = []
const invoices = []
const ranges = []
// Find links and preceding whitespace
for (let i = 0; i < content.length; i++) {
const {type, value} = content[i]
if (type === "link") {
links.push(value)
}
if (type === "lnurl") {
invoices.push(value)
}
if (["link", "lnurl"].includes(type) && !value.startsWith("ws")) {
const prev = content[i - 1]
const next = content[i + 1]
if ((!prev || prev.type === "newline") && (!next || next.type === "newline")) {
let n = 1
for (let j = i - 1; ; j--) {
if (content[j]?.type === "newline") {
n += 1
} else {
break
}
}
ranges.push({i: i + 1, n})
}
}
}
// Remove links and preceding line breaks if they're on their own line
if (showMedia) {
for (const {i, n} of reverse(ranges)) {
content = splice(i - n, n, content)
}
}
// Truncate content if needed
let l = 0
if (shouldTruncate) {
for (let i = 0; i < content.length; i++) {
const prev = content[i - 1]
// Avoid adding an ellipsis right after a newline
if (l > truncateAt && prev?.type != "newline") {
content = content.slice(0, i).concat({type: "text", value: "..."})
break
}
if (typeof content[i].value === "string") {
l += content[i].value.length
}
}
}
const getLinks = parts =>
pluck(
"value",
parts.filter(x => x.type === LINK && x.canDisplay)
)
const isStandalone = i => {
return (
!content[i - 1] ||
content[i - 1].type === "newline" ||
!content[i + 1] ||
content[i + 1].type === "newline"
!shortContent[i - 1] ||
shortContent[i - 1].type === NEWLINE ||
!shortContent[i + 1] ||
shortContent[i + 1].type === NEWLINE
)
}
const loadQuote = async ({id, relays}) => {
// Follow relay hints
relays = (relays || []).map(objOf("url")).concat(Tags.from(note).equals(id).relays())
try {
const [event] = await network.load({
relays: sampleRelays(relays),
filter: [{ids: [id]}],
})
return event || Promise.reject()
} catch (e) {
warn(e)
}
}
const openPerson = pubkey => modal.push({type: "person/feed", pubkey})
const openQuote = id => {
modal.push({type: "note/detail", note: {id}})
}
const openTopic = topic => {
modal.push({type: "topic/feed", topic})
}
const fullContent = parseContent(note)
const shortContent = truncateContent(fullContent, {maxLength, showEntire, showMedia})
const rating = note.kind === 1985 ? getLabelQuality("review/relay", note) : null
const links = getLinks(shortContent)
const extraLinks = without(links, getLinks(fullContent))
</script>
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
@ -156,64 +74,34 @@
</div>
</div>
{/if}
{#each content as { type, value }, i}
{#if type === "newline" && i > 0}
{#each value as _}
<br />
{/each}
{:else if type === "topic"}
<Anchor killEvent on:click={() => openTopic(value)}>#{value}</Anchor>
{:else if type === "link"}
<Anchor external href={value}>
{value.replace(/https?:\/\/(www\.)?/, "")}
</Anchor>
{#each shortContent as { type, value }, i}
{#if type === NEWLINE}
<NoteContentNewline {value} />
{:else if type === TOPIC}
<NoteContentTopic {value} />
{:else if type === INVOICE}
<div on:click|stopPropagation>
<QRCode fullWidth onClick="copy" code={value} />
</div>
{:else if type === LINK}
<NoteContentLink {value} showMedia={showMedia && isStandalone(i)} />
{:else if type.match(/^nostr:np(rofile|ub)$/)}
<NoteContentPerson {value} />
{:else if type.startsWith("nostr:") && showMedia && isStandalone(i) && value.id !== anchorId}
<NoteContentQuote {note} {value}>
<div slot="note-content" let:quote>
<svelte:self note={quote} />
</div>
</NoteContentQuote>
{:else if type.startsWith("nostr:")}
{#if showMedia && value.id && isStandalone(i) && value.id !== anchorId}
<Card interactive invertColors class="my-2" 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
stopPropagation
type="unstyled"
class="flex items-center gap-2"
on:click={() => openPerson(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>
{:else if type.match(/np(rofile|ub)$/)}
@<Anchor killEvent on:click={() => openPerson(value.pubkey)}>
{displayPerson(getPersonWithFallback(value.pubkey))}
</Anchor>
{:else}
<Anchor killEvent href={"/" + value.entity}>
{value.entity.slice(0, 16) + "..."}
</Anchor>
{/if}
<NoteContentEntity {value} />
{:else}
{value}
{/if}
{" "}
{/each}
</p>
{#if invoices.length > 0}
<div on:click|stopPropagation>
<QRCode fullWidth onClick="copy" code={invoices[0]} />
</div>
{/if}
{#if showMedia && links.length > 0}
<div on:click|stopPropagation>
<MediaSet {links} />
</div>
{#if showMedia && extraLinks.length > 0}
<MediaSet links={extraLinks} />
{/if}
</div>

View File

@ -0,0 +1,9 @@
<script lang="ts">
import Anchor from "src/partials/Anchor.svelte"
export let value
</script>
<Anchor killEvent href={"/" + value.entity}>
{value.entity.slice(0, 16) + "..."}
</Anchor>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import {annotateMedia} from "src/util/misc"
import Anchor from "src/partials/Anchor.svelte"
import Media from "src/partials/Media.svelte"
export let value
export let showMedia
const close = () => {
hidden = true
}
console.log(value)
let hidden = false
</script>
{#if showMedia && value.canDisplay}
<div class="py-2">
<Media link={annotateMedia(value.url)} onClose={close} />
</div>
{:else}
<Anchor external href={value.url}>
{value.url.replace(/https?:\/\/(www\.)?/, "")}
</Anchor>
{/if}

View File

@ -0,0 +1,7 @@
<script lang="ts">
export let value
</script>
{#each value as _}
<br />
{/each}

View File

@ -0,0 +1,14 @@
<script lang="ts">
import {displayPerson} from "src/util/nostr"
import {modal} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
import {getPersonWithFallback} from "src/agent/db"
export let value
const openPerson = () => modal.push({type: "person/feed", pubkey: value.pubkey})
</script>
@<Anchor killEvent on:click={openPerson}>
{displayPerson(getPersonWithFallback(value.pubkey))}
</Anchor>

View File

@ -0,0 +1,65 @@
<script lang="ts">
import {objOf} from "ramda"
import {fly} from "svelte/transition"
import {warn} from "src/util/logger"
import {displayPerson, Tags} from "src/util/nostr"
import {modal} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
import Card from "src/partials/Card.svelte"
import Spinner from "src/partials/Spinner.svelte"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import {getPersonWithFallback} from "src/agent/db"
import {sampleRelays} from "src/agent/relays"
import network from "src/agent/network"
export let note
export let value
const openPerson = pubkey => modal.push({type: "person/feed", pubkey})
const loadQuote = async () => {
const {id, relays} = value
try {
const [event] = await network.load({
relays: sampleRelays(
(relays || []).map(objOf("url")).concat(Tags.from(note).equals(id).relays())
),
filter: [{ids: [id]}],
})
return event || Promise.reject()
} catch (e) {
warn(e)
}
}
const openQuote = () => {
modal.push({type: "note/detail", note: {id: value.id}})
}
</script>
<div class="py-2">
<Card interactive invertColors class="my-2" on:click={openQuote}>
{#await loadQuote()}
<Spinner />
{:then quote}
{@const person = getPersonWithFallback(quote.pubkey)}
<div class="mb-4 flex items-center gap-4">
<PersonCircle size={6} {person} />
<Anchor
stopPropagation
type="unstyled"
class="flex items-center gap-2"
on:click={() => openPerson(quote.pubkey)}>
<h2 class="text-lg">{displayPerson(person)}</h2>
</Anchor>
</div>
<slot name="note-content" {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>
</div>

View File

@ -0,0 +1,12 @@
<script lang="ts">
import {modal} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
export let value
const openTopic = topic => {
modal.push({type: "topic/feed", topic})
}
</script>
<Anchor killEvent on:click={() => openTopic(value)}>#{value}</Anchor>

View File

@ -19,8 +19,8 @@
<br />
{/each}
{:else if type === "link"}
<Anchor external href={value}>
{value.replace(/https?:\/\/(www\.)?/, "")}
<Anchor external href={value.url}>
{value.url.replace(/https?:\/\/(www\.)?/, "")}
</Anchor>
{:else if type.startsWith("nostr:")}
<Anchor external href={"/" + value.entity}>

View File

@ -75,13 +75,13 @@
$or: [{"kind0.name": {$type: "string"}}, {"kind0.display_name": {$type: "string"}}],
})
.map(person => {
const {name, about, display_name} = person.kind0
const {name, nip05, about, display_name} = person.kind0
return {
person,
type: "person",
id: person.pubkey,
text: "@" + [name, about, display_name].filter(identity).join(" "),
text: "@" + [name, about, nip05, display_name].filter(identity).join(" "),
}
})
)
@ -116,7 +116,7 @@
camera icon to scan with your device's camera instead.
</p>
</div>
<Input bind:value={q} placeholder="Search for people or topics">
<Input autofocus bind:value={q} placeholder="Search for people or topics">
<i slot="before" class="fa-solid fa-search" />
<i
slot="after"

View File

@ -33,6 +33,8 @@
"w-10 h-10 flex justify-center items-center rounded-full bg-gray-8 text-white whitespace-nowrap border border-solid border-gray-7",
"button-accent":
"py-2 px-4 rounded-full bg-accent text-white whitespace-nowrap border border-solid border-accent-light hover:bg-accent-light",
"button-minimal":
"py-2 px-4 rounded-full whitespace-nowrap border border-solid border-gray-2",
})
)

View File

@ -1,43 +1,35 @@
<script lang="ts">
import {sortBy} from "ramda"
import {slide} from "svelte/transition"
import {annotateMedia} from "src/util/misc"
import Media from "src/partials/Media.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import Modal from "src/partials/Modal.svelte"
export let links
export let onClose = null
let hidden = false
let showModal = false
// Put previews last since we need to load them asynchronously
const annotated = sortBy(l => (l.type === "preview" ? 1 : 0), links.map(annotateMedia))
const close = () => {
onClose?.()
hidden = true
}
const annotated = sortBy(
l => (l.type === "preview" ? 1 : 0),
links.map(link => annotateMedia(link.url))
)
const openModal = () => {
showModal = true
}
const closeModal = () => {
showModal = false
}
</script>
{#if !hidden}
<div in:slide class="relative">
<Media link={annotated[0]} onClose={close} />
{#if annotated.length > 1}
<p class="text-gray-500 cursor-pointer py-4 text-center underline" on:click={openModal}>
<i class="fa fa-plus" /> Show all {annotated.length} link previews
</p>
{/if}
</div>
{/if}
<div class="my-8 flex justify-center">
<Anchor type="button-minimal" on:click={openModal}>
<i class="fa fa-plus" /> Show all {annotated.length} link previews
</Anchor>
</div>
{#if showModal}
<Modal onEscape={closeModal}>

View File

@ -6,6 +6,7 @@ import {tryJson, avg} from "src/util/misc"
import {invoiceAmount} from "src/util/lightning"
export const noteKinds = [1, 1985, 30023, 30018, 10001, 1063, 9802]
// export const noteKinds = [9802]
export const personKinds = [0, 2, 3, 10001, 10002]
export const userKinds = personKinds.concat([10000, 30001, 30078])
export const appDataKeys = [

View File

@ -3,6 +3,19 @@ import {nip19} from "nostr-tools"
import {first} from "hurdak/lib/hurdak"
import {fromNostrURI} from "src/util/nostr"
export const NEWLINE = "newline"
export const TEXT = "text"
export const TOPIC = "topic"
export const LINK = "link"
export const INVOICE = "invoice"
export const NOSTR_NOTE = "nostr:note"
export const NOSTR_NEVENT = "nostr:nevent"
export const NOSTR_NPUB = "nostr:npub"
export const NOSTR_NPROFILE = "nostr:nprofile"
export const NOSTR_NADDR = "nostr:naddr"
const canDisplayUrl = url => !url.match(/\.(apk|docx|xlsx|csv|dmg)/)
export const parseContent = ({content, tags = []}) => {
const result = []
let text = content.trim()
@ -12,7 +25,7 @@ export const parseContent = ({content, tags = []}) => {
const newline = first(text.match(/^\n+/))
if (newline) {
return ["newline", newline, newline]
return [NEWLINE, newline, newline]
}
}
@ -48,7 +61,7 @@ export const parseContent = ({content, tags = []}) => {
// Skip numeric topics
if (topic && !topic.match(/^#\d+$/)) {
return ["topic", topic, topic.slice(1)]
return [TOPIC, topic, topic.slice(1)]
}
}
@ -77,11 +90,11 @@ export const parseContent = ({content, tags = []}) => {
}
}
const parseLNUrl = () => {
const lnurl = first(text.match(/^ln(bc|url)[\d\w]{50,1000}/i))
const parseInvoice = () => {
const invoice = first(text.match(/^ln(bc|url)[\d\w]{50,1000}/i))
if (lnurl) {
return ["lnurl", lnurl, lnurl]
if (invoice) {
return [INVOICE, invoice, invoice]
}
}
@ -107,7 +120,7 @@ export const parseContent = ({content, tags = []}) => {
url = "https://" + url
}
return ["link", raw, url]
return [LINK, raw, {url, canDisplay: canDisplayUrl(url)}]
}
}
@ -118,7 +131,7 @@ export const parseContent = ({content, tags = []}) => {
parseTopic() ||
parseBech32() ||
parseUrl() ||
parseLNUrl()
parseInvoice()
if (part) {
if (buffer) {
@ -141,7 +154,42 @@ export const parseContent = ({content, tags = []}) => {
}
if (buffer) {
result.push({type: "text", value: buffer})
result.push({type: TEXT, value: buffer})
}
return result
}
export const truncateContent = (content, {showEntire, maxLength, showMedia}) => {
if (showEntire) {
return content
}
let length = 0
const result = []
const truncateAt = maxLength * 0.6
for (const part of content) {
const isText = [TOPIC, TEXT].includes(part.type)
const isMedia = [LINK, INVOICE].includes(part.type) || part.type.startsWith("nostr:")
if (isText) {
length += part.value.length
}
if (isMedia) {
length += showMedia ? maxLength / 3 : part.value.length
}
result.push(part)
if (length > truncateAt) {
if (isText || (isMedia && !showMedia)) {
result.push({type: TEXT, value: "..."})
}
break
}
}
return result