Let the blossom server decide which files can be attached
This commit is contained in:
parent
1da59fb322
commit
78fe1e11f4
3 changed files with 121 additions and 38 deletions
|
|
@ -31,7 +31,7 @@ import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
||||||
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
|
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
|
||||||
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
|
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
|
||||||
import {NativeClipboardPasteExtension} from "@app/editor/clipboard"
|
import {NativeClipboardPasteExtension} from "@app/editor/clipboard"
|
||||||
import {UPLOAD_MIME_TYPES, compressFileForUpload, uploadFile} from "@app/uploads"
|
import {compressFileForUpload, makeImetaTag, uploadFile} from "@app/uploads"
|
||||||
import {userSpaceUrls} from "@app/rooms"
|
import {userSpaceUrls} from "@app/rooms"
|
||||||
import {PLATFORM_RELAYS} from "@app/env"
|
import {PLATFORM_RELAYS} from "@app/env"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
@ -132,6 +132,39 @@ export const makeEditor = async ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A file the editor has no node for is uploaded here instead, so its imeta has to join what
|
||||||
|
// the nostr extension collects off the document, and it has to hold `uploading` open itself.
|
||||||
|
const attachments: {url: string; tag: string[]}[] = []
|
||||||
|
|
||||||
|
let attaching = 0
|
||||||
|
|
||||||
|
const setUploading = () =>
|
||||||
|
uploading?.set(
|
||||||
|
attaching > 0 ||
|
||||||
|
ed.storage.fileUpload.uploader.getFiles().some((attrs: FileAttributes) => attrs.uploading),
|
||||||
|
)
|
||||||
|
|
||||||
|
const attachFile = async (editor: Editor, file: File) => {
|
||||||
|
attaching += 1
|
||||||
|
uploading?.set(true)
|
||||||
|
|
||||||
|
const {error, result} = await uploadFile(file, {url, encrypt: encryptFiles})
|
||||||
|
|
||||||
|
attaching -= 1
|
||||||
|
setUploading()
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
attachments.push({url: result.url, tag: makeImetaTag(file, result)})
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({type: "text", text: result.url + " "})
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ed = new Editor({
|
const ed = new Editor({
|
||||||
content: typeof content === "string" ? escapeHtml(content) : content,
|
content: typeof content === "string" ? escapeHtml(content) : content,
|
||||||
editorProps,
|
editorProps,
|
||||||
|
|
@ -154,36 +187,47 @@ export const makeEditor = async ({
|
||||||
},
|
},
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
config: {
|
config: {
|
||||||
allowedMimeTypes: UPLOAD_MIME_TYPES,
|
allowedMimeTypes: ["*/*"],
|
||||||
upload: async (attrs: FileAttributes) =>
|
upload: async (attrs: FileAttributes) =>
|
||||||
uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}),
|
uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}),
|
||||||
onDrop: () => uploading?.set(true),
|
onDrop: () => uploading?.set(true),
|
||||||
onComplete: () => uploading?.set(false),
|
onComplete: () => setUploading(),
|
||||||
onUploadError(currentEditor, task) {
|
onUploadError(currentEditor, task) {
|
||||||
currentEditor.commands.removeFailedUploads()
|
currentEditor.commands.removeFailedUploads()
|
||||||
pushToast({theme: "error", message: task.error})
|
pushToast({theme: "error", message: task.error})
|
||||||
uploading?.set(false)
|
setUploading()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
// The picker, a drop and a paste all reach the uploader through addFile, which
|
// The picker, a drop and a paste all reach the uploader through addFile, which
|
||||||
// refuses a type that isn't allowed above by returning false and saying nothing.
|
// only knows how to make an image or a video node. Everything else is uploaded on
|
||||||
// Say it, or choosing the wrong file looks like the app simply ignored the click.
|
// its own, so what you can attach is the server's call rather than a list here.
|
||||||
onCreate() {
|
onCreate() {
|
||||||
const {uploader} = this.storage
|
const {uploader} = this.storage
|
||||||
const addFile = uploader.addFile.bind(uploader)
|
const addFile = uploader.addFile.bind(uploader)
|
||||||
|
|
||||||
uploader.addFile = (file: File, pos: number) => {
|
uploader.addFile = (file: File, pos: number) => {
|
||||||
const added = addFile(file, pos)
|
if (file.type.match(/^(image|video)\//)) {
|
||||||
|
return addFile(file, pos)
|
||||||
if (!added) {
|
|
||||||
pushToast({
|
|
||||||
theme: "error",
|
|
||||||
message: `${file.name} is not a type you can attach.`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return added
|
attachFile(this.editor, file)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste filters by mime type before it ever reaches addFile.
|
||||||
|
uploader.handlePaste = (event: ClipboardEvent) => {
|
||||||
|
const files = Array.from(event.clipboardData?.items || [])
|
||||||
|
.filter(item => item.kind === "file")
|
||||||
|
.map(item => item.getAsFile())
|
||||||
|
.filter(Boolean) as File[]
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
uploader.addFile(file, uploader.view.state.selection.from + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.length > 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -268,5 +312,18 @@ export const makeEditor = async ({
|
||||||
empty?.set(isEmpty(ed))
|
empty?.set(isEmpty(ed))
|
||||||
text?.set(ed.getText({blockSeparator: "\n"}))
|
text?.set(ed.getText({blockSeparator: "\n"}))
|
||||||
|
|
||||||
|
// Deleting the url an attachment was inserted as is how you remove it, since there is no node
|
||||||
|
// to delete.
|
||||||
|
const getEditorTags = ed.storage.nostr.getEditorTags
|
||||||
|
|
||||||
|
ed.storage.nostr.getEditorTags = (...args: unknown[]) => {
|
||||||
|
const content = ed.getText({blockSeparator: "\n"})
|
||||||
|
|
||||||
|
return [
|
||||||
|
...getEditorTags(...args),
|
||||||
|
...attachments.filter(({url}) => content.includes(url)).map(({tag}) => tag),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
return ed
|
return ed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {app, relays} from "@app/core"
|
||||||
import {navigate, pushModal} from "@app/modal"
|
import {navigate, pushModal} from "@app/modal"
|
||||||
import {makeSpaceChatPath} from "@app/routes"
|
import {makeSpaceChatPath} from "@app/routes"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {UPLOAD_MIME_TYPES} from "@app/uploads"
|
|
||||||
import ShareDialog from "@app/components/Share.svelte"
|
import ShareDialog from "@app/components/Share.svelte"
|
||||||
import ShareEvent from "@app/components/ShareEvent.svelte"
|
import ShareEvent from "@app/components/ShareEvent.svelte"
|
||||||
|
|
||||||
|
|
@ -64,10 +63,6 @@ export const shareFromNative = async ({
|
||||||
if (text) {
|
if (text) {
|
||||||
shareText(text)
|
shareText(text)
|
||||||
} else if (path) {
|
} else if (path) {
|
||||||
if (!UPLOAD_MIME_TYPES.includes(mimeType)) {
|
|
||||||
return pushToast({theme: "error", message: "Flotilla can't share that type of file."})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(Capacitor.convertFileSrc(path))
|
const response = await fetch(Capacitor.convertFileSrc(path))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import type {Maybe} from "@welshman/lib"
|
||||||
import {first, normalizeUrl, parseJson, sha256, simpleCache} from "@welshman/lib"
|
import {first, normalizeUrl, parseJson, sha256, simpleCache} from "@welshman/lib"
|
||||||
import {canUploadBlob, encryptFile, makeBlossomAuthEvent, uploadBlob} from "@welshman/util"
|
import {canUploadBlob, encryptFile, makeBlossomAuthEvent, uploadBlob} from "@welshman/util"
|
||||||
import {Nip01Signer} from "@welshman/signer"
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
import type {UploadTask} from "@welshman/editor"
|
|
||||||
import {compressFile} from "@lib/html"
|
import {compressFile} from "@lib/html"
|
||||||
import {app, blossomServerLists, relays} from "@app/core"
|
import {app, blossomServerLists, relays} from "@app/core"
|
||||||
import {DEFAULT_BLOSSOM_SERVERS} from "@app/env"
|
import {DEFAULT_BLOSSOM_SERVERS} from "@app/env"
|
||||||
|
|
@ -62,22 +61,6 @@ export const getBlossomServer = async (options: GetBlossomServerOptions = {}) =>
|
||||||
return first(DEFAULT_BLOSSOM_SERVERS)!
|
return first(DEFAULT_BLOSSOM_SERVERS)!
|
||||||
}
|
}
|
||||||
|
|
||||||
// The editor's default list leaves out the formats an iPhone camera actually produces, so
|
|
||||||
// anything shared from Photos would be rejected without a word. Heic gets re-encoded by the
|
|
||||||
// compressor on its way to a blossom server; quicktime is uploaded as-is.
|
|
||||||
export const UPLOAD_MIME_TYPES = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
"image/heic",
|
|
||||||
"image/heif",
|
|
||||||
"video/mp4",
|
|
||||||
"video/mpeg",
|
|
||||||
"video/webm",
|
|
||||||
"video/quicktime",
|
|
||||||
]
|
|
||||||
|
|
||||||
export type CompressFileOptions = {
|
export type CompressFileOptions = {
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
maxHeight?: number
|
maxHeight?: number
|
||||||
|
|
@ -94,9 +77,38 @@ export type UploadFileOptions = {
|
||||||
encrypt?: boolean
|
encrypt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UploadResult = {
|
||||||
|
url: string
|
||||||
|
sha256: string
|
||||||
|
tags: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
export type UploadFileResult = {
|
export type UploadFileResult = {
|
||||||
error?: string
|
error?: string
|
||||||
result?: UploadTask
|
result?: UploadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the imeta the editor builds for the media it holds as a node, for the files it
|
||||||
|
// doesn't.
|
||||||
|
export const makeImetaTag = (file: File, result: UploadResult) => {
|
||||||
|
const meta: Record<string, string> = {
|
||||||
|
url: result.url,
|
||||||
|
x: result.sha256,
|
||||||
|
ox: result.sha256,
|
||||||
|
m: file.type,
|
||||||
|
size: String(file.size),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [k, v] of result.tags) {
|
||||||
|
meta[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"imeta",
|
||||||
|
...Object.entries(meta)
|
||||||
|
.map(entry => entry.join(" "))
|
||||||
|
.sort(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadFile = async (file: File, options: UploadFileOptions = {}) => {
|
export const uploadFile = async (file: File, options: UploadFileOptions = {}) => {
|
||||||
|
|
@ -119,12 +131,31 @@ export const uploadFile = async (file: File, options: UploadFileOptions = {}) =>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = "." + type.split("/")[1]
|
// A subtype worth putting on a url is a plain word. Anything else (an office document, a
|
||||||
|
// file the platform gave no type at all) keeps whatever the server named it.
|
||||||
|
const [, subtype = ""] = type.split("/")
|
||||||
|
const ext = /^[a-z0-9]+$/.test(subtype) ? "." + subtype : ""
|
||||||
const server = await getBlossomServer(options)
|
const server = await getBlossomServer(options)
|
||||||
const hashes = [await sha256(await file.arrayBuffer())]
|
const hashes = [await sha256(await file.arrayBuffer())]
|
||||||
const $signer = app.get().user?.signer || Nip01Signer.ephemeral()
|
const $signer = app.get().user?.signer || Nip01Signer.ephemeral()
|
||||||
const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes})
|
const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes})
|
||||||
const authEvent = await $signer.sign(authTemplate)
|
const authEvent = await $signer.sign(authTemplate)
|
||||||
|
|
||||||
|
// What a server takes is its own business, so ask before spending the upload. A 404 or a
|
||||||
|
// 405 is a server that doesn't implement BUD-06 rather than one saying no.
|
||||||
|
const check = await canUploadBlob(server, {
|
||||||
|
authEvent,
|
||||||
|
headers: {
|
||||||
|
"X-Content-Type": file.type,
|
||||||
|
"X-Content-Length": String(file.size),
|
||||||
|
"X-SHA-256": hashes[0],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (![200, 404, 405].includes(check.status)) {
|
||||||
|
return {error: check.headers.get("X-Reason") || `${name} was refused (HTTP ${check.status})`}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await uploadBlob(server, file, {authEvent})
|
const res = await uploadBlob(server, file, {authEvent})
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue