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 RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
|
||||
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 {PLATFORM_RELAYS} from "@app/env"
|
||||
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({
|
||||
content: typeof content === "string" ? escapeHtml(content) : content,
|
||||
editorProps,
|
||||
|
|
@ -154,36 +187,47 @@ export const makeEditor = async ({
|
|||
},
|
||||
fileUpload: {
|
||||
config: {
|
||||
allowedMimeTypes: UPLOAD_MIME_TYPES,
|
||||
allowedMimeTypes: ["*/*"],
|
||||
upload: async (attrs: FileAttributes) =>
|
||||
uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}),
|
||||
onDrop: () => uploading?.set(true),
|
||||
onComplete: () => uploading?.set(false),
|
||||
onComplete: () => setUploading(),
|
||||
onUploadError(currentEditor, task) {
|
||||
currentEditor.commands.removeFailedUploads()
|
||||
pushToast({theme: "error", message: task.error})
|
||||
uploading?.set(false)
|
||||
setUploading()
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
// 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.
|
||||
// Say it, or choosing the wrong file looks like the app simply ignored the click.
|
||||
// only knows how to make an image or a video node. Everything else is uploaded on
|
||||
// its own, so what you can attach is the server's call rather than a list here.
|
||||
onCreate() {
|
||||
const {uploader} = this.storage
|
||||
const addFile = uploader.addFile.bind(uploader)
|
||||
|
||||
uploader.addFile = (file: File, pos: number) => {
|
||||
const added = addFile(file, pos)
|
||||
|
||||
if (!added) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: `${file.name} is not a type you can attach.`,
|
||||
})
|
||||
if (file.type.match(/^(image|video)\//)) {
|
||||
return addFile(file, pos)
|
||||
}
|
||||
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {app, relays} from "@app/core"
|
|||
import {navigate, pushModal} from "@app/modal"
|
||||
import {makeSpaceChatPath} from "@app/routes"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {UPLOAD_MIME_TYPES} from "@app/uploads"
|
||||
import ShareDialog from "@app/components/Share.svelte"
|
||||
import ShareEvent from "@app/components/ShareEvent.svelte"
|
||||
|
||||
|
|
@ -64,10 +63,6 @@ export const shareFromNative = async ({
|
|||
if (text) {
|
||||
shareText(text)
|
||||
} else if (path) {
|
||||
if (!UPLOAD_MIME_TYPES.includes(mimeType)) {
|
||||
return pushToast({theme: "error", message: "Flotilla can't share that type of file."})
|
||||
}
|
||||
|
||||
try {
|
||||
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 {canUploadBlob, encryptFile, makeBlossomAuthEvent, uploadBlob} from "@welshman/util"
|
||||
import {Nip01Signer} from "@welshman/signer"
|
||||
import type {UploadTask} from "@welshman/editor"
|
||||
import {compressFile} from "@lib/html"
|
||||
import {app, blossomServerLists, relays} from "@app/core"
|
||||
import {DEFAULT_BLOSSOM_SERVERS} from "@app/env"
|
||||
|
|
@ -62,22 +61,6 @@ export const getBlossomServer = async (options: GetBlossomServerOptions = {}) =>
|
|||
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 = {
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
|
|
@ -94,9 +77,38 @@ export type UploadFileOptions = {
|
|||
encrypt?: boolean
|
||||
}
|
||||
|
||||
export type UploadResult = {
|
||||
url: string
|
||||
sha256: string
|
||||
tags: string[][]
|
||||
}
|
||||
|
||||
export type UploadFileResult = {
|
||||
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 = {}) => {
|
||||
|
|
@ -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 hashes = [await sha256(await file.arrayBuffer())]
|
||||
const $signer = app.get().user?.signer || Nip01Signer.ephemeral()
|
||||
const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes})
|
||||
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 text = await res.text()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue