Add NIP 96 support

This commit is contained in:
Jon Staab 2023-11-22 13:04:49 -08:00
parent b165aec8ca
commit e23b9f1fa7
29 changed files with 536 additions and 416 deletions

1
.env
View File

@ -2,6 +2,7 @@ VITE_THEME=transparent:transparent,black:#0f0f0e,white:#FFFFFF,accent:#EB5E28,ac
VITE_DVM_RELAYS=wss://relay.damus.io,wss://relay.snort.social,wss://bucket.coracle.social,wss://offchain.pub
VITE_DEFAULT_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://relay.nostr.band,wss://relayable.org,wss://nostr.wine
VITE_DEFAULT_FOLLOWS=1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef,fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52,cc8d072efdcc676fcbac14f6cd6825edc3576e55eb786a2a975ee034a6a026cb,d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075,3335d373e6c1b5bc669b4b1220c08728ea8ce622e5a7cfeeb4c0001d91ded1de,0b118e40d6f3dfabb17f21a94a647701f140d8b063a9e84fe6e483644edc09cb,b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450,958b754a1d3de5b5eca0fe31d2d555f451325f8498a83da1997b7fcd5c39e88c,b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc,b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e,5c508c34f58866ec7341aaf10cc1af52e9232bb9f859c8103ca5ecf2aa93bf78,baf27a4cc4da49913e7fdecc951fd3b971c9279959af62b02b761a043c33384c,2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884,0fecf65daa26faf3f668e8143325a4c199a040b6345ed40a08614d7dd85b1823,1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411,f783ba3b12b91e375aba6594015b90bd95f7e132b03cc8c4c52ce0a7c36aab52,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,a1fc5dfd7ffcf563c89155b466751b580d115e136e2f8c90e8913385bbedb1cf,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240
VITE_NIP96_URLS=https://nostrcheck.me,https://nostr.build,https://sove.rent,https://void.cat
VITE_IMGPROXY_URL=https://imgproxy.coracle.social
VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com
VITE_ENABLE_GROUPS=true

View File

@ -5,6 +5,9 @@
- [x] Update lists to use new 30003 user bookmarks kind
- [x] Add NIP 72 community support
- [x] Add NIP 87 closed community support
- [x] Add NIP 96 file storage
- [x] Add NIP 98 auth support
- [x] Add DIP 01 imeta tag creation
- [x] Re-work keys page, include group keys
- [x] Add anonymous posting
- [x] Add note options dialog to replies
@ -13,6 +16,7 @@
- [x] Fix throttled notifications derive loop
- [x] Conservatively load from cache when on a slow network
- [x] Add refresh button to feeds
- [x] Add image previews to note reply
# 0.3.12

View File

@ -34,6 +34,7 @@ If you like Coracle and want to support its development, you can donate sats via
- [x] White-labeling support
- [x] NIP 51 person lists
- [x] Exportable copy of all user events
- [x] NIP96 - HTTP File Storage Integration
- [ ] Reporting and basic distributed moderation
- [ ] Content and person recommendations
- [ ] Private groups including administration, moderation, and membership

View File

@ -60,7 +60,7 @@
"normalize-url": "^8.0.0",
"nostr-tools": "^1.12.1",
"npm-run-all": "^4.1.5",
"paravel": "^0.4.9",
"paravel": "^0.4.10",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.1",
"ramda": "^0.29.1",

View File

@ -220,7 +220,7 @@
const selection = window.getSelection()
const textNode = document.createTextNode(text)
const target = (last(input.childNodes) || input) as unknown as Node
const offset = target instanceof Text ? target.wholeText.length : target.childNodes.length
const offset = target instanceof Text ? target.textContent.length : target.childNodes.length
selection.collapse(target, offset)
selection.getRangeAt(0).insertNode(textNode)

View File

@ -1,6 +1,5 @@
<script lang="ts">
import {Tags} from "paravel"
import {annotateMedia} from "src/util/misc"
import Media from "src/partials/Media.svelte"
import NoteContentLabel from "src/app/shared/NoteContentLabel.svelte"
import NoteContentKind1 from "src/app/shared/NoteContentKind1.svelte"
@ -13,7 +12,7 @@
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
<NoteContentKind1 {note} {anchorId} {maxLength} {showEntire} />
{#if stream_url}
<Media link={annotateMedia(stream_url)} />
<Media url={stream_url} />
{/if}
<NoteContentLabel type="t" {note} />
</div>

View File

@ -1,5 +1,4 @@
<script lang="ts">
import {annotateMedia} from "src/util/misc"
import Card from "src/partials/Card.svelte"
import Media from "src/partials/Media.svelte"
@ -10,5 +9,5 @@
<Card interactive class="flex flex-col gap-4" on:click={() => window.open(link)}>
<p class="staatliches text-xl">{title} | {creator}</p>
<Media link={annotateMedia(enclosure)} />
<Media url={enclosure} />
</Card>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import {isShareableRelay} from "paravel"
import {annotateMedia, displayUrl} from "src/util/misc"
import {displayUrl} from "src/util/misc"
import Anchor from "src/partials/Anchor.svelte"
import Media from "src/partials/Media.svelte"
import {router} from "src/app/router"
@ -17,7 +17,7 @@
{#if showMedia && value.isMedia && !hidden}
<div class="py-2">
<Media link={annotateMedia(value.url)} onClose={close} />
<Media url={value.url} onClose={close} />
</div>
{:else if value.isMedia}
<Anchor

View File

@ -0,0 +1,31 @@
<script lang="ts">
import {without} from "ramda"
import Media from "src/partials/Media.svelte"
export let compose
export let value = []
const getUrl = imeta => imeta.type("url").values().first()
export const addImage = imeta => {
value = value.concat(imeta)
compose.write("\n" + getUrl(imeta))
}
export const removeImage = imeta => {
const content = compose.parse()
compose.clear()
compose.write(content.replace(getUrl(imeta), ""))
value = without([imeta], value)
}
</script>
<div class="columns-2 gap-2 lg:columns-3">
{#each value as imeta}
<div class="pb-2">
<Media {imeta} url={getUrl(imeta)} onClose={() => removeImage(imeta)} />
</div>
{/each}
</div>

View File

@ -1,14 +1,14 @@
<script lang="ts">
import {Tags} from "paravel"
import {createEventDispatcher} from "svelte"
import {without, identity, uniq} from "ramda"
import {join, without, identity, uniq} from "ramda"
import {getGroupAddress, asNostrEvent} from "src/util/nostr"
import {slide} from "src/util/transition"
import ImageInput from "src/partials/ImageInput.svelte"
import Chip from "src/partials/Chip.svelte"
import Media from "src/partials/Media.svelte"
import Compose from "src/app/shared/Compose.svelte"
import NoteOptions from "src/app/shared/NoteOptions.svelte"
import NoteImages from "src/app/shared/NoteImages.svelte"
import {
Publisher,
buildReply,
@ -25,11 +25,10 @@
const dispatch = createEventDispatcher()
let data = null
let reply = null
let container = null
let images, compose, container, options
let isOpen = false
let mentions = []
let draft = ""
let options
let opts = {
warning: "",
groups: parent.wrap ? Tags.from(parent).communities().all() : [],
@ -41,15 +40,13 @@
export const start = () => {
dispatch("start")
data = {
image: null,
mentions: without(
[$session.pubkey],
uniq(Tags.from(parent).type("p").values().all().concat(parent.pubkey))
),
}
isOpen = true
mentions = without(
[$session.pubkey],
uniq(Tags.from(parent).type("p").values().all().concat(parent.pubkey))
)
setTimeout(() => reply.write(draft))
setTimeout(() => compose.write(draft))
}
const setOpts = e => {
@ -57,8 +54,8 @@
}
const saveDraft = () => {
if (reply) {
draft = reply.parse()
if (compose) {
draft = compose.parse()
}
}
@ -69,24 +66,26 @@
const reset = () => {
dispatch("reset")
data = null
reply = null
mentions = null
compose = null
}
const removeMention = pubkey => {
data.mentions = without([pubkey], data.mentions)
mentions = without([pubkey], mentions)
}
const getContent = () => (reply.parse() + "\n" + (data.image || "")).trim()
const send = async () => {
const content = getContent()
const content = compose.parse().trim()
if (!content) {
return
}
const tags = data.mentions.map(mention)
const tags = mentions.map(mention)
for (const imeta of images.value) {
tags.push(["imeta", ...imeta.all().map(join(" "))])
}
if (opts.warning) {
tags.push(["content-warning", opts.warning])
@ -120,7 +119,7 @@
<svelte:body on:click={onBodyClick} />
{#if data}
{#if isOpen}
<div
transition:slide|local
class="note-reply relative z-10 my-2 flex flex-col gap-1"
@ -130,8 +129,8 @@
<div class="absolute bottom-0 left-4 top-0 z-0 -my-2 w-px bg-gray-6" />
{/if}
<div class="z-10 overflow-hidden rounded-2xl border border-solid border-gray-6">
<div class="bg-gray-7 p-3 text-gray-2" class:rounded-b={data.mentions.length === 0}>
<Compose bind:this={reply} onSubmit={send} style="min-height: 4rem">
<div class="bg-gray-7 p-3 text-gray-2" class:rounded-b={mentions.length === 0}>
<Compose bind:this={compose} onSubmit={send} style="min-height: 4rem">
<div class="flex flex-col justify-start" slot="addon">
<button
on:click={send}
@ -141,20 +140,14 @@
</div>
</Compose>
</div>
{#if data.image}
<div class="bg-gray-7 p-2">
<Media
link={{type: "image", url: data.image}}
onClose={() => {
data.image = null
}} />
</div>
{/if}
<div class="bg-gray-7 p-2">
<NoteImages bind:this={images} bind:compose />
</div>
<div class="h-px bg-gray-7 group-[.modal]:bg-gray-6" />
<div class="flex gap-2 rounded-b bg-gray-7 p-2 text-sm text-gray-2">
<div class="inline-block border-r border-solid border-gray-6 py-2 pl-1 pr-3">
<div class="flex cursor-pointer items-center gap-3">
<ImageInput bind:value={data.image}>
<ImageInput multi hostLimit={3} on:change={e => images.addImage(e.detail)}>
<i slot="button" class="fa fa-paperclip" />
</ImageInput>
<i class="fa fa-cog" on:click={() => options.setView("settings")} />
@ -162,7 +155,7 @@
</div>
</div>
<div on:click|stopPropagation>
{#each data.mentions as pubkey}
{#each mentions as pubkey}
<Chip class="mb-1 mr-1" theme="dark" onRemove={() => removeMention(pubkey)}>
{displayPubkey(pubkey)}
</Chip>

View File

@ -1,5 +1,4 @@
<script lang="ts">
import {annotateMedia} from "src/util/misc"
import Content from "src/partials/Content.svelte"
import Media from "src/partials/Media.svelte"
@ -7,5 +6,5 @@
</script>
<Content>
<Media fullSize link={annotateMedia(url)} />
<Media fullSize {url} />
</Content>

View File

@ -1,19 +1,18 @@
<script lang="ts">
import {onMount} from "svelte"
import {nip19} from "nostr-tools"
import {without, identity, prop, uniqBy} from "ramda"
import {join, identity, prop, uniqBy} from "ramda"
import {throttle, quantify} from "hurdak"
import {createEvent, Tags} from "paravel"
import {annotateMedia} from "src/util/misc"
import {asNostrEvent} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte"
import Compose from "src/app/shared/Compose.svelte"
import ImageInput from "src/partials/ImageInput.svelte"
import Media from "src/partials/Media.svelte"
import Content from "src/partials/Content.svelte"
import Heading from "src/partials/Heading.svelte"
import NoteContent from "src/app/shared/NoteContent.svelte"
import NoteOptions from "src/app/shared/NoteOptions.svelte"
import NoteImages from "src/app/shared/NoteImages.svelte"
import {Publisher, mention} from "src/engine"
import {toastProgress} from "src/app/state"
import {router} from "src/app/router"
@ -31,8 +30,7 @@
export let pubkey = null
export let group = null
let images = []
let compose = null
let images, compose
let charCount = 0
let wordCount = 0
let showPreview = false
@ -76,6 +74,10 @@
return
}
for (const imeta of images.value) {
tags.push(["imeta", ...imeta.all().map(join(" "))])
}
if (opts.warning) {
tags.push(["content-warning", opts.warning])
}
@ -100,20 +102,6 @@
router.clearModals()
}
const addImage = url => {
images = images.concat(url)
compose.write("\n" + url)
}
const removeImage = url => {
const content = compose.parse()
compose.clear()
compose.write(content.replace(url, ""))
images = without([url], images)
}
const togglePreview = () => {
showPreview = !showPreview
}
@ -187,19 +175,11 @@
</small>
</div>
</div>
{#if images.length > 0}
<div class="columns-2 gap-2 lg:columns-3">
{#each images as url}
<div class="mb-2">
<Media link={annotateMedia(url)} onClose={() => removeImage(url)} />
</div>
{/each}
</div>
{/if}
<NoteImages bind:this={images} bind:compose />
<div class="flex gap-2">
<Anchor tag="button" theme="button" type="submit" class="flex-grow text-center"
>Send</Anchor>
<ImageInput multi onChange={addImage} />
<ImageInput multi hostLimit={3} on:change={e => images.addImage(e.detail)} />
</div>
<small
class="flex cursor-pointer items-center justify-end gap-4"

View File

@ -1,4 +1,5 @@
<script lang="ts">
import {identity} from "ramda"
import {toast, appName} from "src/partials/state"
import Field from "src/partials/Field.svelte"
import FieldInline from "src/partials/FieldInline.svelte"
@ -8,6 +9,8 @@
import Content from "src/partials/Content.svelte"
import Heading from "src/partials/Heading.svelte"
import {env, getSettings, publishSettings} from "src/engine"
import MultiSelect from "src/partials/MultiSelect.svelte"
import {fuzzy} from "src/util/misc"
let settings = getSettings()
@ -17,6 +20,8 @@
toast.show("info", "Your settings have been saved!")
}
const searchUploadProviders = fuzzy($env.NIP96_URLS, {keys: ["url"]})
document.title = "Settings"
</script>
@ -48,6 +53,21 @@
Allows {appName} to authenticate with relays that have access controls automatically.
</p>
</FieldInline>
<Field label="Upload Providers URL">
<p slot="info">
Enter one or more urls for nostr media servers. You can find a full list of NIP-96
compatible servers
<a href="https://github.com/quentintaranpino/NIP96-compatible-servers">here</a>
</p>
<MultiSelect
search={searchUploadProviders}
bind:value={settings.nip96_urls}
termToItem={identity}>
<div slot="item" let:item>
<strong>{item}</strong>
</div>
</MultiSelect>
</Field>
<Field label="Dufflepud URL">
<Input bind:value={settings.dufflepud_url}>
<i slot="before" class="fa-solid fa-server" />

View File

@ -0,0 +1,28 @@
import crypto from "crypto"
import {Fetch} from "hurdak"
import {createEvent} from "paravel"
import {generatePrivateKey} from "nostr-tools"
import {signer} from "src/engine/session/derived"
export const nip98Fetch = async (url, method, body = null) => {
const tags = [
["u", url],
["method", method],
]
if (body) {
tags.push(["payload", crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex")])
}
const template = createEvent(27235, {tags})
const $signer = signer.get()
const event = $signer.canSign()
? await $signer.signAsUser(template)
: await $signer.signWithKey(template, generatePrivateKey())
const auth = btoa(JSON.stringify(event))
const headers = {Authorization: `Nostr ${auth}`}
return Fetch.fetchJson(url, {body, method, headers})
}

1
src/engine/auth/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./commands"

View File

@ -11,6 +11,7 @@ import {pubkey, sessions} from "./session"
import {channels} from "./channels"
export * from "./core"
export * from "./auth"
export * from "./channels"
export * from "./events"
export * from "./groups"

View File

@ -1,27 +1,86 @@
import {Fetch} from "hurdak"
import {createEvent} from "paravel"
import {generatePrivateKey} from "nostr-tools"
import {signer} from "src/engine/session/derived"
import {prop, nth, uniq, flatten, groupBy} from "ramda"
import {Fetch, tryFunc, sleep} from "hurdak"
import {now, cached, Tags} from "paravel"
import {joinPath} from "src/util/misc"
import type {Event} from "src/engine/events/model"
import {nip98Fetch} from "src/engine/auth/commands"
import {stripExifData, blobToFile} from "src/util/html"
export const uploadToNostrBuild = async body => {
const $signer = signer.get()
const url = "https://nostr.build/api/v2/upload/files"
const template = createEvent(27235, {
tags: [
["u", url],
["method", "POST"],
],
})
export const getMediaProviderURL = cached({
maxSize: 10,
getKey: ([url]) => url,
getValue: ([url]) => fetchMediaProviderURL(url),
})
const event = await ($signer.canSign()
? $signer.signAsUser(template)
: $signer.signWithKey(template, generatePrivateKey()))
const fetchMediaProviderURL = async host =>
prop("api_url", await Fetch.fetchJson(joinPath(host, ".well-known/nostr/nip96.json")))
return Fetch.fetchJson(url, {
body,
method: "POST",
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
},
const fileToFormData = file => {
const formData = new FormData()
formData.append("file[]", file)
return formData
}
export const uploadFileToHost = async (url, file) => {
const startTime = now()
const apiUrl = await getMediaProviderURL(url)
const response = await nip98Fetch(apiUrl, "POST", fileToFormData(file))
// If the media provider uses delayed processing, we need to wait for the processing to be done
while (response.processing_url) {
const {status, nip94_event} = await nip98Fetch(response.processing_url, "GET")
if (status === "success") {
return Tags.from(nip94_event).type("url").values().first()
}
if (now() - startTime > 60) {
break
}
await sleep(3000)
}
return response.nip94_event
}
export const uploadFilesToHost = (url, files) =>
Promise.all(files.map(file => tryFunc(async () => await uploadFileToHost(url, file))))
export const uploadFileToHosts = (urls, file) =>
Promise.all(urls.map(url => tryFunc(async () => await uploadFileToHost(url, file))))
export const uploadFilesToHosts = async (urls, files) =>
flatten(await Promise.all(urls.map(url => uploadFilesToHost(url, files))))
export const compressFiles = (files, opts) =>
Promise.all(
files.map(async f => {
if (f.type.match("image/(webp|gif)")) {
return f
}
return blobToFile(await stripExifData(f, opts))
})
)
export const eventsToMeta = (events: Event[]) => {
const tagsByHash = groupBy(
tags => tags.type("ox").values().first(),
events.map(e => Tags.from(e))
)
// Merge all nip94 tags together so we can supply as much imeta as possible
return Object.values(tagsByHash).map(groupedTags => {
return new Tags(uniq(groupedTags.flatMap(tags => tags.filter(nth(1)).all())))
})
}
export const uploadFiles = async (urls, files, compressorOpts = {}) => {
const compressedFiles = await compressFiles(files, compressorOpts)
const nip94Events = await uploadFilesToHosts(urls, compressedFiles)
return eventsToMeta(nip94Events)
}

View File

@ -11,6 +11,7 @@ export const getDefaultSettings = () => ({
auto_authenticate: false,
min_wot_score: 1,
enable_reactions: true,
nip96_urls: env.get().NIP96_URLS,
imgproxy_url: env.get().IMGPROXY_URL,
dufflepud_url: env.get().DUFFLEPUD_URL,
multiplextr_url: env.get().MULTIPLEXTR_URL,

View File

@ -44,6 +44,8 @@ const DUFFLEPUD_URL = import.meta.env.VITE_DUFFLEPUD_URL
const MULTIPLEXTR_URL = import.meta.env.VITE_MULTIPLEXTR_URL
const NIP96_URLS = fromCsv(import.meta.env.VITE_NIP96_URLS)
const FORCE_RELAYS = fromCsv(import.meta.env.VITE_FORCE_RELAYS)
const DVM_RELAYS = FORCE_RELAYS.length > 0 ? FORCE_RELAYS : fromCsv(import.meta.env.VITE_DVM_RELAYS)
@ -65,6 +67,7 @@ const ENABLE_JUKEBOX = JSON.parse(import.meta.env.VITE_ENABLE_JUKEBOX)
// Prep our env
env.set({
DEFAULT_FOLLOWS,
NIP96_URLS,
IMGPROXY_URL,
DUFFLEPUD_URL,
MULTIPLEXTR_URL,

View File

@ -45,8 +45,8 @@
}
}
const addImage = url => {
textarea.value = (textarea.value + "\n\n" + url).trim()
const addImage = imeta => {
textarea.value += "\n" + imeta.type("url").values().first()
}
const send = async () => {
@ -125,7 +125,7 @@
class="w-full resize-none bg-gray-6 p-2
text-gray-2 outline-0 placeholder:text-gray-1" />
<div>
<ImageInput onChange={addImage}>
<ImageInput multi on:change={e => addImage(e.detail)}>
<button
slot="button"
class="flex cursor-pointer flex-col justify-center gap-2 border-l border-solid border-gray-7 p-3

View File

@ -19,12 +19,21 @@
<script lang="ts">
import {onMount} from "svelte"
import {ensurePlural} from 'hurdak'
import {imgproxy} from "src/engine"
export let src
let element
let i = 0
let loading = true
let urls = ensurePlural(src)
const onError = () => {
if (i < urls.length - 1) {
i++
}
}
onMount(() => {
element.addEventListener("load", () => {
@ -33,7 +42,7 @@
})
</script>
<img {...$$props} class:hidden={loading} bind:this={element} src={imgproxy(src)} />
<img {...$$props} class:hidden={loading} bind:this={element} on:error={onError} src={imgproxy(urls[i])} />
{#if loading}
<slot name="placeholder">

View File

@ -1,70 +1,53 @@
<script lang="ts">
import {identity} from "ramda"
import {filterVals} from "hurdak"
import {createEventDispatcher} from "svelte"
import {displayList} from "hurdak"
import Input from "src/partials/Input.svelte"
import Modal from "src/partials/Modal.svelte"
import Content from "src/partials/Content.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Anchor from "src/partials/Anchor.svelte"
import {listenForFile, stripExifData, blobToFile} from "src/util/html"
import {uploadToNostrBuild} from "src/engine"
import {listenForFile} from "src/util/html"
import {uploadFiles, settings} from "src/engine"
export let icon = null
export let value = null
export let multi = false
export let maxWidth = null
export let maxHeight = null
export let onChange = null
export let hostLimit = 1
const urls = $settings.nip96_urls.slice(0, hostLimit)
const dispatch = createEventDispatcher()
let input, listener, loading
let files = []
let isOpen = false
$: {
if (input) {
listener = listenForFile(input, async inputFiles => {
if (inputFiles) {
const opts = filterVals(identity, {maxWidth, maxHeight})
loading = true
try {
files = await Promise.all(
inputFiles.map(async f => {
if (f.type.match("image/(webp|gif)")) {
return f
}
for (const tags of await uploadFiles(urls, inputFiles, {
maxWidth,
maxHeight,
})) {
// For inputs that only want one file
value = tags.type("url").values().first()
return blobToFile(await stripExifData(f, opts))
})
)
const body = new FormData()
for (const file of files) {
body.append("file[]", file)
}
const result = await uploadToNostrBuild(body)
// Legacy weirdness
for (const {url} of result.data) {
value = url
onChange?.(url)
dispatch("change", tags)
}
} finally {
isOpen = false
loading = false
}
} else {
files = []
}
})
}
}
const decline = () => {
files = []
isOpen = false
}
</script>
@ -93,7 +76,7 @@
<Modal mini onEscape={decline}>
<Content>
{#if loading}
<Spinner delay={0}>Uploading your media...</Spinner>
<Spinner delay={0}>Uploading files using: {displayList(urls)}</Spinner>
{:else}
<h1 class="staatliches text-2xl">Upload a File</h1>
<p>Click below to select a file to upload.</p>

View File

@ -1,19 +1,23 @@
<script lang="ts">
import cx from "classnames"
import {last} from "ramda"
import {ellipsize, Fetch} from "hurdak"
import {Tags} from "paravel"
import {AudioController} from "src/util/audio"
import Audio from "src/partials/Audio.svelte"
import Image from "src/partials/Image.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Spinner from "src/partials/Spinner.svelte"
import {dufflepud, imgproxy} from "src/engine"
export let link
export let url
export let imeta = new Tags([["url", url]])
export let onClick = null
export let onClose = null
export let fullSize = false
const loadPreview = async () => {
const json = await Fetch.postJson(dufflepud("link/preview"), {url: link.url})
const json = await Fetch.postJson(dufflepud("link/preview"), {url})
if (!json.title && !json.image) {
throw new Error("Unable to load preview")
@ -23,24 +27,18 @@
}
</script>
{#if link.type === "audio"}
<Audio controller={new AudioController(link.url)} />
{#if url.match(/\.(wav|mp3|m3u8)$/)}
<Audio controller={new AudioController(url)} />
{:else}
<Anchor
external
type="unstyled"
href={onClick ? null : link.url}
href={onClick ? null : url}
on:click={onClick}
style="background-color: rgba(15, 15, 14, 0.5)"
class="relative flex flex-col overflow-hidden rounded-xl border border-solid border-gray-6">
{#if link.type === "image"}
<img
alt="Link preview"
src={imgproxy(link.url)}
class:max-h-96={!fullSize}
class="object-contain object-center" />
{:else if link.type === "spotify"}
{@const id = last(link.url.split("?")[0].match(/[a-z]+\/[0-9A-z]+$/))}
{#if url.match(/open.spotify.com/)}
{@const id = last(url.split("?")[0].match(/[a-z]+\/[0-9A-z]+$/))}
{@const src = `https://open.spotify.com/embed/${id}`}
<iframe
{src}
@ -51,8 +49,13 @@
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy" />
{:else if link.type === "video"}
<video controls src={link.url} class="max-h-96 object-contain object-center" />
{:else if url.match(/\.(mov|webm|mp4)$/)}
<video controls src={url} class="max-h-96 object-contain object-center" />
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
<Image
alt="Link preview"
src={imeta.type("url").values().all().map(imgproxy)}
class={cx("object-contain object-center", {"max-h-96": !fullSize})} />
{:else}
{#await loadPreview()}
<Spinner />
@ -72,7 +75,7 @@
{/if}
{:catch}
<p class="mb-1 px-12 py-24 text-center text-gray-5">
Unable to load a preview for {link.url}
Unable to load a preview for {url}
</p>
{/await}
{/if}

View File

@ -1,6 +1,4 @@
<script lang="ts">
import {sortBy} from "ramda"
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"
@ -10,12 +8,6 @@
let showModal = false
// Put previews last since we need to load them asynchronously
const annotated = sortBy(
(l: any) => (l.type === "preview" ? 1 : 0),
links.map(link => annotateMedia(link.url))
)
const openModal = () => {
showModal = true
}
@ -27,15 +19,15 @@
<div class="my-8 flex justify-center">
<Anchor theme="button-minimal" on:click={openModal}>
<i class="fa fa-plus" /> Show all {annotated.length} link previews
<i class="fa fa-plus" /> Show all {links.length} link previews
</Anchor>
</div>
{#if showModal}
<Modal onEscape={closeModal}>
<Content>
{#each annotated as link}
<Media {link} />
{#each links as url}
<Media {url} />
{/each}
</Content>
</Modal>

View File

@ -45,7 +45,7 @@
<div
bind:this={root}
transition:fade
class="modal group fixed inset-0 z-30"
class="modal group fixed inset-0 z-40"
class:pointer-events-none={closing}>
<div
class="fixed inset-0 cursor-pointer bg-black opacity-50"

View File

@ -16,7 +16,7 @@ export const copyToClipboard = text => {
return result
}
export const stripExifData = async (file, opts = {}) => {
export const stripExifData = async (file, {maxWidth = null, maxHeight = null} = {}) => {
if (window.DataTransferItem && file instanceof DataTransferItem) {
file = file.getAsFile()
}
@ -31,10 +31,9 @@ export const stripExifData = async (file, opts = {}) => {
return new Promise((resolve, _reject) => {
new Compressor(file, {
maxWidth: 2048,
maxHeight: 2048,
maxWidth: maxWidth || 2048,
maxHeight: maxHeight || 2048,
convertSize: bytes(10, "mb"),
...opts,
success: resolve,
error: e => {
// Non-images break compressor

View File

@ -165,20 +165,6 @@ export const formatSats = (sats: number) => {
return numberFmt.format(round(2, sats / 100_000_000)) + "BTC"
}
export const annotateMedia = (url: string) => {
if (url.match(/open.spotify.com/)) {
return {type: "spotify", url}
} else if (url.match(/\.(jpe?g|png|gif|webp)$/)) {
return {type: "image", url}
} else if (url.match(/\.(mov|webm|mp4)$/)) {
return {type: "video", url}
} else if (url.match(/\.(wav|mp3|m3u8)$/)) {
return {type: "audio", url}
} else {
return {type: "preview", url}
}
}
export const shadeColor = (color: string, percent: number) => {
let R = parseInt(color.substring(1, 3), 16)
let G = parseInt(color.substring(3, 5), 16)
@ -343,3 +329,17 @@ export const buildQueryString = params => "?" + new URLSearchParams(params)
export const parseQueryString = path =>
fromPairs(Array.from(new URLSearchParams(last(path.split("?")))))
export const joinPath = (...parts) => {
let path = ""
for (let part of parts) {
if (!part.endsWith("/")) {
part += "/"
}
path += part
}
return path.slice(0, -1)
}

View File

@ -1,4 +1,4 @@
import {last, pluck, identity} from "ramda"
import {last, map, pluck, identity} from "ramda"
import {fromNostrURI, Tags} from "paravel"
import {nip19} from "nostr-tools"
import {first, switcherFn} from "hurdak"
@ -18,11 +18,25 @@ export const NOSTR_NADDR = "nostr:naddr"
export const urlIsMedia = (url: string) =>
!url.match(/\.(apk|docx|xlsx|csv|dmg)/) && last(url.split("://"))?.includes("/")
export const parseContent = ({content, tags = []}: {content: string; tags?: string[][]}) => {
export const parseContent = (event: {content: string; tags?: string[][]}) => {
const result: any[] = []
let text = content.trim() || new Tags(tags).getValue("alt") || ""
const tags = new Tags(event.tags || [])
let text = event.content.trim() || tags.getValue("alt") || ""
let buffer = ""
const getMeta = url =>
tags
.type("imeta")
.map(
map((m: string) => {
const parts = m.split(" ")
return [parts[0], parts.slice(1).join(" ")]
})
)
.filter(meta => new Tags(meta).type("url").nthEq(1, url).exists())
.flatMap(identity)
const parseNewline = () => {
const newline = first(text.match(/^\n+/))
@ -126,7 +140,7 @@ export const parseContent = ({content, tags = []}: {content: string; tags?: stri
url = "https://" + url
}
return [LINK, raw, {url, isMedia: urlIsMedia(url)}]
return [LINK, raw, {url, isMedia: urlIsMedia(url), meta: getMeta(url)}]
}
while (text) {

462
yarn.lock

File diff suppressed because it is too large Load Diff