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 # Current
- [ ] Support other kinds
- Fix note truncation
- [ ] Feeds load forever if a modal is open - [ ] Feeds load forever if a modal is open
- [ ] Support other list types than 30001 - [ ] Support other list types than 30001
- [ ] Fix connection management stuff. Have GPT help - [ ] Fix connection management stuff. Have GPT help

View File

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

View File

@ -1,21 +1,20 @@
<script lang="ts"> <script lang="ts">
import {objOf, reverse} from "ramda" import {pluck, without} from "ramda"
import {fly} from "svelte/transition" import {switcher, switcherFn} from "hurdak/lib/hurdak"
import {splice, switcher, switcherFn} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger"
import {displayPerson, getLabelQuality, displayRelay, Tags} from "src/util/nostr" 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 {modal} from "src/partials/state"
import MediaSet from "src/partials/MediaSet.svelte" import MediaSet from "src/partials/MediaSet.svelte"
import QRCode from "src/partials/QRCode.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 Anchor from "src/partials/Anchor.svelte"
import Rating from "src/partials/Rating.svelte" import Rating from "src/partials/Rating.svelte"
import PersonCircle from "src/app/shared/PersonCircle.svelte" import NoteContentNewline from "src/app/shared/NoteContentNewline.svelte"
import {sampleRelays} from "src/agent/relays" 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 user from "src/agent/user"
import network from "src/agent/network"
import {getPersonWithFallback} from "src/agent/db" import {getPersonWithFallback} from "src/agent/db"
export let note export let note
@ -24,107 +23,26 @@
export let showEntire = false export let showEntire = false
export let showMedia = user.getSetting("showMedia") export let showMedia = user.getSetting("showMedia")
const truncateAt = maxLength * 0.6 const getLinks = parts =>
const shouldTruncate = !showEntire && note.content.length > maxLength pluck(
"value",
let content = parseContent(note) parts.filter(x => x.type === LINK && x.canDisplay)
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 isStandalone = i => { const isStandalone = i => {
return ( return (
!content[i - 1] || !shortContent[i - 1] ||
content[i - 1].type === "newline" || shortContent[i - 1].type === NEWLINE ||
!content[i + 1] || !shortContent[i + 1] ||
content[i + 1].type === "newline" shortContent[i + 1].type === NEWLINE
) )
} }
const loadQuote = async ({id, relays}) => { const fullContent = parseContent(note)
// Follow relay hints const shortContent = truncateContent(fullContent, {maxLength, showEntire, showMedia})
relays = (relays || []).map(objOf("url")).concat(Tags.from(note).equals(id).relays()) const rating = note.kind === 1985 ? getLabelQuality("review/relay", note) : null
const links = getLinks(shortContent)
try { const extraLinks = without(links, getLinks(fullContent))
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})
}
</script> </script>
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis"> <div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
@ -156,64 +74,34 @@
</div> </div>
</div> </div>
{/if} {/if}
{#each content as { type, value }, i} {#each shortContent as { type, value }, i}
{#if type === "newline" && i > 0} {#if type === NEWLINE}
{#each value as _} <NoteContentNewline {value} />
<br /> {:else if type === TOPIC}
{/each} <NoteContentTopic {value} />
{:else if type === "topic"} {:else if type === INVOICE}
<Anchor killEvent on:click={() => openTopic(value)}>#{value}</Anchor> <div on:click|stopPropagation>
{:else if type === "link"} <QRCode fullWidth onClick="copy" code={value} />
<Anchor external href={value}> </div>
{value.replace(/https?:\/\/(www\.)?/, "")} {:else if type === LINK}
</Anchor> <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:")} {:else if type.startsWith("nostr:")}
{#if showMedia && value.id && isStandalone(i) && value.id !== anchorId} <NoteContentEntity {value} />
<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}
{:else} {:else}
{value} {value}
{/if} {/if}
{" "} {" "}
{/each} {/each}
</p> </p>
{#if invoices.length > 0} {#if showMedia && extraLinks.length > 0}
<div on:click|stopPropagation> <MediaSet links={extraLinks} />
<QRCode fullWidth onClick="copy" code={invoices[0]} />
</div>
{/if}
{#if showMedia && links.length > 0}
<div on:click|stopPropagation>
<MediaSet {links} />
</div>
{/if} {/if}
</div> </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 /> <br />
{/each} {/each}
{:else if type === "link"} {:else if type === "link"}
<Anchor external href={value}> <Anchor external href={value.url}>
{value.replace(/https?:\/\/(www\.)?/, "")} {value.url.replace(/https?:\/\/(www\.)?/, "")}
</Anchor> </Anchor>
{:else if type.startsWith("nostr:")} {:else if type.startsWith("nostr:")}
<Anchor external href={"/" + value.entity}> <Anchor external href={"/" + value.entity}>

View File

@ -75,13 +75,13 @@
$or: [{"kind0.name": {$type: "string"}}, {"kind0.display_name": {$type: "string"}}], $or: [{"kind0.name": {$type: "string"}}, {"kind0.display_name": {$type: "string"}}],
}) })
.map(person => { .map(person => {
const {name, about, display_name} = person.kind0 const {name, nip05, about, display_name} = person.kind0
return { return {
person, person,
type: "person", type: "person",
id: person.pubkey, 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. camera icon to scan with your device's camera instead.
</p> </p>
</div> </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="before" class="fa-solid fa-search" />
<i <i
slot="after" 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", "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": "button-accent":
"py-2 px-4 rounded-full bg-accent text-white whitespace-nowrap border border-solid border-accent-light hover:bg-accent-light", "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"> <script lang="ts">
import {sortBy} from "ramda" import {sortBy} from "ramda"
import {slide} from "svelte/transition"
import {annotateMedia} from "src/util/misc" import {annotateMedia} from "src/util/misc"
import Media from "src/partials/Media.svelte" import Media from "src/partials/Media.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import Modal from "src/partials/Modal.svelte" import Modal from "src/partials/Modal.svelte"
export let links export let links
export let onClose = null
let hidden = false
let showModal = false let showModal = false
// Put previews last since we need to load them asynchronously // Put previews last since we need to load them asynchronously
const annotated = sortBy(l => (l.type === "preview" ? 1 : 0), links.map(annotateMedia)) const annotated = sortBy(
l => (l.type === "preview" ? 1 : 0),
const close = () => { links.map(link => annotateMedia(link.url))
onClose?.() )
hidden = true
}
const openModal = () => { const openModal = () => {
showModal = true showModal = true
} }
const closeModal = () => { const closeModal = () => {
showModal = false showModal = false
} }
</script> </script>
{#if !hidden} <div class="my-8 flex justify-center">
<div in:slide class="relative"> <Anchor type="button-minimal" on:click={openModal}>
<Media link={annotated[0]} onClose={close} /> <i class="fa fa-plus" /> Show all {annotated.length} link previews
{#if annotated.length > 1} </Anchor>
<p class="text-gray-500 cursor-pointer py-4 text-center underline" on:click={openModal}> </div>
<i class="fa fa-plus" /> Show all {annotated.length} link previews
</p>
{/if}
</div>
{/if}
{#if showModal} {#if showModal}
<Modal onEscape={closeModal}> <Modal onEscape={closeModal}>

View File

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

View File

@ -3,6 +3,19 @@ import {nip19} from "nostr-tools"
import {first} from "hurdak/lib/hurdak" import {first} from "hurdak/lib/hurdak"
import {fromNostrURI} from "src/util/nostr" 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 = []}) => { export const parseContent = ({content, tags = []}) => {
const result = [] const result = []
let text = content.trim() let text = content.trim()
@ -12,7 +25,7 @@ export const parseContent = ({content, tags = []}) => {
const newline = first(text.match(/^\n+/)) const newline = first(text.match(/^\n+/))
if (newline) { if (newline) {
return ["newline", newline, newline] return [NEWLINE, newline, newline]
} }
} }
@ -48,7 +61,7 @@ export const parseContent = ({content, tags = []}) => {
// Skip numeric topics // Skip numeric topics
if (topic && !topic.match(/^#\d+$/)) { 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 parseInvoice = () => {
const lnurl = first(text.match(/^ln(bc|url)[\d\w]{50,1000}/i)) const invoice = first(text.match(/^ln(bc|url)[\d\w]{50,1000}/i))
if (lnurl) { if (invoice) {
return ["lnurl", lnurl, lnurl] return [INVOICE, invoice, invoice]
} }
} }
@ -107,7 +120,7 @@ export const parseContent = ({content, tags = []}) => {
url = "https://" + url url = "https://" + url
} }
return ["link", raw, url] return [LINK, raw, {url, canDisplay: canDisplayUrl(url)}]
} }
} }
@ -118,7 +131,7 @@ export const parseContent = ({content, tags = []}) => {
parseTopic() || parseTopic() ||
parseBech32() || parseBech32() ||
parseUrl() || parseUrl() ||
parseLNUrl() parseInvoice()
if (part) { if (part) {
if (buffer) { if (buffer) {
@ -141,7 +154,42 @@ export const parseContent = ({content, tags = []}) => {
} }
if (buffer) { 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 return result