mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-30 00:41:12 +00:00
Add NIP 96 support
This commit is contained in:
parent
b165aec8ca
commit
e23b9f1fa7
1
.env
1
.env
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
31
src/app/shared/NoteImages.svelte
Normal file
31
src/app/shared/NoteImages.svelte
Normal 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>
|
@ -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(
|
||||
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
|
||||
}} />
|
||||
<NoteImages bind:this={images} bind:compose />
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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" />
|
||||
|
28
src/engine/auth/commands.ts
Normal file
28
src/engine/auth/commands.ts
Normal 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
1
src/engine/auth/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./commands"
|
@ -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"
|
||||
|
@ -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 fetchMediaProviderURL = async host =>
|
||||
prop("api_url", await Fetch.fetchJson(joinPath(host, ".well-known/nostr/nip96.json")))
|
||||
|
||||
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))
|
||||
})
|
||||
)
|
||||
|
||||
const event = await ($signer.canSign()
|
||||
? $signer.signAsUser(template)
|
||||
: $signer.signWithKey(template, generatePrivateKey()))
|
||||
export const eventsToMeta = (events: Event[]) => {
|
||||
const tagsByHash = groupBy(
|
||||
tags => tags.type("ox").values().first(),
|
||||
events.map(e => Tags.from(e))
|
||||
)
|
||||
|
||||
return Fetch.fetchJson(url, {
|
||||
body,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
|
||||
},
|
||||
// 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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user