diff --git a/appview/pages/static/markdown-editor.js b/appview/pages/static/markdown-editor.js index 08c1363e..e487e5a9 100644 --- a/appview/pages/static/markdown-editor.js +++ b/appview/pages/static/markdown-editor.js @@ -40,10 +40,10 @@ export default class TangledMarkdownEditor extends HTMLElement { }); }); - // this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); - // this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); - // this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); - // this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); + this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); + this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); + this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); + this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); } async insertFile() { @@ -114,19 +114,25 @@ export default class TangledMarkdownEditor extends HTMLElement { this.#insertTextAtCursor(placeholder); - let blob; try { - blob = await this.#upload(file); + const blob = await this.#upload(file); + + // append json-encoded blob to form + const input = document.createElement("input"); + input.name = "blob"; + input.type = "text"; + input.hidden = true; + input.value = JSON.stringify(blob); + this.appendChild(input); + + // insert image to markdown + const cid = blob.ref["$link"] + textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); } catch (e) { console.error("failed to upload blob", e) textarea.value = textarea.value.replace(placeholder, ``); return } - - // TODO: insert blob itself to form - - const cid = blob.ref["$link"] - textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); } /** @param {string} text */ @@ -168,6 +174,7 @@ export default class TangledMarkdownEditor extends HTMLElement { }, }); const output = await res.json(); + console.warn("com.atproto.repo.uploadBlob out:", output); return output.blob; } } diff --git a/appview/state/comment.go b/appview/state/comment.go index 9ce5f649..f7338e03 100644 --- a/appview/state/comment.go +++ b/appview/state/comment.go @@ -2,6 +2,7 @@ package state import ( "bytes" + "encoding/json" "fmt" "net/http" "strconv" @@ -100,14 +101,44 @@ func (s *State) NewComment(w http.ResponseWriter, r *http.Request) { return } + var blobs []*lexutil.LexBlob + rawBlobs := r.Form["blob"] + if len(rawBlobs) > 0 { + blobs = make([]*lexutil.LexBlob, len(rawBlobs)); + for i, rawBlob := range rawBlobs { + var blob lexutil.LexBlob + if err := json.Unmarshal([]byte(rawBlob), &blob); err != nil { + l.Error("failed to decode blob", "err", err) + s.pages.Notice(w, noticeId, "Failed to decode blob object. Please try again later.") + return + } + blobs[i] = &blob + } + } + // TODO(boltless): normalize markdown body + // normalize(body string) -> (string, []syntax.DID, []syntax.ATURI, []syntax.CID) + // @boltless.me -> [did:...] + // https://tangled.org/tangled.org/core/issues/1 -> [at://...] + // (additionally return list of blobs referenced in markdown) + // + // parse_markdown(string) -> AST -> transform_markdown(body AST) -> (AST) + // 1. parse markdown (string->AST). reflinks are parsed at this phase + // 2. transform markdown (AST->AST). we convert blob links here. (pds/porxie + camo) + // NOTE: can we do blob link transformation on render? don't want to touch AST. Markdown should not have an AST. + // [did:...] -> "@boltless.me" + // [at://...] -> "#123 my issue" + // ![image](blob://...) -> + // + // body -> (normalizedBody, dids, uris, blobs) + // we check if number of referenced blobs match the number of uploaded blobs normalizedBody := body _, references := s.mentionsResolver.Resolve(ctx, body) markdownBody := tangled.MarkupMarkdown{ Text: normalizedBody, Original: &body, - Blobs: nil, + Blobs: blobs, } subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) diff --git a/appview/state/router.go b/appview/state/router.go index a9e6d5ee..a46e0629 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -215,6 +215,10 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.With(middleware.AuthMiddleware(s.oauth)).Route("/markup", func(r chi.Router) { r.Post("/preview", s.MarkdownPreview) }) + r.Route("/xrpc", func(r chi.Router) { + r.With(middleware.AuthMiddleware(s.oauth)).Post("/com.atproto.repo.uploadBlob", s.XrpcComAtprotoRepoUploadBlob) + }) + r.Get("/profile/popover", s.ProfilePopover) r.Route("/profile", func(r chi.Router) { diff --git a/appview/state/xrpc.go b/appview/state/xrpc.go new file mode 100644 index 00000000..e2f352a8 --- /dev/null +++ b/appview/state/xrpc.go @@ -0,0 +1,63 @@ +package state + +import ( + "encoding/json" + "errors" + "net/http" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "tangled.org/core/xrpc" +) + +// proxy requests to {pds}/xrpc/com.atproto.repo.uploadBlob +func (s *State) XrpcComAtprotoRepoUploadBlob(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + defer r.Body.Close() + + l := s.logger.With("handler", "XrpcComAtprotoRepoUploadBlob") + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + if err := json.NewEncoder(w).Encode(&indigoxrpc.XRPCError{ + ErrStr: "AuthMissing", + Message: "Authentication Required", + }); err != nil { + l.Error("failed to encode json", "err", err) + } + return + } + + out, err := xrpc.RepoUploadBlob(ctx, client, r.Body, r.Header.Get("Content-Type")) + if err != nil { + var xrpcErr *indigoxrpc.Error + if ok := errors.As(err, &xrpcErr); ok { + l.Error("xrpc response", "err", xrpcErr) + + w.WriteHeader(xrpcErr.StatusCode) + var xrpcBody *indigoxrpc.XRPCError + if ok := errors.As(xrpcErr.Wrapped, &xrpcBody); ok { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(xrpcBody); err != nil { + l.Error("failed to encode json", "err", err) + } + } + } else { + l.Error("failed to parse xrpc error body") + + w.WriteHeader(http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(&indigoxrpc.XRPCError{ + ErrStr: "InternalError", + Message: "Internal server error", + }); err != nil { + l.Error("failed to encode json", "err", err) + } + } + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(out); err != nil { + l.Error("failed to encode json", "err", err) + } +} diff --git a/input.css b/input.css index 123c88a7..9cd6006e 100644 --- a/input.css +++ b/input.css @@ -1109,6 +1109,15 @@ actor-typeahead::part(menu) { } } +markdown-editor.drag-hover textarea { + outline: dashed 2px #ccc; + outline-offset: -0.5em; +} +markdown-editor:not(.drag-hover) button > .condensed, +markdown-editor.drag-hover button > .spacious { + display: none; +} + @layer utilities { .hit-area { position: relative;