diff --git a/.env b/.env
index 879765af..453005fa 100644
--- a/.env
+++ b/.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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 074f4313..266b0cee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 4240f41f..443e07a8 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/package.json b/package.json
index 428164d3..e40a71aa 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/shared/Compose.svelte b/src/app/shared/Compose.svelte
index c2b5bbc1..54cb8367 100644
--- a/src/app/shared/Compose.svelte
+++ b/src/app/shared/Compose.svelte
@@ -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)
diff --git a/src/app/shared/NoteContentKind1808.svelte b/src/app/shared/NoteContentKind1808.svelte
index ca781c22..925f0192 100644
--- a/src/app/shared/NoteContentKind1808.svelte
+++ b/src/app/shared/NoteContentKind1808.svelte
@@ -1,6 +1,5 @@
+
+
+ {#each value as imeta}
+
+ removeImage(imeta)} />
+
+ {/each}
+
diff --git a/src/app/shared/NoteReply.svelte b/src/app/shared/NoteReply.svelte
index 469db284..0c5b9894 100644
--- a/src/app/shared/NoteReply.svelte
+++ b/src/app/shared/NoteReply.svelte
@@ -1,14 +1,14 @@
-
+
diff --git a/src/app/views/NoteCreate.svelte b/src/app/views/NoteCreate.svelte
index 79f2a0a1..f8202f5c 100644
--- a/src/app/views/NoteCreate.svelte
+++ b/src/app/views/NoteCreate.svelte
@@ -1,19 +1,18 @@
@@ -48,6 +53,21 @@
Allows {appName} to authenticate with relays that have access controls automatically.
+
+
+ Enter one or more urls for nostr media servers. You can find a full list of NIP-96
+ compatible servers
+ here
+
+
+
+ {item}
+
+
+
diff --git a/src/engine/auth/commands.ts b/src/engine/auth/commands.ts
new file mode 100644
index 00000000..9926656a
--- /dev/null
+++ b/src/engine/auth/commands.ts
@@ -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})
+}
diff --git a/src/engine/auth/index.ts b/src/engine/auth/index.ts
new file mode 100644
index 00000000..790681f8
--- /dev/null
+++ b/src/engine/auth/index.ts
@@ -0,0 +1 @@
+export * from "./commands"
diff --git a/src/engine/index.ts b/src/engine/index.ts
index e44a3c5c..01e4ce24 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -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"
diff --git a/src/engine/media/commands.ts b/src/engine/media/commands.ts
index afd5b6dd..27952ca6 100644
--- a/src/engine/media/commands.ts
+++ b/src/engine/media/commands.ts
@@ -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)
+}
diff --git a/src/engine/session/utils/settings.ts b/src/engine/session/utils/settings.ts
index 43414a17..8311a9a9 100644
--- a/src/engine/session/utils/settings.ts
+++ b/src/engine/session/utils/settings.ts
@@ -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,
diff --git a/src/main.js b/src/main.js
index 98b32055..45b9b337 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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,
diff --git a/src/partials/Channel.svelte b/src/partials/Channel.svelte
index 3a4c2b1a..4b410114 100644
--- a/src/partials/Channel.svelte
+++ b/src/partials/Channel.svelte
@@ -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" />
-
+ addImage(e.detail)}>