Add content parser and image carousel

This commit is contained in:
Jonathan Staab 2023-03-30 11:54:32 -05:00
parent 0896623253
commit fa56bca422
6 changed files with 292 additions and 69 deletions

View File

@ -7,6 +7,7 @@
- [ ] Show all images in preview as slideshow or something
- [ ] Extract nostr: links and bech32 entities, hover
- [ ] Linkify topics
- [ ] Fix extra newlines when composing
- [ ] Multiplexer
- [ ] Announce multiplextr, paravel, coracle update w/url
- [ ] Write NIP to support proxies. Update COUNT NIP to mention how proxies are a good use case for COUNT
@ -15,6 +16,7 @@
- [ ] Multiplex, charge past a certain usage level based on bandwidth
- [ ] Move blog to https://twitter.com/fiatjaf/status/1638514052014940162
- [ ] Add onError handler to subscriptions for when sockets fail to connect?
- [ ] Add bugsnag to multiplexr
# Others
@ -145,3 +147,4 @@
- "adoptarelay.com"
- Add suggested relays based on follows or topics
- [ ] Integrate plephy https://plebhy.com/
- [ ] Switch to https://github.com/techfort/LokiJS for persistence

View File

@ -0,0 +1,65 @@
<script lang="ts">
import {sortBy} from 'ramda'
import {quantify} from 'hurdak/lib/hurdak'
import {slide} from "svelte/transition"
import CarouselItem from "src/partials/CarouselItem.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(
({type}) => type === 'preview' ? 1 : 0,
links
.filter(url => !url.startsWith('ws'))
.map(url => {
if (url.match(".(jpg|jpeg|png|gif)")) {
return {type: 'image', url}
} else if (url.match(".(mov|mp4)")) {
return {type: 'video', url}
} else {
return {type: 'preview', url}
}
})
)
const close = () => {
onClose?.()
hidden = true
}
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>
{#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>
{/if}

View File

@ -0,0 +1,70 @@
<script>
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 showLoading = true
const loadPreview = async () => {
const res = await fetch(user.dufflepud("/link/preview"), {
method: "POST",
body: JSON.stringify({url: link.url}),
headers: {
"Content-Type": "application/json",
},
})
const json = await res.json()
if (!json.title && !json.image) {
throw new Error("Unable to load preview")
}
return json
}
</script>
<Anchor
external
type="unstyled"
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'}
<img alt="Link preview" src={link.url} class="max-h-96 object-contain object-center" />
{:else if link.type === 'video'}
<video controls src={link.url} class="max-h-96 object-contain object-center" />
{:else}
{#await loadPreview()}
{#if showLoading}
<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}
{: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>
{/if}
{/await}
{/if}
</Anchor>

View File

@ -1,4 +1,4 @@
import {uniq} from "ramda"
import {uniq, last} from "ramda"
import {ellipsize, bytes} from "hurdak/lib/hurdak"
export const copyToClipboard = text => {
@ -113,12 +113,87 @@ export const fromParentOffset = (element, offset): [HTMLElement, number] => {
throw new Error("Unable to find parent offset")
}
export const extractUrls = content => {
const regex = /((http|ws)s?:\/\/)?[-a-z0-9@:%_\+~#=\.]+\.[a-z]{1,6}[-a-z0-9:%_\+~#\?!&\/=;\.]*/gi
const urls = content.match(regex) || []
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
return urls.filter(url => !url.match(/^[.\.]+$/) && !url.match(/\.{2}/))
(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) => {
if (buffer) {
result.push({type: "text", value: buffer})
buffer = ""
}
result.push({type, value})
i += value.length
}
for (; i < text.length; ) {
const tail = text.slice(i)
const brMatch = tail.match(/^(<br>)+/)
if (brMatch) {
push("br", brMatch[0])
continue
}
const mentionMatch = tail.match(/^#\[\d+\]/i)
if (mentionMatch) {
push("mention", mentionMatch[0])
continue
}
const topicMatch = tail.match(/^#\w+/i)
if (topicMatch) {
push("topic", topicMatch[0])
continue
}
const urlMatch = tail.match(
/^((http|ws)s?:\/\/)?[-a-z0-9@:%_\+~#=\.]+\.[a-z]{1,6}[-a-z0-9:%_\+~#\?!&\/=;\.]*/gi
)
// Skip url if it's just the end of a filepath
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)
}
push("link", url)
continue
}
// Instead of going character by character and re-running all the above regular expressions
// a million times, try to match the next word and add it to the buffer
const wordMatch = tail.match(/^[\w\d]+ ?/i)
if (wordMatch) {
buffer += wordMatch[0]
i += wordMatch[0].length
} else {
buffer += text[i]
i += 1
}
}
if (buffer) {
result.push({type: "text", value: buffer})
}
return result
}
export const renderContent = content => {

View File

@ -23,6 +23,9 @@ export class Tags {
first() {
return first(this.tags)
}
nth(i) {
return this.tags[i]
}
last() {
return last(this.tags)
}

View File

@ -1,87 +1,94 @@
<script lang="ts">
import {last, uniq, trim} from "ramda"
import {doPipe, ellipsize} from "hurdak/lib/hurdak"
import {extractUrls, escapeHtml} from "src/util/html"
import {displayPerson} from "src/util/nostr"
import Preview from "src/partials/Preview.svelte"
import {first} from "hurdak/lib/hurdak"
import {parseContent} from "src/util/html"
import {displayPerson, Tags} from "src/util/nostr"
import Carousel from "src/partials/Carousel.svelte"
import Anchor from "src/partials/Anchor.svelte"
import user from "src/agent/user"
import {getPersonWithFallback} from "src/agent/tables"
import {routes} from "src/app/ui"
const canPreview = url => url.match("\.(jpg|jpeg|png|gif|mov|mp4)")
export let note
export let showEntire
const links = uniq(extractUrls(note.content))
const content = doPipe(note.content, [
trim,
escapeHtml,
c => (showEntire || c.length < 800 ? c : ellipsize(c, 400)),
c => {
// Pad content with whitespace to simplify our regular expressions
c = `<br>${c}<br>`
const links = []
const shouldTruncate = !showEntire && note.content.length > 800
const content = parseContent(note.content)
for (let url of links) {
// It's common for punctuation to end a url, trim it off
if (url.match(/[\.\?,:]$/)) {
url = url.slice(0, -1)
let l = 0
for (let i = 0; i < content.length; i++) {
const {type, value} = content[i]
// Find links on their own line and remove them from content
if (type === "link") {
const prev = content[i - 1]
const next = content[i + 1]
links.push(value)
if ((!prev || prev.type === "br") && (!next || next.type === "br")) {
let n = 1
for (let j = i + 1; j < content.length; j++) {
if (content[j].type !== "br") {
break
}
n++
}
const href = url.includes("://") ? url : "https://" + url
const display = url.replace(/(http|ws)s?:\/\/(www\.)?/, "").replace(/[\.\/?;,:]$/, "")
const escaped = url.replace(/([.*+?^${}()|[\]\\])/g, "\\$1")
const wsRegex = new RegExp(`<br>${escaped}<br>`, "g")
const slashRegex = new RegExp(`\/${escaped}`, "g")
// Skip stuff that's just at the end of a filepath
if (c.match(slashRegex)) {
continue
}
// If the url is on its own line, remove it entirely
if (c.match(wsRegex) && canPreview(url)) {
c = c.replace(wsRegex, '')
continue
}
// Avoid matching urls inside quotes to avoid double-replacing
const quoteRegex = new RegExp(`([^"]*)(${escaped})([^"]*)`, "g")
const $a = document.createElement("a")
$a.href = href
$a.target = "_blank"
$a.className = "underline"
$a.innerText = ellipsize(display, 50)
c = c.replace(quoteRegex, `$1${$a.outerHTML}$3`)
content.splice(i, n)
}
}
return c.trim()
},
// Mentions
c =>
c.replace(/#\[(\d+)\]/g, (tag, i) => {
if (!note.tags[parseInt(i)]) {
return tag
}
l += value.length
const pubkey = note.tags[parseInt(i)][1]
const person = getPersonWithFallback(pubkey)
const name = displayPerson(person)
const path = routes.person(pubkey)
if (shouldTruncate && l > 400 && type !== "br") {
content[i].value = value.trim()
content.splice(i + 1, content.length, {type: "text", value: "..."})
break
}
}
return `@<a href="${path}" class="underline">${name}</a>`
}),
])
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]
} else {
return Tags.from(note).type("p").values().nth(i)
}
}
</script>
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
<p>{@html content}</p>
<p>
{#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 === "mention"}
{@const pubkey = getMentionPubkey(value)}
{#if pubkey}
@<Anchor href={routes.person(pubkey)}>
{displayPerson(getPersonWithFallback(pubkey))}
</Anchor>
{:else}
{value}
{/if}
{:else}
{value}
{/if}
{/each}
</p>
{#if user.getSetting("showMedia") && links.length > 0}
<button class="inline-block" on:click={e => e.stopPropagation()}>
<Preview url={last(links)} />
<Carousel {links} />
</button>
{/if}
</div>