Monorepo for Tangled tangled.org
8

Configure Feed

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

appview: replace `IssueComment` to `Comment`

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

author
Seongmin Lee
date (May 22, 2026, 7:55 PM +0900) commit d8f993c7 parent 29575536 change-id rnzmxlxx
+240 -481
+9 -5
appview/db/comments.go
··· 4 4 "database/sql" 5 5 "encoding/json" 6 6 "fmt" 7 + "log" 7 8 "sort" 8 9 "strings" 9 10 "time" ··· 95 96 return err 96 97 } 97 98 98 - if affected > 0 { 99 - // update references when comment is updated 100 - if err := putReferences(tx, c.AtUri(), references); err != nil { 101 - return fmt.Errorf("put reference_links: %w", err) 102 - } 99 + if affected < 1 { 100 + log.Println("record is already stored. skipping operation") 101 + return nil 102 + } 103 + 104 + // update references when comment is updated 105 + if err := putReferences(tx, c.AtUri(), references); err != nil { 106 + return fmt.Errorf("put reference_links: %w", err) 103 107 } 104 108 105 109 return nil
+6 -186
appview/db/issues.go
··· 100 100 } 101 101 102 102 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 103 - issueMap := make(map[string]*models.Issue) // at-uri -> issue 103 + issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue 104 104 105 105 var conditions []string 106 106 var args []any ··· 196 196 } 197 197 } 198 198 199 - atUri := issue.AtUri().String() 200 - issueMap[atUri] = &issue 199 + issueMap[issue.AtUri()] = &issue 201 200 } 202 201 203 202 // collect reverse repos ··· 229 228 // collect comments 230 229 issueAts := slices.Collect(maps.Keys(issueMap)) 231 230 232 - comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 231 + comments, err := GetComments(e, orm.FilterIn("subject_uri", issueAts)) 233 232 if err != nil { 234 233 return nil, fmt.Errorf("failed to query comments: %w", err) 235 234 } 236 235 for i := range comments { 237 - issueAt := comments[i].IssueAt 236 + issueAt := syntax.ATURI(comments[i].Subject.Uri) 238 237 if issue, ok := issueMap[issueAt]; ok { 239 238 issue.Comments = append(issue.Comments, comments[i]) 240 239 } ··· 246 245 return nil, fmt.Errorf("failed to query labels: %w", err) 247 246 } 248 247 for issueAt, labels := range allLabels { 249 - if issue, ok := issueMap[issueAt.String()]; ok { 248 + if issue, ok := issueMap[issueAt]; ok { 250 249 issue.Labels = labels 251 250 } 252 251 } ··· 257 256 return nil, fmt.Errorf("failed to query reference_links: %w", err) 258 257 } 259 258 for issueAt, references := range allReferences { 260 - if issue, ok := issueMap[issueAt.String()]; ok { 259 + if issue, ok := issueMap[issueAt]; ok { 261 260 issue.References = references 262 261 } 263 262 } ··· 293 292 294 293 func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 295 294 return GetIssuesPaginated(e, pagination.Page{}, filters...) 296 - } 297 - 298 - func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 299 - result, err := tx.Exec( 300 - `insert into issue_comments ( 301 - did, 302 - rkey, 303 - issue_at, 304 - body, 305 - reply_to, 306 - created, 307 - edited 308 - ) 309 - values (?, ?, ?, ?, ?, ?, null) 310 - on conflict(did, rkey) do update set 311 - issue_at = excluded.issue_at, 312 - body = excluded.body, 313 - edited = case 314 - when 315 - issue_comments.issue_at != excluded.issue_at 316 - or issue_comments.body != excluded.body 317 - or issue_comments.reply_to != excluded.reply_to 318 - then ? 319 - else issue_comments.edited 320 - end`, 321 - c.Did, 322 - c.Rkey, 323 - c.IssueAt, 324 - c.Body, 325 - c.ReplyTo, 326 - c.Created.Format(time.RFC3339), 327 - time.Now().Format(time.RFC3339), 328 - ) 329 - if err != nil { 330 - return 0, err 331 - } 332 - 333 - id, err := result.LastInsertId() 334 - if err != nil { 335 - return 0, err 336 - } 337 - 338 - if err := putReferences(tx, c.AtUri(), c.References); err != nil { 339 - return 0, fmt.Errorf("put reference_links: %w", err) 340 - } 341 - 342 - return id, nil 343 - } 344 - 345 - func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 346 - var conditions []string 347 - var args []any 348 - for _, filter := range filters { 349 - conditions = append(conditions, filter.Condition()) 350 - args = append(args, filter.Arg()...) 351 - } 352 - 353 - whereClause := "" 354 - if conditions != nil { 355 - whereClause = " where " + strings.Join(conditions, " and ") 356 - } 357 - 358 - query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 359 - 360 - _, err := e.Exec(query, args...) 361 - return err 362 - } 363 - 364 - func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 365 - commentMap := make(map[string]*models.IssueComment) 366 - 367 - var conditions []string 368 - var args []any 369 - for _, filter := range filters { 370 - conditions = append(conditions, filter.Condition()) 371 - args = append(args, filter.Arg()...) 372 - } 373 - 374 - whereClause := "" 375 - if conditions != nil { 376 - whereClause = " where " + strings.Join(conditions, " and ") 377 - } 378 - 379 - query := fmt.Sprintf(` 380 - select 381 - id, 382 - did, 383 - rkey, 384 - issue_at, 385 - reply_to, 386 - body, 387 - created, 388 - edited, 389 - deleted 390 - from 391 - issue_comments 392 - %s 393 - `, whereClause) 394 - 395 - rows, err := e.Query(query, args...) 396 - if err != nil { 397 - return nil, err 398 - } 399 - defer rows.Close() 400 - 401 - for rows.Next() { 402 - var comment models.IssueComment 403 - var created string 404 - var rkey, edited, deleted, replyTo sql.Null[string] 405 - err := rows.Scan( 406 - &comment.Id, 407 - &comment.Did, 408 - &rkey, 409 - &comment.IssueAt, 410 - &replyTo, 411 - &comment.Body, 412 - &created, 413 - &edited, 414 - &deleted, 415 - ) 416 - if err != nil { 417 - return nil, err 418 - } 419 - 420 - // this is a remnant from old times, newer comments always have rkey 421 - if rkey.Valid { 422 - comment.Rkey = rkey.V 423 - } 424 - 425 - if t, err := time.Parse(time.RFC3339, created); err == nil { 426 - comment.Created = t 427 - } 428 - 429 - if edited.Valid { 430 - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 431 - comment.Edited = &t 432 - } 433 - } 434 - 435 - if deleted.Valid { 436 - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 437 - comment.Deleted = &t 438 - } 439 - } 440 - 441 - if replyTo.Valid { 442 - comment.ReplyTo = &replyTo.V 443 - } 444 - 445 - atUri := comment.AtUri().String() 446 - commentMap[atUri] = &comment 447 - } 448 - 449 - if err = rows.Err(); err != nil { 450 - return nil, err 451 - } 452 - 453 - // collect references for each comments 454 - commentAts := slices.Collect(maps.Keys(commentMap)) 455 - allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 456 - if err != nil { 457 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 458 - } 459 - for commentAt, references := range allReferences { 460 - if comment, ok := commentMap[commentAt.String()]; ok { 461 - comment.References = references 462 - } 463 - } 464 - 465 - var comments []models.IssueComment 466 - for _, c := range commentMap { 467 - comments = append(comments, *c) 468 - } 469 - 470 - sort.Slice(comments, func(i, j int) bool { 471 - return comments[i].Created.After(comments[j].Created) 472 - }) 473 - 474 - return comments, nil 475 295 } 476 296 477 297 func DeleteIssues(tx *sql.Tx, did, rkey string) error {
+13 -24
appview/db/reference.go
··· 11 11 "tangled.org/core/orm" 12 12 ) 13 13 14 - // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. 15 15 // It will ignore missing refLinks. 16 16 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 17 var ( ··· 53 53 values %s 54 54 ) 55 55 select 56 - i.did, i.rkey, 57 - c.did, c.rkey 56 + i.at_uri, c.at_uri 58 57 from input inp 59 58 join repos r 60 59 on r.did = inp.owner_did ··· 62 61 join issues i 63 62 on i.repo_did = r.repo_did 64 63 and i.issue_id = inp.issue_id 65 - left join issue_comments c 64 + left join comments c 66 65 on inp.comment_id is not null 67 - and c.issue_at = i.at_uri 66 + and c.subject_uri = i.at_uri 68 67 and c.id = inp.comment_id 69 68 `, 70 69 strings.Join(vals, ","), ··· 79 78 80 79 for rows.Next() { 81 80 // Scan rows 82 - var issueOwner, issueRkey string 83 - var commentOwner, commentRkey sql.NullString 81 + var issueUri string 82 + var commentUri sql.NullString 84 83 var uri syntax.ATURI 85 - if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 84 + if err := rows.Scan(&issueUri, &commentUri); err != nil { 86 85 return nil, err 87 86 } 88 - if commentOwner.Valid && commentRkey.Valid { 89 - uri = syntax.ATURI(fmt.Sprintf( 90 - "at://%s/%s/%s", 91 - commentOwner.String, 92 - tangled.RepoIssueCommentNSID, 93 - commentRkey.String, 94 - )) 87 + if commentUri.Valid { 88 + uri = syntax.ATURI(commentUri.String) 95 89 } else { 96 - uri = syntax.ATURI(fmt.Sprintf( 97 - "at://%s/%s/%s", 98 - issueOwner, 99 - tangled.RepoIssueNSID, 100 - issueRkey, 101 - )) 90 + uri = syntax.ATURI(issueUri) 102 91 } 103 92 uris = append(uris, uri) 104 93 } ··· 282 271 return nil, fmt.Errorf("get issue backlinks: %w", err) 283 272 } 284 273 backlinks = append(backlinks, ls...) 285 - ls, err = getIssueCommentBacklinks(e, target, backlinksMap[tangled.RepoIssueCommentNSID]) 274 + ls, err = getIssueCommentBacklinks(e, target, backlinksMap[tangled.FeedCommentNSID]) 286 275 if err != nil { 287 276 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 288 277 } ··· 352 341 rows, err := e.Query( 353 342 fmt.Sprintf( 354 343 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 355 - from issue_comments c 344 + from comments c 356 345 join issues i 357 - on i.at_uri = c.issue_at 346 + on i.at_uri = c.subject_uri 358 347 join repos r 359 348 on r.repo_did = i.repo_did 360 349 where %s and %s`,
+8 -40
appview/ingester.go
··· 1545 1545 return nil 1546 1546 } 1547 1547 1548 + // ingestIssueComment ingests legacy sh.tangled.repo.issue.comment deletions 1548 1549 func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 1549 - did := e.Did 1550 - rkey := e.Commit.RKey 1551 - 1552 - var err error 1553 - 1554 - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 1550 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) 1555 1551 l.Info("ingesting record") 1556 1552 1557 1553 switch e.Commit.Operation { 1558 1554 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1559 - raw := json.RawMessage(e.Commit.Record) 1560 - record := tangled.RepoIssueComment{} 1561 - err = json.Unmarshal(raw, &record) 1562 - if err != nil { 1563 - return fmt.Errorf("invalid record: %w", err) 1564 - } 1565 - 1566 - comment, err := models.IssueCommentFromRecord(did, rkey, record) 1567 - if err != nil { 1568 - return fmt.Errorf("failed to parse comment from record: %w", err) 1569 - } 1570 - 1571 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 1572 - return fmt.Errorf("failed to validate comment: %w", err) 1573 - } 1574 - 1575 - tx, err := i.Db.Begin() 1576 - if err != nil { 1577 - return fmt.Errorf("failed to start transaction: %w", err) 1578 - } 1579 - defer tx.Rollback() 1580 - 1581 - _, err = db.AddIssueComment(tx, *comment) 1582 - if err != nil { 1583 - return fmt.Errorf("failed to create issue comment: %w", err) 1584 - } 1585 - 1586 - return tx.Commit() 1555 + // no-op. sh.tangled.repo.issue.comment is deprecated 1587 1556 1588 1557 case jmodels.CommitOperationDelete: 1589 - if err := db.DeleteIssueComments( 1558 + if err := db.PurgeComments( 1590 1559 i.Db, 1591 - orm.FilterEq("did", did), 1592 - orm.FilterEq("rkey", rkey), 1560 + orm.FilterEq("did", e.Did), 1561 + orm.FilterEq("collection", e.Commit.Collection), 1562 + orm.FilterEq("rkey", e.Commit.RKey), 1593 1563 ); err != nil { 1594 - return fmt.Errorf("failed to delete issue comment record: %w", err) 1564 + return fmt.Errorf("failed to delete comment record: %w", err) 1595 1565 } 1596 - 1597 - return nil 1598 1566 } 1599 1567 1600 1568 return nil
+138 -81
appview/issues/issues.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/atclient" 15 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 18 "github.com/go-chi/chi/v5" 18 19 19 20 "tangled.org/core/api/tangled" ··· 420 421 421 422 body := r.FormValue("body") 422 423 if body == "" { 423 - rp.pages.Notice(w, "issue", "Body is required") 424 + rp.pages.Notice(w, "issue-comment", "Body is required") 425 + return 426 + } 427 + 428 + // TODO(boltless): normalize markdown body 429 + normalizedBody := body 430 + _, references := rp.mentionsResolver.Resolve(r.Context(), body) 431 + 432 + markdownBody := tangled.MarkupMarkdown{ 433 + Text: normalizedBody, 434 + Original: &body, 435 + Blobs: nil, 436 + } 437 + 438 + // ingest CID of issue record on-demand. 439 + // TODO(boltless): appview should ingest CID of atproto records 440 + cid, err := func() (syntax.CID, error) { 441 + ident, err := rp.idResolver.ResolveIdent(r.Context(), issue.Did) 442 + if err != nil { 443 + return "", err 444 + } 445 + 446 + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 447 + out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 448 + if err != nil { 449 + return "", err 450 + } 451 + if out.Cid == nil { 452 + return "", fmt.Errorf("record CID is empty") 453 + } 454 + 455 + cid, err := syntax.ParseCID(*out.Cid) 456 + if err != nil { 457 + return "", err 458 + } 459 + 460 + return cid, nil 461 + }() 462 + if err != nil { 463 + rp.logger.Error("failed to backfill subject PR record", "err", err) 464 + rp.pages.Notice(w, "issue-comment", "failed to backfill subject record") 424 465 return 425 466 } 467 + issueStrongRef := comatproto.RepoStrongRef{ 468 + Uri: issue.AtUri().String(), 469 + Cid: cid.String(), 470 + } 426 471 427 - replyToUri := r.FormValue("reply-to") 428 - var replyTo *string 429 - if replyToUri != "" { 430 - replyTo = &replyToUri 472 + var replyTo *comatproto.RepoStrongRef 473 + replyToUriRaw := r.FormValue("reply-to-uri") 474 + replyToCidRaw := r.FormValue("reply-to-cid") 475 + if replyToUriRaw != "" && replyToCidRaw != "" { 476 + uri, err := syntax.ParseATURI(replyToUriRaw) 477 + if err != nil { 478 + rp.pages.Notice(w, "issue-comment", "reply-to-uri should be valid AT-URI") 479 + return 480 + } 481 + cid, err := syntax.ParseCID(replyToCidRaw) 482 + if err != nil { 483 + rp.pages.Notice(w, "issue-comment", "reply-to-cid should be valid CID") 484 + return 485 + } 486 + replyTo = &comatproto.RepoStrongRef{ 487 + Uri: uri.String(), 488 + Cid: cid.String(), 489 + } 431 490 } 432 491 433 492 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 434 493 435 - comment := models.IssueComment{ 436 - Did: user.Did, 437 - Rkey: tid.TID(), 438 - IssueAt: issue.AtUri().String(), 439 - ReplyTo: replyTo, 440 - Body: body, 441 - Created: time.Now(), 442 - Mentions: mentions, 443 - References: references, 494 + comment := models.Comment{ 495 + Did: syntax.DID(user.Did), 496 + Collection: tangled.FeedCommentNSID, 497 + Rkey: syntax.RecordKey(tid.TID()), 498 + 499 + Subject: issueStrongRef, 500 + Body: markdownBody, 501 + Created: time.Now(), 502 + ReplyTo: replyTo, 444 503 } 445 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 504 + if err = comment.Validate(); err != nil { 446 505 l.Error("failed to validate comment", "err", err) 447 506 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 448 507 return 449 508 } 450 - record := comment.AsRecord() 451 509 452 510 client, err := rp.oauth.AuthorizedClient(r) 453 511 if err != nil { ··· 457 515 } 458 516 459 517 // create a record first 460 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 461 - Collection: tangled.RepoIssueCommentNSID, 462 - Repo: comment.Did, 463 - Rkey: comment.Rkey, 464 - Record: &lexutil.LexiconTypeDecoder{ 465 - Val: &record, 466 - }, 518 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 519 + Collection: comment.Collection.String(), 520 + Repo: comment.Did.String(), 521 + Rkey: comment.Rkey.String(), 522 + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 467 523 }) 468 524 if err != nil { 469 525 l.Error("failed to create comment", "err", err) 470 526 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 471 527 return 472 528 } 473 - atUri := resp.Uri 474 - defer func() { 475 - if err := rollbackRecord(context.Background(), atUri, client); err != nil { 476 - l.Error("rollback failed", "err", err) 477 - } 478 - }() 529 + 530 + comment.Cid = syntax.CID(out.Cid) 479 531 480 532 tx, err := rp.db.Begin() 481 533 if err != nil { ··· 485 537 } 486 538 defer tx.Rollback() 487 539 488 - commentId, err := db.AddIssueComment(tx, comment) 540 + err = db.PutComment(tx, &comment, references) 489 541 if err != nil { 490 542 l.Error("failed to create comment", "err", err) 491 543 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 492 544 return 493 545 } 546 + 494 547 err = tx.Commit() 495 548 if err != nil { 496 549 l.Error("failed to commit transaction", "err", err) ··· 498 551 return 499 552 } 500 553 501 - // reset atUri to make rollback a no-op 502 - atUri = "" 503 - 504 - // notify about the new comment 505 - comment.Id = commentId 506 - 507 554 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 508 555 509 556 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 510 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 557 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 511 558 } 512 559 513 560 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 522 569 } 523 570 524 571 commentId := chi.URLParam(r, "commentId") 525 - comments, err := db.GetIssueComments( 572 + comments, err := db.GetComments( 526 573 rp.db, 527 574 orm.FilterEq("id", commentId), 528 575 ) ··· 558 605 } 559 606 560 607 commentId := chi.URLParam(r, "commentId") 561 - comments, err := db.GetIssueComments( 608 + comments, err := db.GetComments( 562 609 rp.db, 563 610 orm.FilterEq("id", commentId), 564 611 ) ··· 574 621 } 575 622 comment := comments[0] 576 623 577 - if comment.Did != user.Did { 624 + if comment.Did.String() != user.Did { 578 625 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 579 626 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 580 627 return ··· 590 637 }) 591 638 case http.MethodPost: 592 639 // extract form value 593 - newBody := r.FormValue("body") 640 + body := r.FormValue("body") 641 + if body == "" { 642 + rp.pages.Notice(w, "issue-comment", "Body is required") 643 + return 644 + } 645 + 646 + // TODO(boltless): normalize markdown body 647 + normalizedBody := body 648 + _, references := rp.mentionsResolver.Resolve(r.Context(), body) 649 + 650 + now := time.Now() 651 + newComment := comment 652 + newComment.Body = tangled.MarkupMarkdown{ 653 + Text: normalizedBody, 654 + Original: &body, 655 + Blobs: nil, 656 + } 657 + newComment.Edited = &now 658 + 594 659 client, err := rp.oauth.AuthorizedClient(r) 595 660 if err != nil { 596 661 l.Error("failed to get authorized client", "err", err) ··· 598 663 return 599 664 } 600 665 601 - now := time.Now() 602 - newComment := comment 603 - newComment.Body = newBody 604 - newComment.Edited = &now 605 - newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 666 + // update a record first 667 + exCid := comment.Cid.String() 668 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 669 + Collection: newComment.Collection.String(), 670 + Repo: newComment.Did.String(), 671 + Rkey: newComment.Rkey.String(), 672 + SwapRecord: &exCid, 673 + Record: &lexutil.LexiconTypeDecoder{ 674 + Val: newComment.AsRecord(), 675 + }, 676 + }) 677 + if err != nil { 678 + l.Error("failed to update comment", "err", err) 679 + rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 680 + return 681 + } 606 682 607 - record := newComment.AsRecord() 683 + newComment.Cid = syntax.CID(resp.Cid) 608 684 609 685 tx, err := rp.db.Begin() 610 686 if err != nil { ··· 614 690 } 615 691 defer tx.Rollback() 616 692 617 - _, err = db.AddIssueComment(tx, newComment) 693 + err = db.PutComment(tx, &newComment, references) 618 694 if err != nil { 619 695 l.Error("failed to perform update-description query", "err", err) 620 696 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 621 697 return 622 698 } 623 - tx.Commit() 624 - 625 - // rkey is optional, it was introduced later 626 - if newComment.Rkey != "" { 627 - // update the record on pds 628 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 629 - if err != nil { 630 - l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 631 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 632 - return 633 - } 634 - 635 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 636 - Collection: tangled.RepoIssueCommentNSID, 637 - Repo: user.Did, 638 - Rkey: newComment.Rkey, 639 - SwapRecord: ex.Cid, 640 - Record: &lexutil.LexiconTypeDecoder{ 641 - Val: &record, 642 - }, 643 - }) 644 - if err != nil { 645 - l.Error("failed to update record on PDS", "err", err) 646 - } 699 + err = tx.Commit() 700 + if err != nil { 701 + l.Error("failed to commit transaction", "err", err) 702 + rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 703 + return 647 704 } 648 705 649 706 // return new comment body with htmx ··· 668 725 } 669 726 670 727 commentId := chi.URLParam(r, "commentId") 671 - comments, err := db.GetIssueComments( 728 + comments, err := db.GetComments( 672 729 rp.db, 673 730 orm.FilterEq("id", commentId), 674 731 ) ··· 704 761 } 705 762 706 763 commentId := chi.URLParam(r, "commentId") 707 - comments, err := db.GetIssueComments( 764 + comments, err := db.GetComments( 708 765 rp.db, 709 766 orm.FilterEq("id", commentId), 710 767 ) ··· 740 797 } 741 798 742 799 commentId := chi.URLParam(r, "commentId") 743 - comments, err := db.GetIssueComments( 800 + comments, err := db.GetComments( 744 801 rp.db, 745 802 orm.FilterEq("id", commentId), 746 803 ) ··· 756 813 } 757 814 comment := comments[0] 758 815 759 - if comment.Did != user.Did { 816 + if comment.Did.String() != user.Did { 760 817 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 761 818 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 762 819 return ··· 769 826 770 827 // optimistic deletion 771 828 deleted := time.Now() 772 - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 829 + err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 773 830 if err != nil { 774 831 l.Error("failed to delete comment", "err", err) 775 832 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 785 842 return 786 843 } 787 844 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 788 - Collection: tangled.RepoIssueCommentNSID, 789 - Repo: user.Did, 790 - Rkey: comment.Rkey, 845 + Collection: comment.Collection.String(), 846 + Repo: comment.Did.String(), 847 + Rkey: comment.Rkey.String(), 791 848 }) 792 849 if err != nil { 793 850 l.Error("failed to delete from PDS", "err", err) ··· 795 852 } 796 853 797 854 // optimistic update for htmx 798 - comment.Body = "" 855 + comment.Body = tangled.MarkupMarkdown{} 799 856 comment.Deleted = &deleted 800 857 801 858 // htmx fragment of comment after deletion
+11
appview/models/comment.go
··· 61 61 } 62 62 } 63 63 64 + func (c *Comment) EditableBody() string { 65 + if c.Body.Original != nil { 66 + return *c.Body.Original 67 + } 68 + return c.Body.Text 69 + } 70 + 71 + func (c *Comment) IsLegacy() bool { 72 + return c.Collection != tangled.FeedCommentNSID 73 + } 74 + 64 75 func (c *Comment) IsTopLevel() bool { 65 76 return c.ReplyTo == nil 66 77 }
+12 -91
appview/models/issue.go
··· 26 26 27 27 // optionally, populate this when querying for reverse mappings 28 28 // like comment counts, parent repo etc. 29 - Comments []IssueComment 29 + Comments []Comment 30 30 Labels LabelState 31 31 Repo *Repo 32 32 } ··· 63 63 } 64 64 65 65 type CommentListItem struct { 66 - Self *IssueComment 67 - Replies []*IssueComment 66 + Self *Comment 67 + Replies []*Comment 68 68 } 69 69 70 70 func (it *CommentListItem) Participants() []syntax.DID { ··· 89 89 90 90 func (i *Issue) CommentList() []CommentListItem { 91 91 // Create a map to quickly find comments by their aturi 92 - toplevel := make(map[string]*CommentListItem) 93 - var replies []*IssueComment 92 + toplevel := make(map[syntax.ATURI]*CommentListItem) 93 + var replies []*Comment 94 94 95 95 // collect top level comments into the map 96 96 for _, comment := range i.Comments { 97 97 if comment.IsTopLevel() { 98 - toplevel[comment.AtUri().String()] = &CommentListItem{ 98 + toplevel[comment.AtUri()] = &CommentListItem{ 99 99 Self: &comment, 100 100 } 101 101 } else { ··· 104 104 } 105 105 106 106 for _, r := range replies { 107 - parentAt := *r.ReplyTo 108 - if parent, exists := toplevel[parentAt]; exists { 107 + if r.ReplyTo == nil { 108 + continue 109 + } 110 + if parent, exists := toplevel[syntax.ATURI(r.ReplyTo.Uri)]; exists { 109 111 parent.Replies = append(parent.Replies, r) 110 112 } 111 113 } ··· 116 118 } 117 119 118 120 // sort everything 119 - sortFunc := func(a, b *IssueComment) bool { 121 + sortFunc := func(a, b *Comment) bool { 120 122 return a.Created.Before(b.Created) 121 123 } 122 124 sort.Slice(listing, func(i, j int) bool { ··· 145 147 addParticipant(syntax.DID(i.Did)) 146 148 147 149 for _, c := range i.Comments { 148 - addParticipant(syntax.DID(c.Did)) 150 + addParticipant(c.Did) 149 151 } 150 152 151 153 return participants ··· 172 174 Open: true, // new issues are open by default 173 175 } 174 176 } 175 - 176 - type IssueComment struct { 177 - Id int64 178 - Did string 179 - Rkey string 180 - IssueAt string 181 - ReplyTo *string 182 - Body string 183 - Created time.Time 184 - Edited *time.Time 185 - Deleted *time.Time 186 - Mentions []syntax.DID 187 - References []syntax.ATURI 188 - } 189 - 190 - func (i *IssueComment) AtUri() syntax.ATURI { 191 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 192 - } 193 - 194 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 195 - mentions := make([]string, len(i.Mentions)) 196 - for i, did := range i.Mentions { 197 - mentions[i] = string(did) 198 - } 199 - references := make([]string, len(i.References)) 200 - for i, uri := range i.References { 201 - references[i] = string(uri) 202 - } 203 - return tangled.RepoIssueComment{ 204 - Body: i.Body, 205 - Issue: i.IssueAt, 206 - CreatedAt: i.Created.Format(time.RFC3339), 207 - ReplyTo: i.ReplyTo, 208 - Mentions: mentions, 209 - References: references, 210 - } 211 - } 212 - 213 - func (i *IssueComment) IsTopLevel() bool { 214 - return i.ReplyTo == nil 215 - } 216 - 217 - func (i *IssueComment) IsReply() bool { 218 - return i.ReplyTo != nil 219 - } 220 - 221 - func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 222 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 223 - if err != nil { 224 - created = time.Now() 225 - } 226 - 227 - ownerDid := did 228 - 229 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 230 - return nil, err 231 - } 232 - 233 - i := record 234 - mentions := make([]syntax.DID, len(record.Mentions)) 235 - for i, did := range record.Mentions { 236 - mentions[i] = syntax.DID(did) 237 - } 238 - references := make([]syntax.ATURI, len(record.References)) 239 - for i, uri := range i.References { 240 - references[i] = syntax.ATURI(uri) 241 - } 242 - 243 - comment := IssueComment{ 244 - Did: ownerDid, 245 - Rkey: rkey, 246 - Body: record.Body, 247 - IssueAt: record.Issue, 248 - ReplyTo: record.ReplyTo, 249 - Created: created, 250 - Mentions: mentions, 251 - References: references, 252 - } 253 - 254 - return &comment, nil 255 - }
+5 -5
appview/notify/db/db.go
··· 133 133 ) 134 134 } 135 135 136 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 136 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 137 137 l := log.FromContext(ctx) 138 138 139 - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 139 + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.Subject)) 140 140 if err != nil { 141 141 l.Error("failed to get issues", "err", err) 142 142 return 143 143 } 144 144 if len(issues) == 0 { 145 - l.Error("no issue found for", "err", comment.IssueAt) 145 + l.Error("no issue found for", "err", comment.Subject) 146 146 return 147 147 } 148 148 issue := issues[0] ··· 156 156 157 157 if comment.IsReply() { 158 158 // if this comment is a reply, then notify everybody in that thread 159 - parentAtUri := *comment.ReplyTo 159 + parent := *comment.ReplyTo 160 160 161 161 // find the parent thread, and add all DIDs from here to the recipient list 162 162 for _, t := range issue.CommentList() { 163 - if t.Self.AtUri().String() == parentAtUri { 163 + if t.Self.AtUri() == syntax.ATURI(parent.Uri) { 164 164 for _, p := range t.Participants() { 165 165 recipients.Insert(p) 166 166 }
+1 -1
appview/notify/logging/notifier.go
··· 51 51 l.inner.NewIssue(ctx, issue, mentions) 52 52 } 53 53 54 - func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 54 + func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 55 55 ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueComment")) 56 56 l.inner.NewIssueComment(ctx, comment, mentions) 57 57 }
+1 -1
appview/notify/merged_notifier.go
··· 54 54 m.fanout(func(n Notifier) { n.NewIssue(ctx, issue, mentions) }) 55 55 } 56 56 57 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 57 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 58 58 m.fanout(func(n Notifier) { n.NewIssueComment(ctx, comment, mentions) }) 59 59 } 60 60
+2 -2
appview/notify/notifier.go
··· 16 16 DeleteStar(ctx context.Context, star *models.Star) 17 17 18 18 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 19 - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 19 + NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) 20 20 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 21 21 DeleteIssue(ctx context.Context, issue *models.Issue) 22 22 ··· 55 55 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 56 56 57 57 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 58 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 58 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 59 59 } 60 60 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 61 61 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
+3 -3
appview/notify/posthog/notifier.go
··· 212 212 } 213 213 } 214 214 215 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 215 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 216 216 err := n.client.Enqueue(posthog.Capture{ 217 - DistinctId: comment.Did, 217 + DistinctId: comment.Did.String(), 218 218 Event: "new_issue_comment", 219 219 Properties: posthog.Properties{ 220 - "issue_at": comment.IssueAt, 220 + "issue_at": comment.Subject.Uri, 221 221 "mentions": mentions, 222 222 }, 223 223 })
+4 -4
appview/pages/pages.go
··· 1242 1242 LoggedInUser *oauth.MultiAccountUser 1243 1243 RepoInfo repoinfo.RepoInfo 1244 1244 Issue *models.Issue 1245 - Comment *models.IssueComment 1245 + Comment *models.Comment 1246 1246 } 1247 1247 1248 1248 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1253 1253 LoggedInUser *oauth.MultiAccountUser 1254 1254 RepoInfo repoinfo.RepoInfo 1255 1255 Issue *models.Issue 1256 - Comment *models.IssueComment 1256 + Comment *models.Comment 1257 1257 } 1258 1258 1259 1259 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1264 1264 LoggedInUser *oauth.MultiAccountUser 1265 1265 RepoInfo repoinfo.RepoInfo 1266 1266 Issue *models.Issue 1267 - Comment *models.IssueComment 1267 + Comment *models.Comment 1268 1268 } 1269 1269 1270 1270 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1275 1275 LoggedInUser *oauth.MultiAccountUser 1276 1276 RepoInfo repoinfo.RepoInfo 1277 1277 Issue *models.Issue 1278 - Comment *models.IssueComment 1278 + Comment *models.Comment 1279 1279 } 1280 1280 1281 1281 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+4 -4
appview/pages/templates/repo/issues/fragments/commentList.html
··· 15 15 "LoggedInUser" $root.LoggedInUser 16 16 "Issue" $root.Issue 17 17 "Comment" $comment.Self 18 - "VouchRelationship" (index $root.VouchRelationships (did $comment.Self.Did)) 18 + "VouchRelationship" (index $root.VouchRelationships $comment.Self.Did) 19 19 ) }} 20 20 21 21 <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> ··· 31 31 "LoggedInUser" $root.LoggedInUser 32 32 "Issue" $root.Issue 33 33 "Comment" $reply 34 - "VouchRelationship" (index $root.VouchRelationships (did $reply.Did)) 34 + "VouchRelationship" (index $root.VouchRelationships $reply.Did) 35 35 ) }} 36 36 </div> 37 37 {{ end }} ··· 44 44 {{ define "topLevelComment" }} 45 45 <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 46 46 <div class="flex-shrink-0"> 47 - {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1" .VouchRelationship) }} 47 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1" .VouchRelationship) }} 48 48 </div> 49 49 <div class="flex-1 min-w-0"> 50 50 {{ template "repo/issues/fragments/issueCommentHeader" . }} ··· 56 56 {{ define "replyComment" }} 57 57 <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 58 58 <div class="flex-shrink-0"> 59 - {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1" .VouchRelationship) }} 59 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1" .VouchRelationship) }} 60 60 </div> 61 61 <div class="flex-1 min-w-0"> 62 62 {{ template "repo/issues/fragments/issueCommentHeader" . }}
+1 -1
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 5 5 name="body" 6 6 class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 7 rows="5" 8 - autofocus>{{ .Comment.Body }}</textarea> 8 + autofocus>{{ .Comment.EditableBody }}</textarea> 9 9 10 10 {{ template "editActions" $ }} 11 11 </div>
+1 -1
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 1 {{ define "repo/issues/fragments/issueCommentBody" }} 2 2 <div id="comment-body-{{.Comment.Id}}"> 3 3 {{ if not .Comment.Deleted }} 4 - <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 4 + <div class="prose dark:prose-invert">{{ .Comment.Body.Text | markdown }}</div> 5 5 {{ else }} 6 6 <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 7 {{ end }}
+5 -3
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ $handle := resolve .Comment.Did }} 3 + {{ $handle := resolve .Comment.Did.String }} 4 4 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a> 5 5 {{ template "hats" $ }} 6 6 <span class="before:content-['·']"></span> 7 7 {{ template "timestamp" . }} 8 - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 8 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 9 9 {{ if and $isCommentOwner (not .Comment.Deleted) }} 10 - {{ template "editIssueComment" . }} 10 + {{ if not .Comment.IsLegacy }} 11 + {{ template "editIssueComment" . }} 12 + {{ end }} 11 13 {{ template "deleteIssueComment" . }} 12 14 {{ end }} 13 15 </div>
+10 -2
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 18 18 19 19 <input 20 20 type="text" 21 - id="reply-to" 22 - name="reply-to" 21 + id="reply-to-uri" 22 + name="reply-to-uri" 23 23 required 24 24 value="{{ .Comment.AtUri }}" 25 + class="hidden" 26 + /> 27 + <input 28 + type="text" 29 + id="reply-to-cid" 30 + name="reply-to-cid" 31 + required 32 + value="{{ .Comment.Cid }}" 25 33 class="hidden" 26 34 /> 27 35 {{ template "replyActions" . }}
+6
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 1 {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 2 <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .Comment.IsLegacy }} 4 + {{ if .LoggedInUser }} 5 + <span class="text-orange-500">Can't reply to legacy comment.</span> 6 + {{ end }} 7 + {{ else }} 3 8 {{ if .LoggedInUser }} 4 9 {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} 5 10 {{ end }} ··· 12 17 hx-swap="outerHTML" 13 18 > 14 19 </input> 20 + {{ end }} 15 21 </div> 16 22 {{ end }}
-27
appview/validator/issue.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 - "tangled.org/core/appview/db" 8 7 "tangled.org/core/appview/models" 9 - "tangled.org/core/orm" 10 8 ) 11 - 12 - func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 - // if comments have parents, only ingest ones that are 1 level deep 14 - if comment.ReplyTo != nil { 15 - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 - if err != nil { 17 - return fmt.Errorf("failed to fetch parent comment: %w", err) 18 - } 19 - if len(parents) != 1 { 20 - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 21 - } 22 - 23 - // depth check 24 - parent := parents[0] 25 - if parent.ReplyTo != nil { 26 - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 27 - } 28 - } 29 - 30 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 31 - return fmt.Errorf("body is empty after HTML sanitization") 32 - } 33 - 34 - return nil 35 - } 36 9 37 10 func (v *Validator) ValidateIssue(issue *models.Issue) error { 38 11 if issue.Title == "" {