mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-18 19:23:40 +00:00
Add content parser and image carousel
This commit is contained in:
parent
0896623253
commit
fa56bca422
@ -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
|
||||
|
65
src/partials/Carousel.svelte
Normal file
65
src/partials/Carousel.svelte
Normal 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}
|
70
src/partials/CarouselItem.svelte
Normal file
70
src/partials/CarouselItem.svelte
Normal 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>
|
@ -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 => {
|
||||
|
@ -23,6 +23,9 @@ export class Tags {
|
||||
first() {
|
||||
return first(this.tags)
|
||||
}
|
||||
nth(i) {
|
||||
return this.tags[i]
|
||||
}
|
||||
last() {
|
||||
return last(this.tags)
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user