Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

appview: support blob upload from markdown editor

This commit doesn't include blob rendering

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jun 21, 2026, 8:28 PM +0900) commit b5d23dfb parent 693d0644 change-id wxmyzsvk
+165 -27
+1 -1
appview/ingester.go
··· 1648 1648 1649 1649 var references []syntax.ATURI 1650 1650 if comment.Body.Original != nil { 1651 - _, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) 1651 + _, references, _ = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) 1652 1652 } 1653 1653 1654 1654 tx, err := i.Db.Begin()
+2 -2
appview/issues/issues.go
··· 204 204 newIssue := issue 205 205 newIssue.Title = r.FormValue("title") 206 206 newIssue.Body = r.FormValue("body") 207 - newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 207 + newIssue.Mentions, newIssue.References, _ = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 208 208 209 209 if err := rp.validator.ValidateIssue(newIssue); err != nil { 210 210 l.Error("validation error", "err", err) ··· 665 665 }) 666 666 case http.MethodPost: 667 667 body := r.FormValue("body") 668 - mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 668 + mentions, references, _ := rp.mentionsResolver.Resolve(r.Context(), body) 669 669 670 670 issue := &models.Issue{ 671 671 RepoDid: syntax.DID(f.RepoDid),
+3 -3
appview/mentions/resolver.go
··· 33 33 } 34 34 } 35 35 36 - func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) { 36 + func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI, []syntax.CID) { 37 37 l := r.logger.With("method", "Resolve") 38 38 39 - rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source) 39 + rawMentions, rawRefs, blobs := markup.FindReferences(r.config.Core.AppviewHost, source) 40 40 l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs) 41 41 42 42 idents := r.idResolver.ResolveIdents(ctx, rawMentions) ··· 63 63 } 64 64 l.Debug("found references", "refs", aturiRefs) 65 65 66 - return mentions, aturiRefs 66 + return mentions, aturiRefs, blobs 67 67 }
+11 -2
appview/pages/markup/reference_link.go
··· 19 19 // like issues, PRs, comments or even @-mentions 20 20 // This function doesn't actually check for the existence of records in the DB 21 21 // or the PDS; it merely returns a list of what are presumed to be references. 22 - func FindReferences(host string, source string) ([]string, []models.ReferenceLink) { 22 + func FindReferences(host string, source string) ([]string, []models.ReferenceLink, []syntax.CID) { 23 23 var ( 24 24 refLinkSet = make(map[models.ReferenceLink]struct{}) 25 25 mentionsSet = make(map[string]struct{}) 26 + blobsSet = make(map[syntax.CID]struct{}) 26 27 md = NewMarkdown(host) 27 28 sourceBytes = []byte(source) 28 29 root = md.Parser().Parse(text.NewReader(sourceBytes)) ··· 54 55 } 55 56 } 56 57 return ast.WalkSkipChildren, nil 58 + case ast.KindImage: 59 + n := n.(*ast.Image) 60 + if rawCid, found := strings.CutPrefix(string(n.Destination), "blob://"); found { 61 + if cid, err := syntax.ParseCID(rawCid); err == nil { 62 + blobsSet[cid] = struct{}{} 63 + } 64 + } 57 65 } 58 66 return ast.WalkContinue, nil 59 67 }) 60 68 mentions := slices.Collect(maps.Keys(mentionsSet)) 61 69 references := slices.Collect(maps.Keys(refLinkSet)) 62 - return mentions, references 70 + blobs := slices.Collect(maps.Keys(blobsSet)) 71 + return mentions, references, blobs 63 72 } 64 73 65 74 func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink {
+18 -11
appview/pages/static/markdown-editor.js
··· 40 40 }); 41 41 }); 42 42 43 - // this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); 44 - // this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); 45 - // this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); 46 - // this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); 43 + this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); 44 + this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); 45 + this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); 46 + this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); 47 47 } 48 48 49 49 async insertFile() { ··· 114 114 115 115 this.#insertTextAtCursor(placeholder); 116 116 117 - let blob; 118 117 try { 119 - blob = await this.#upload(file); 118 + const blob = await this.#upload(file); 119 + 120 + // append json-encoded blob to form 121 + const input = document.createElement("input"); 122 + input.name = "blob"; 123 + input.type = "text"; 124 + input.hidden = true; 125 + input.value = JSON.stringify(blob); 126 + this.appendChild(input); 127 + 128 + // insert image to markdown 129 + const cid = blob.ref["$link"] 130 + textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); 120 131 } catch (e) { 121 132 console.error("failed to upload blob", e) 122 133 textarea.value = textarea.value.replace(placeholder, `<!-- Failed to upload "${file.name}". -->`); 123 134 return 124 135 } 125 - 126 - // TODO: insert blob itself to form 127 - 128 - const cid = blob.ref["$link"] 129 - textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); 130 136 } 131 137 132 138 /** @param {string} text */ ··· 168 174 }, 169 175 }); 170 176 const output = await res.json(); 177 + console.warn("com.atproto.repo.uploadBlob out:", output); 171 178 return output.blob; 172 179 } 173 180 }
+2 -2
appview/pulls/create.go
··· 261 261 } 262 262 } 263 263 264 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 264 + mentions, references, _ := s.mentionsResolver.Resolve(r.Context(), body) 265 265 266 266 rkey := tid.TID() 267 267 ··· 484 484 } 485 485 rkey := tid.TID() 486 486 487 - mentions, references := s.mentionsResolver.Resolve(ctx, body) 487 + mentions, references, _ := s.mentionsResolver.Resolve(ctx, body) 488 488 489 489 now := time.Now() 490 490
+1 -1
appview/pulls/edit.go
··· 36 36 newPull := *pull 37 37 newPull.Title = r.FormValue("title") 38 38 newPull.Body = r.FormValue("body") 39 - newPull.Mentions, newPull.References = s.mentionsResolver.Resolve(ctx, newPull.Body) 39 + newPull.Mentions, newPull.References, _ = s.mentionsResolver.Resolve(ctx, newPull.Body) 40 40 41 41 // edit an atproto record 42 42 client, err := s.oauth.AuthorizedClient(r)
+42 -4
appview/state/comment.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "encoding/json" 5 6 "fmt" 6 7 "net/http" 8 + "slices" 7 9 "strconv" 8 10 "time" 9 11 ··· 102 104 103 105 // TODO(boltless): normalize markdown body 104 106 normalizedBody := body 105 - mentions, references := s.mentionsResolver.Resolve(ctx, body) 107 + mentions, references, blobCIDs := s.mentionsResolver.Resolve(ctx, body) 108 + 109 + rawBlobs := r.Form["blob"] 110 + blobs := make([]*lexutil.LexBlob, 0, len(rawBlobs)) 111 + if len(rawBlobs) > 0 { 112 + blobs = make([]*lexutil.LexBlob, len(rawBlobs)); 113 + for _, rawBlob := range rawBlobs { 114 + var blob lexutil.LexBlob 115 + if err := json.Unmarshal([]byte(rawBlob), &blob); err != nil { 116 + l.Error("failed to decode blob", "err", err) 117 + s.pages.Notice(w, noticeId, "Failed to decode blob object. Please try again later.") 118 + return 119 + } 120 + if !slices.Contains(blobCIDs, syntax.CID(blob.Ref.String())) { 121 + continue 122 + } 123 + blobs = append(blobs, &blob) 124 + } 125 + } 106 126 107 127 markdownBody := tangled.MarkupMarkdown{ 108 128 Text: normalizedBody, 109 129 Original: &body, 110 - Blobs: nil, 130 + Blobs: blobs, 111 131 } 112 132 113 133 subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) ··· 332 352 333 353 // TODO(boltless): normalize markdown body 334 354 normalizedBody := body 335 - _, references := s.mentionsResolver.Resolve(ctx, body) 355 + _, references, blobCIDs := s.mentionsResolver.Resolve(ctx, body) 356 + 357 + rawBlobs := r.Form["blob"] 358 + blobs := make([]*lexutil.LexBlob, 0, len(rawBlobs)) 359 + if len(rawBlobs) > 0 { 360 + blobs = make([]*lexutil.LexBlob, len(rawBlobs)); 361 + for _, rawBlob := range rawBlobs { 362 + var blob lexutil.LexBlob 363 + if err := json.Unmarshal([]byte(rawBlob), &blob); err != nil { 364 + l.Error("failed to decode blob", "err", err) 365 + s.pages.Notice(w, noticeId, "Failed to decode blob object. Please try again later.") 366 + return 367 + } 368 + if !slices.Contains(blobCIDs, syntax.CID(blob.Ref.String())) { 369 + continue 370 + } 371 + blobs = append(blobs, &blob) 372 + } 373 + } 336 374 337 375 now := time.Now() 338 376 newComment := comment 339 377 newComment.Body = tangled.MarkupMarkdown{ 340 378 Text: normalizedBody, 341 379 Original: &body, 342 - Blobs: nil, 380 + Blobs: blobs, 343 381 } 344 382 newComment.Edited = &now 345 383 if err := newComment.Validate(); err != nil {
+4
appview/state/router.go
··· 234 234 r.With(middleware.AuthMiddleware(s.oauth)).Route("/markup", func(r chi.Router) { 235 235 r.Post("/preview", s.MarkdownPreview) 236 236 }) 237 + r.Route("/xrpc", func(r chi.Router) { 238 + r.With(middleware.AuthMiddleware(s.oauth)).Post("/com.atproto.repo.uploadBlob", s.XrpcComAtprotoRepoUploadBlob) 239 + }) 240 + 237 241 r.Get("/profile/popover", s.ProfilePopover) 238 242 239 243 r.Route("/profile", func(r chi.Router) {
+71
appview/state/xrpc.go
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 + "tangled.org/core/xrpc" 11 + ) 12 + 13 + // proxy requests to {pds}/xrpc/com.atproto.repo.uploadBlob 14 + func (s *State) XrpcComAtprotoRepoUploadBlob(w http.ResponseWriter, r *http.Request) { 15 + ctx := r.Context() 16 + defer r.Body.Close() 17 + 18 + l := s.logger.With("handler", "XrpcComAtprotoRepoUploadBlob") 19 + 20 + client, err := s.oauth.AuthorizedClient(r) 21 + if err != nil { 22 + if err := json.NewEncoder(w).Encode(&indigoxrpc.XRPCError{ 23 + ErrStr: "AuthMissing", 24 + Message: "Authentication Required", 25 + }); err != nil { 26 + l.Error("failed to encode json", "err", err) 27 + } 28 + return 29 + } 30 + 31 + // pre-warm DPoP nonce 32 + if _, err := comatproto.ServerGetSession(ctx, client); err != nil { 33 + l.Error("failed to pre-warm session", "err", err) 34 + w.WriteHeader(http.StatusInternalServerError) 35 + return 36 + } 37 + 38 + out, err := xrpc.RepoUploadBlob(ctx, client, r.Body, r.Header.Get("Content-Type")) 39 + if err != nil { 40 + var xrpcErr *indigoxrpc.Error 41 + if ok := errors.As(err, &xrpcErr); ok { 42 + l.Error("xrpc response", "err", xrpcErr) 43 + 44 + w.WriteHeader(xrpcErr.StatusCode) 45 + var xrpcBody *indigoxrpc.XRPCError 46 + if ok := errors.As(xrpcErr.Wrapped, &xrpcBody); ok { 47 + w.Header().Set("Content-Type", "application/json") 48 + if err := json.NewEncoder(w).Encode(xrpcBody); err != nil { 49 + l.Error("failed to encode json", "err", err) 50 + } 51 + } 52 + } else { 53 + l.Error("failed to parse xrpc error body", "err", err) 54 + 55 + w.WriteHeader(http.StatusInternalServerError) 56 + if err := json.NewEncoder(w).Encode(&indigoxrpc.XRPCError{ 57 + ErrStr: "InternalError", 58 + Message: "Internal server error", 59 + }); err != nil { 60 + l.Error("failed to encode json", "err", err) 61 + } 62 + } 63 + return 64 + } 65 + 66 + w.Header().Set("Content-Type", "application/json") 67 + w.WriteHeader(http.StatusOK) 68 + if err := json.NewEncoder(w).Encode(out); err != nil { 69 + l.Error("failed to encode json", "err", err) 70 + } 71 + }
+9
input.css
··· 1232 1232 } 1233 1233 } 1234 1234 1235 + markdown-editor.drag-hover textarea { 1236 + outline: dashed 2px #ccc; 1237 + outline-offset: -0.5em; 1238 + } 1239 + markdown-editor:not(.drag-hover) button > .condensed, 1240 + markdown-editor.drag-hover button > .spacious { 1241 + display: none; 1242 + } 1243 + 1235 1244 @layer utilities { 1236 1245 .hit-area { 1237 1246 position: relative;