Monorepo for Tangled tangled.org
768
fork

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>

+239 -485
+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_at = r.at_uri 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.at_uri = i.repo_at 360 349 where %s and %s`,
+9 -46
appview/ingester.go
··· 1115 1115 return nil 1116 1116 } 1117 1117 1118 + // ingestIssueComment ingests legacy sh.tangled.repo.issue.comment deletions 1118 1119 func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 1119 - did := e.Did 1120 - rkey := e.Commit.RKey 1121 - 1122 - var err error 1123 - 1124 - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 1120 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) 1125 1121 l.Info("ingesting record") 1126 1122 1127 - ddb, ok := i.Db.Execer.(*db.DB) 1128 - if !ok { 1129 - return fmt.Errorf("failed to index issue comment record, invalid db cast") 1130 - } 1131 - 1132 1123 switch e.Commit.Operation { 1133 1124 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1134 - raw := json.RawMessage(e.Commit.Record) 1135 - record := tangled.RepoIssueComment{} 1136 - err = json.Unmarshal(raw, &record) 1137 - if err != nil { 1138 - return fmt.Errorf("invalid record: %w", err) 1139 - } 1140 - 1141 - comment, err := models.IssueCommentFromRecord(did, rkey, record) 1142 - if err != nil { 1143 - return fmt.Errorf("failed to parse comment from record: %w", err) 1144 - } 1145 - 1146 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 1147 - return fmt.Errorf("failed to validate comment: %w", err) 1148 - } 1149 - 1150 - tx, err := ddb.Begin() 1151 - if err != nil { 1152 - return fmt.Errorf("failed to start transaction: %w", err) 1153 - } 1154 - defer tx.Rollback() 1155 - 1156 - _, err = db.AddIssueComment(tx, *comment) 1157 - if err != nil { 1158 - return fmt.Errorf("failed to create issue comment: %w", err) 1159 - } 1160 - 1161 - return tx.Commit() 1125 + // no-op. sh.tangled.repo.issue.comment is deprecated 1162 1126 1163 1127 case jmodels.CommitOperationDelete: 1164 - if err := db.DeleteIssueComments( 1165 - ddb, 1166 - orm.FilterEq("did", did), 1167 - orm.FilterEq("rkey", rkey), 1128 + if err := db.PurgeComments( 1129 + i.Db, 1130 + orm.FilterEq("did", e.Did), 1131 + orm.FilterEq("collection", e.Commit.Collection), 1132 + orm.FilterEq("rkey", e.Commit.RKey), 1168 1133 ); err != nil { 1169 - return fmt.Errorf("failed to delete issue comment record: %w", err) 1134 + return fmt.Errorf("failed to delete comment record: %w", err) 1170 1135 } 1171 - 1172 - return nil 1173 1136 } 1174 1137 1175 1138 return nil
+138 -81
appview/issues/issues.go
··· 13 13 "github.com/bluesky-social/indigo/atproto/atclient" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 17 "github.com/go-chi/chi/v5" 17 18 18 19 "tangled.org/core/api/tangled" ··· 406 407 407 408 body := r.FormValue("body") 408 409 if body == "" { 409 - rp.pages.Notice(w, "issue", "Body is required") 410 + rp.pages.Notice(w, "issue-comment", "Body is required") 411 + return 412 + } 413 + 414 + // TODO(boltless): normalize markdown body 415 + normalizedBody := body 416 + _, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 + 418 + markdownBody := tangled.MarkupMarkdown{ 419 + Text: normalizedBody, 420 + Original: &body, 421 + Blobs: nil, 422 + } 423 + 424 + // ingest CID of issue record on-demand. 425 + // TODO(boltless): appview should ingest CID of atproto records 426 + cid, err := func() (syntax.CID, error) { 427 + ident, err := rp.idResolver.ResolveIdent(r.Context(), issue.Did) 428 + if err != nil { 429 + return "", err 430 + } 431 + 432 + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 433 + out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 434 + if err != nil { 435 + return "", err 436 + } 437 + if out.Cid == nil { 438 + return "", fmt.Errorf("record CID is empty") 439 + } 440 + 441 + cid, err := syntax.ParseCID(*out.Cid) 442 + if err != nil { 443 + return "", err 444 + } 445 + 446 + return cid, nil 447 + }() 448 + if err != nil { 449 + rp.logger.Error("failed to backfill subject PR record", "err", err) 450 + rp.pages.Notice(w, "issue-comment", "failed to backfill subject record") 410 451 return 411 452 } 453 + issueStrongRef := comatproto.RepoStrongRef{ 454 + Uri: issue.AtUri().String(), 455 + Cid: cid.String(), 456 + } 412 457 413 - replyToUri := r.FormValue("reply-to") 414 - var replyTo *string 415 - if replyToUri != "" { 416 - replyTo = &replyToUri 458 + var replyTo *comatproto.RepoStrongRef 459 + replyToUriRaw := r.FormValue("reply-to-uri") 460 + replyToCidRaw := r.FormValue("reply-to-cid") 461 + if replyToUriRaw != "" && replyToCidRaw != "" { 462 + uri, err := syntax.ParseATURI(replyToUriRaw) 463 + if err != nil { 464 + rp.pages.Notice(w, "issue-comment", "reply-to-uri should be valid AT-URI") 465 + return 466 + } 467 + cid, err := syntax.ParseCID(replyToCidRaw) 468 + if err != nil { 469 + rp.pages.Notice(w, "issue-comment", "reply-to-cid should be valid CID") 470 + return 471 + } 472 + replyTo = &comatproto.RepoStrongRef{ 473 + Uri: uri.String(), 474 + Cid: cid.String(), 475 + } 417 476 } 418 477 419 478 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 420 479 421 - comment := models.IssueComment{ 422 - Did: user.Did, 423 - Rkey: tid.TID(), 424 - IssueAt: issue.AtUri().String(), 425 - ReplyTo: replyTo, 426 - Body: body, 427 - Created: time.Now(), 428 - Mentions: mentions, 429 - References: references, 480 + comment := models.Comment{ 481 + Did: syntax.DID(user.Did), 482 + Collection: tangled.FeedCommentNSID, 483 + Rkey: syntax.RecordKey(tid.TID()), 484 + 485 + Subject: issueStrongRef, 486 + Body: markdownBody, 487 + Created: time.Now(), 488 + ReplyTo: replyTo, 430 489 } 431 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 490 + if err = comment.Validate(); err != nil { 432 491 l.Error("failed to validate comment", "err", err) 433 492 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 434 493 return 435 494 } 436 - record := comment.AsRecord() 437 495 438 496 client, err := rp.oauth.AuthorizedClient(r) 439 497 if err != nil { ··· 443 501 } 444 502 445 503 // create a record first 446 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 447 - Collection: tangled.RepoIssueCommentNSID, 448 - Repo: comment.Did, 449 - Rkey: comment.Rkey, 450 - Record: &lexutil.LexiconTypeDecoder{ 451 - Val: &record, 452 - }, 504 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 505 + Collection: comment.Collection.String(), 506 + Repo: comment.Did.String(), 507 + Rkey: comment.Rkey.String(), 508 + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 453 509 }) 454 510 if err != nil { 455 511 l.Error("failed to create comment", "err", err) 456 512 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 457 513 return 458 514 } 459 - atUri := resp.Uri 460 - defer func() { 461 - if err := rollbackRecord(context.Background(), atUri, client); err != nil { 462 - l.Error("rollback failed", "err", err) 463 - } 464 - }() 515 + 516 + comment.Cid = syntax.CID(out.Cid) 465 517 466 518 tx, err := rp.db.Begin() 467 519 if err != nil { ··· 471 523 } 472 524 defer tx.Rollback() 473 525 474 - commentId, err := db.AddIssueComment(tx, comment) 526 + err = db.PutComment(tx, &comment, references) 475 527 if err != nil { 476 528 l.Error("failed to create comment", "err", err) 477 529 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 478 530 return 479 531 } 532 + 480 533 err = tx.Commit() 481 534 if err != nil { 482 535 l.Error("failed to commit transaction", "err", err) ··· 484 537 return 485 538 } 486 539 487 - // reset atUri to make rollback a no-op 488 - atUri = "" 489 - 490 - // notify about the new comment 491 - comment.Id = commentId 492 - 493 540 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 494 541 495 542 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 496 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 543 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 497 544 } 498 545 499 546 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 508 555 } 509 556 510 557 commentId := chi.URLParam(r, "commentId") 511 - comments, err := db.GetIssueComments( 558 + comments, err := db.GetComments( 512 559 rp.db, 513 560 orm.FilterEq("id", commentId), 514 561 ) ··· 544 591 } 545 592 546 593 commentId := chi.URLParam(r, "commentId") 547 - comments, err := db.GetIssueComments( 594 + comments, err := db.GetComments( 548 595 rp.db, 549 596 orm.FilterEq("id", commentId), 550 597 ) ··· 560 607 } 561 608 comment := comments[0] 562 609 563 - if comment.Did != user.Did { 610 + if comment.Did.String() != user.Did { 564 611 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 565 612 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 566 613 return ··· 576 623 }) 577 624 case http.MethodPost: 578 625 // extract form value 579 - newBody := r.FormValue("body") 626 + body := r.FormValue("body") 627 + if body == "" { 628 + rp.pages.Notice(w, "issue-comment", "Body is required") 629 + return 630 + } 631 + 632 + // TODO(boltless): normalize markdown body 633 + normalizedBody := body 634 + _, references := rp.mentionsResolver.Resolve(r.Context(), body) 635 + 636 + now := time.Now() 637 + newComment := comment 638 + newComment.Body = tangled.MarkupMarkdown{ 639 + Text: normalizedBody, 640 + Original: &body, 641 + Blobs: nil, 642 + } 643 + newComment.Edited = &now 644 + 580 645 client, err := rp.oauth.AuthorizedClient(r) 581 646 if err != nil { 582 647 l.Error("failed to get authorized client", "err", err) ··· 584 649 return 585 650 } 586 651 587 - now := time.Now() 588 - newComment := comment 589 - newComment.Body = newBody 590 - newComment.Edited = &now 591 - newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 652 + // update a record first 653 + exCid := comment.Cid.String() 654 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 655 + Collection: newComment.Collection.String(), 656 + Repo: newComment.Did.String(), 657 + Rkey: newComment.Rkey.String(), 658 + SwapRecord: &exCid, 659 + Record: &lexutil.LexiconTypeDecoder{ 660 + Val: newComment.AsRecord(), 661 + }, 662 + }) 663 + if err != nil { 664 + l.Error("failed to update comment", "err", err) 665 + rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 666 + return 667 + } 592 668 593 - record := newComment.AsRecord() 669 + newComment.Cid = syntax.CID(resp.Cid) 594 670 595 671 tx, err := rp.db.Begin() 596 672 if err != nil { ··· 600 676 } 601 677 defer tx.Rollback() 602 678 603 - _, err = db.AddIssueComment(tx, newComment) 679 + err = db.PutComment(tx, &newComment, references) 604 680 if err != nil { 605 681 l.Error("failed to perform update-description query", "err", err) 606 682 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 607 683 return 608 684 } 609 - tx.Commit() 610 - 611 - // rkey is optional, it was introduced later 612 - if newComment.Rkey != "" { 613 - // update the record on pds 614 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 615 - if err != nil { 616 - l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 617 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 618 - return 619 - } 620 - 621 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 622 - Collection: tangled.RepoIssueCommentNSID, 623 - Repo: user.Did, 624 - Rkey: newComment.Rkey, 625 - SwapRecord: ex.Cid, 626 - Record: &lexutil.LexiconTypeDecoder{ 627 - Val: &record, 628 - }, 629 - }) 630 - if err != nil { 631 - l.Error("failed to update record on PDS", "err", err) 632 - } 685 + err = tx.Commit() 686 + if err != nil { 687 + l.Error("failed to commit transaction", "err", err) 688 + rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 689 + return 633 690 } 634 691 635 692 // return new comment body with htmx ··· 654 711 } 655 712 656 713 commentId := chi.URLParam(r, "commentId") 657 - comments, err := db.GetIssueComments( 714 + comments, err := db.GetComments( 658 715 rp.db, 659 716 orm.FilterEq("id", commentId), 660 717 ) ··· 690 747 } 691 748 692 749 commentId := chi.URLParam(r, "commentId") 693 - comments, err := db.GetIssueComments( 750 + comments, err := db.GetComments( 694 751 rp.db, 695 752 orm.FilterEq("id", commentId), 696 753 ) ··· 726 783 } 727 784 728 785 commentId := chi.URLParam(r, "commentId") 729 - comments, err := db.GetIssueComments( 786 + comments, err := db.GetComments( 730 787 rp.db, 731 788 orm.FilterEq("id", commentId), 732 789 ) ··· 742 799 } 743 800 comment := comments[0] 744 801 745 - if comment.Did != user.Did { 802 + if comment.Did.String() != user.Did { 746 803 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 747 804 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 748 805 return ··· 755 812 756 813 // optimistic deletion 757 814 deleted := time.Now() 758 - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 815 + err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 759 816 if err != nil { 760 817 l.Error("failed to delete comment", "err", err) 761 818 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 771 828 return 772 829 } 773 830 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 774 - Collection: tangled.RepoIssueCommentNSID, 775 - Repo: user.Did, 776 - Rkey: comment.Rkey, 831 + Collection: comment.Collection.String(), 832 + Repo: comment.Did.String(), 833 + Rkey: comment.Rkey.String(), 777 834 }) 778 835 if err != nil { 779 836 l.Error("failed to delete from PDS", "err", err) ··· 781 838 } 782 839 783 840 // optimistic update for htmx 784 - comment.Body = "" 841 + comment.Body = tangled.MarkupMarkdown{} 785 842 comment.Deleted = &deleted 786 843 787 844 // htmx fragment of comment after deletion
+11
appview/models/comment.go
··· 60 60 } 61 61 } 62 62 63 + func (c *Comment) EditableBody() string { 64 + if c.Body.Original != nil { 65 + return *c.Body.Original 66 + } 67 + return c.Body.Text 68 + } 69 + 70 + func (c *Comment) IsLegacy() bool { 71 + return c.Collection != tangled.FeedCommentNSID 72 + } 73 + 63 74 func (c *Comment) IsTopLevel() bool { 64 75 return c.ReplyTo == nil 65 76 }
+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 } ··· 67 67 } 68 68 69 69 type CommentListItem struct { 70 - Self *IssueComment 71 - Replies []*IssueComment 70 + Self *Comment 71 + Replies []*Comment 72 72 } 73 73 74 74 func (it *CommentListItem) Participants() []syntax.DID { ··· 93 93 94 94 func (i *Issue) CommentList() []CommentListItem { 95 95 // Create a map to quickly find comments by their aturi 96 - toplevel := make(map[string]*CommentListItem) 97 - var replies []*IssueComment 96 + toplevel := make(map[syntax.ATURI]*CommentListItem) 97 + var replies []*Comment 98 98 99 99 // collect top level comments into the map 100 100 for _, comment := range i.Comments { 101 101 if comment.IsTopLevel() { 102 - toplevel[comment.AtUri().String()] = &CommentListItem{ 102 + toplevel[comment.AtUri()] = &CommentListItem{ 103 103 Self: &comment, 104 104 } 105 105 } else { ··· 108 108 } 109 109 110 110 for _, r := range replies { 111 - parentAt := *r.ReplyTo 112 - if parent, exists := toplevel[parentAt]; exists { 111 + if r.ReplyTo == nil { 112 + continue 113 + } 114 + if parent, exists := toplevel[syntax.ATURI(r.ReplyTo.Uri)]; exists { 113 115 parent.Replies = append(parent.Replies, r) 114 116 } 115 117 } ··· 120 122 } 121 123 122 124 // sort everything 123 - sortFunc := func(a, b *IssueComment) bool { 125 + sortFunc := func(a, b *Comment) bool { 124 126 return a.Created.Before(b.Created) 125 127 } 126 128 sort.Slice(listing, func(i, j int) bool { ··· 149 151 addParticipant(i.Did) 150 152 151 153 for _, c := range i.Comments { 152 - addParticipant(c.Did) 154 + addParticipant(c.Did.String()) 153 155 } 154 156 155 157 return participants ··· 181 183 Open: true, // new issues are open by default 182 184 } 183 185 } 184 - 185 - type IssueComment struct { 186 - Id int64 187 - Did string 188 - Rkey string 189 - IssueAt string 190 - ReplyTo *string 191 - Body string 192 - Created time.Time 193 - Edited *time.Time 194 - Deleted *time.Time 195 - Mentions []syntax.DID 196 - References []syntax.ATURI 197 - } 198 - 199 - func (i *IssueComment) AtUri() syntax.ATURI { 200 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 201 - } 202 - 203 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 204 - mentions := make([]string, len(i.Mentions)) 205 - for i, did := range i.Mentions { 206 - mentions[i] = string(did) 207 - } 208 - references := make([]string, len(i.References)) 209 - for i, uri := range i.References { 210 - references[i] = string(uri) 211 - } 212 - return tangled.RepoIssueComment{ 213 - Body: i.Body, 214 - Issue: i.IssueAt, 215 - CreatedAt: i.Created.Format(time.RFC3339), 216 - ReplyTo: i.ReplyTo, 217 - Mentions: mentions, 218 - References: references, 219 - } 220 - } 221 - 222 - func (i *IssueComment) IsTopLevel() bool { 223 - return i.ReplyTo == nil 224 - } 225 - 226 - func (i *IssueComment) IsReply() bool { 227 - return i.ReplyTo != nil 228 - } 229 - 230 - func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 231 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 232 - if err != nil { 233 - created = time.Now() 234 - } 235 - 236 - ownerDid := did 237 - 238 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 239 - return nil, err 240 - } 241 - 242 - i := record 243 - mentions := make([]syntax.DID, len(record.Mentions)) 244 - for i, did := range record.Mentions { 245 - mentions[i] = syntax.DID(did) 246 - } 247 - references := make([]syntax.ATURI, len(record.References)) 248 - for i, uri := range i.References { 249 - references[i] = syntax.ATURI(uri) 250 - } 251 - 252 - comment := IssueComment{ 253 - Did: ownerDid, 254 - Rkey: rkey, 255 - Body: record.Body, 256 - IssueAt: record.Issue, 257 - ReplyTo: record.ReplyTo, 258 - Created: created, 259 - Mentions: mentions, 260 - References: references, 261 - } 262 - 263 - return &comment, nil 264 - }
+5 -5
appview/notify/db/db.go
··· 132 132 ) 133 133 } 134 134 135 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 135 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 136 136 l := log.FromContext(ctx) 137 137 138 - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 138 + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.Subject)) 139 139 if err != nil { 140 140 l.Error("failed to get issues", "err", err) 141 141 return 142 142 } 143 143 if len(issues) == 0 { 144 - l.Error("no issue found for", "err", comment.IssueAt) 144 + l.Error("no issue found for", "err", comment.Subject) 145 145 return 146 146 } 147 147 issue := issues[0] ··· 155 155 156 156 if comment.IsReply() { 157 157 // if this comment is a reply, then notify everybody in that thread 158 - parentAtUri := *comment.ReplyTo 158 + parent := *comment.ReplyTo 159 159 160 160 // find the parent thread, and add all DIDs from here to the recipient list 161 161 for _, t := range issue.CommentList() { 162 - if t.Self.AtUri().String() == parentAtUri { 162 + if t.Self.AtUri() == syntax.ATURI(parent.Uri) { 163 163 for _, p := range t.Participants() { 164 164 recipients.Insert(p) 165 165 }
+1 -1
appview/notify/logging/notifier.go
··· 46 46 l.inner.NewIssue(ctx, issue, mentions) 47 47 } 48 48 49 - func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 49 + func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 50 50 ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueComment")) 51 51 l.inner.NewIssueComment(ctx, comment, mentions) 52 52 }
+1 -1
appview/notify/merged_notifier.go
··· 50 50 m.fanout(func(n Notifier) { n.NewIssue(ctx, issue, mentions) }) 51 51 } 52 52 53 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 53 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 54 54 m.fanout(func(n Notifier) { n.NewIssueComment(ctx, comment, mentions) }) 55 55 } 56 56
+2 -2
appview/notify/notifier.go
··· 15 15 DeleteStar(ctx context.Context, star *models.Star) 16 16 17 17 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 18 - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 + NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) 19 19 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 20 20 DeleteIssue(ctx context.Context, issue *models.Issue) 21 21 ··· 52 52 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 53 53 54 54 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 55 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 55 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 56 56 } 57 57 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 58 58 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
+3 -3
appview/notify/posthog/notifier.go
··· 190 190 } 191 191 } 192 192 193 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 193 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 194 194 err := n.client.Enqueue(posthog.Capture{ 195 - DistinctId: comment.Did, 195 + DistinctId: comment.Did.String(), 196 196 Event: "new_issue_comment", 197 197 Properties: posthog.Properties{ 198 - "issue_at": comment.IssueAt, 198 + "issue_at": comment.Subject.Uri, 199 199 "mentions": mentions, 200 200 }, 201 201 })
+4 -4
appview/pages/pages.go
··· 1181 1181 LoggedInUser *oauth.MultiAccountUser 1182 1182 RepoInfo repoinfo.RepoInfo 1183 1183 Issue *models.Issue 1184 - Comment *models.IssueComment 1184 + Comment *models.Comment 1185 1185 } 1186 1186 1187 1187 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1192 1192 LoggedInUser *oauth.MultiAccountUser 1193 1193 RepoInfo repoinfo.RepoInfo 1194 1194 Issue *models.Issue 1195 - Comment *models.IssueComment 1195 + Comment *models.Comment 1196 1196 } 1197 1197 1198 1198 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1203 1203 LoggedInUser *oauth.MultiAccountUser 1204 1204 RepoInfo repoinfo.RepoInfo 1205 1205 Issue *models.Issue 1206 - Comment *models.IssueComment 1206 + Comment *models.Comment 1207 1207 } 1208 1208 1209 1209 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1214 1214 LoggedInUser *oauth.MultiAccountUser 1215 1215 RepoInfo repoinfo.RepoInfo 1216 1216 Issue *models.Issue 1217 - Comment *models.IssueComment 1217 + Comment *models.Comment 1218 1218 } 1219 1219 1220 1220 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+2 -2
appview/pages/templates/repo/issues/fragments/commentList.html
··· 41 41 {{ define "topLevelComment" }} 42 42 <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 43 43 <div class="flex-shrink-0"> 44 - {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} 44 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }} 45 45 </div> 46 46 <div class="flex-1 min-w-0"> 47 47 {{ template "repo/issues/fragments/issueCommentHeader" . }} ··· 53 53 {{ define "replyComment" }} 54 54 <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 55 55 <div class="flex-shrink-0"> 56 - {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} 56 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }} 57 57 </div> 58 58 <div class="flex-1 min-w-0"> 59 59 {{ 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 == "" {