Monorepo for Tangled tangled.org
854
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>

+241 -487
+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
··· 1244 1244 return nil 1245 1245 } 1246 1246 1247 + // ingestIssueComment ingests legacy sh.tangled.repo.issue.comment deletions 1247 1248 func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 1248 - did := e.Did 1249 - rkey := e.Commit.RKey 1250 - 1251 - var err error 1252 - 1253 - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 1249 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) 1254 1250 l.Info("ingesting record") 1255 1251 1256 - ddb, ok := i.Db.Execer.(*db.DB) 1257 - if !ok { 1258 - return fmt.Errorf("failed to index issue comment record, invalid db cast") 1259 - } 1260 - 1261 1252 switch e.Commit.Operation { 1262 1253 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1263 - raw := json.RawMessage(e.Commit.Record) 1264 - record := tangled.RepoIssueComment{} 1265 - err = json.Unmarshal(raw, &record) 1266 - if err != nil { 1267 - return fmt.Errorf("invalid record: %w", err) 1268 - } 1269 - 1270 - comment, err := models.IssueCommentFromRecord(did, rkey, record) 1271 - if err != nil { 1272 - return fmt.Errorf("failed to parse comment from record: %w", err) 1273 - } 1274 - 1275 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 1276 - return fmt.Errorf("failed to validate comment: %w", err) 1277 - } 1278 - 1279 - tx, err := ddb.Begin() 1280 - if err != nil { 1281 - return fmt.Errorf("failed to start transaction: %w", err) 1282 - } 1283 - defer tx.Rollback() 1284 - 1285 - _, err = db.AddIssueComment(tx, *comment) 1286 - if err != nil { 1287 - return fmt.Errorf("failed to create issue comment: %w", err) 1288 - } 1289 - 1290 - return tx.Commit() 1254 + // no-op. sh.tangled.repo.issue.comment is deprecated 1291 1255 1292 1256 case jmodels.CommitOperationDelete: 1293 - if err := db.DeleteIssueComments( 1294 - ddb, 1295 - orm.FilterEq("did", did), 1296 - orm.FilterEq("rkey", rkey), 1257 + if err := db.PurgeComments( 1258 + i.Db, 1259 + orm.FilterEq("did", e.Did), 1260 + orm.FilterEq("collection", e.Commit.Collection), 1261 + orm.FilterEq("rkey", e.Commit.RKey), 1297 1262 ); err != nil { 1298 - return fmt.Errorf("failed to delete issue comment record: %w", err) 1263 + return fmt.Errorf("failed to delete comment record: %w", err) 1299 1264 } 1300 - 1301 - return nil 1302 1265 } 1303 1266 1304 1267 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" ··· 419 420 420 421 body := r.FormValue("body") 421 422 if body == "" { 422 - rp.pages.Notice(w, "issue", "Body is required") 423 + rp.pages.Notice(w, "issue-comment", "Body is required") 424 + return 425 + } 426 + 427 + // TODO(boltless): normalize markdown body 428 + normalizedBody := body 429 + _, references := rp.mentionsResolver.Resolve(r.Context(), body) 430 + 431 + markdownBody := tangled.MarkupMarkdown{ 432 + Text: normalizedBody, 433 + Original: &body, 434 + Blobs: nil, 435 + } 436 + 437 + // ingest CID of issue record on-demand. 438 + // TODO(boltless): appview should ingest CID of atproto records 439 + cid, err := func() (syntax.CID, error) { 440 + ident, err := rp.idResolver.ResolveIdent(r.Context(), issue.Did) 441 + if err != nil { 442 + return "", err 443 + } 444 + 445 + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 446 + out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 447 + if err != nil { 448 + return "", err 449 + } 450 + if out.Cid == nil { 451 + return "", fmt.Errorf("record CID is empty") 452 + } 453 + 454 + cid, err := syntax.ParseCID(*out.Cid) 455 + if err != nil { 456 + return "", err 457 + } 458 + 459 + return cid, nil 460 + }() 461 + if err != nil { 462 + rp.logger.Error("failed to backfill subject PR record", "err", err) 463 + rp.pages.Notice(w, "issue-comment", "failed to backfill subject record") 423 464 return 424 465 } 466 + issueStrongRef := comatproto.RepoStrongRef{ 467 + Uri: issue.AtUri().String(), 468 + Cid: cid.String(), 469 + } 425 470 426 - replyToUri := r.FormValue("reply-to") 427 - var replyTo *string 428 - if replyToUri != "" { 429 - replyTo = &replyToUri 471 + var replyTo *comatproto.RepoStrongRef 472 + replyToUriRaw := r.FormValue("reply-to-uri") 473 + replyToCidRaw := r.FormValue("reply-to-cid") 474 + if replyToUriRaw != "" && replyToCidRaw != "" { 475 + uri, err := syntax.ParseATURI(replyToUriRaw) 476 + if err != nil { 477 + rp.pages.Notice(w, "issue-comment", "reply-to-uri should be valid AT-URI") 478 + return 479 + } 480 + cid, err := syntax.ParseCID(replyToCidRaw) 481 + if err != nil { 482 + rp.pages.Notice(w, "issue-comment", "reply-to-cid should be valid CID") 483 + return 484 + } 485 + replyTo = &comatproto.RepoStrongRef{ 486 + Uri: uri.String(), 487 + Cid: cid.String(), 488 + } 430 489 } 431 490 432 491 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 433 492 434 - comment := models.IssueComment{ 435 - Did: user.Did, 436 - Rkey: tid.TID(), 437 - IssueAt: issue.AtUri().String(), 438 - ReplyTo: replyTo, 439 - Body: body, 440 - Created: time.Now(), 441 - Mentions: mentions, 442 - References: references, 493 + comment := models.Comment{ 494 + Did: syntax.DID(user.Did), 495 + Collection: tangled.FeedCommentNSID, 496 + Rkey: syntax.RecordKey(tid.TID()), 497 + 498 + Subject: issueStrongRef, 499 + Body: markdownBody, 500 + Created: time.Now(), 501 + ReplyTo: replyTo, 443 502 } 444 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 503 + if err = comment.Validate(); err != nil { 445 504 l.Error("failed to validate comment", "err", err) 446 505 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 447 506 return 448 507 } 449 - record := comment.AsRecord() 450 508 451 509 client, err := rp.oauth.AuthorizedClient(r) 452 510 if err != nil { ··· 456 514 } 457 515 458 516 // create a record first 459 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 460 - Collection: tangled.RepoIssueCommentNSID, 461 - Repo: comment.Did, 462 - Rkey: comment.Rkey, 463 - Record: &lexutil.LexiconTypeDecoder{ 464 - Val: &record, 465 - }, 517 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 518 + Collection: comment.Collection.String(), 519 + Repo: comment.Did.String(), 520 + Rkey: comment.Rkey.String(), 521 + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 466 522 }) 467 523 if err != nil { 468 524 l.Error("failed to create comment", "err", err) 469 525 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 470 526 return 471 527 } 472 - atUri := resp.Uri 473 - defer func() { 474 - if err := rollbackRecord(context.Background(), atUri, client); err != nil { 475 - l.Error("rollback failed", "err", err) 476 - } 477 - }() 528 + 529 + comment.Cid = syntax.CID(out.Cid) 478 530 479 531 tx, err := rp.db.Begin() 480 532 if err != nil { ··· 484 536 } 485 537 defer tx.Rollback() 486 538 487 - commentId, err := db.AddIssueComment(tx, comment) 539 + err = db.PutComment(tx, &comment, references) 488 540 if err != nil { 489 541 l.Error("failed to create comment", "err", err) 490 542 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 491 543 return 492 544 } 545 + 493 546 err = tx.Commit() 494 547 if err != nil { 495 548 l.Error("failed to commit transaction", "err", err) ··· 497 550 return 498 551 } 499 552 500 - // reset atUri to make rollback a no-op 501 - atUri = "" 502 - 503 - // notify about the new comment 504 - comment.Id = commentId 505 - 506 553 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 507 554 508 555 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 509 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 556 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 510 557 } 511 558 512 559 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 521 568 } 522 569 523 570 commentId := chi.URLParam(r, "commentId") 524 - comments, err := db.GetIssueComments( 571 + comments, err := db.GetComments( 525 572 rp.db, 526 573 orm.FilterEq("id", commentId), 527 574 ) ··· 557 604 } 558 605 559 606 commentId := chi.URLParam(r, "commentId") 560 - comments, err := db.GetIssueComments( 607 + comments, err := db.GetComments( 561 608 rp.db, 562 609 orm.FilterEq("id", commentId), 563 610 ) ··· 573 620 } 574 621 comment := comments[0] 575 622 576 - if comment.Did != user.Did { 623 + if comment.Did.String() != user.Did { 577 624 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 578 625 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 579 626 return ··· 589 636 }) 590 637 case http.MethodPost: 591 638 // extract form value 592 - newBody := r.FormValue("body") 639 + body := r.FormValue("body") 640 + if body == "" { 641 + rp.pages.Notice(w, "issue-comment", "Body is required") 642 + return 643 + } 644 + 645 + // TODO(boltless): normalize markdown body 646 + normalizedBody := body 647 + _, references := rp.mentionsResolver.Resolve(r.Context(), body) 648 + 649 + now := time.Now() 650 + newComment := comment 651 + newComment.Body = tangled.MarkupMarkdown{ 652 + Text: normalizedBody, 653 + Original: &body, 654 + Blobs: nil, 655 + } 656 + newComment.Edited = &now 657 + 593 658 client, err := rp.oauth.AuthorizedClient(r) 594 659 if err != nil { 595 660 l.Error("failed to get authorized client", "err", err) ··· 597 662 return 598 663 } 599 664 600 - now := time.Now() 601 - newComment := comment 602 - newComment.Body = newBody 603 - newComment.Edited = &now 604 - newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 665 + // update a record first 666 + exCid := comment.Cid.String() 667 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 668 + Collection: newComment.Collection.String(), 669 + Repo: newComment.Did.String(), 670 + Rkey: newComment.Rkey.String(), 671 + SwapRecord: &exCid, 672 + Record: &lexutil.LexiconTypeDecoder{ 673 + Val: newComment.AsRecord(), 674 + }, 675 + }) 676 + if err != nil { 677 + l.Error("failed to update comment", "err", err) 678 + rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 679 + return 680 + } 605 681 606 - record := newComment.AsRecord() 682 + newComment.Cid = syntax.CID(resp.Cid) 607 683 608 684 tx, err := rp.db.Begin() 609 685 if err != nil { ··· 613 689 } 614 690 defer tx.Rollback() 615 691 616 - _, err = db.AddIssueComment(tx, newComment) 692 + err = db.PutComment(tx, &newComment, references) 617 693 if err != nil { 618 694 l.Error("failed to perform update-description query", "err", err) 619 695 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 620 696 return 621 697 } 622 - tx.Commit() 623 - 624 - // rkey is optional, it was introduced later 625 - if newComment.Rkey != "" { 626 - // update the record on pds 627 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 628 - if err != nil { 629 - l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 630 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 631 - return 632 - } 633 - 634 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 635 - Collection: tangled.RepoIssueCommentNSID, 636 - Repo: user.Did, 637 - Rkey: newComment.Rkey, 638 - SwapRecord: ex.Cid, 639 - Record: &lexutil.LexiconTypeDecoder{ 640 - Val: &record, 641 - }, 642 - }) 643 - if err != nil { 644 - l.Error("failed to update record on PDS", "err", err) 645 - } 698 + err = tx.Commit() 699 + if err != nil { 700 + l.Error("failed to commit transaction", "err", err) 701 + rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 702 + return 646 703 } 647 704 648 705 // return new comment body with htmx ··· 667 724 } 668 725 669 726 commentId := chi.URLParam(r, "commentId") 670 - comments, err := db.GetIssueComments( 727 + comments, err := db.GetComments( 671 728 rp.db, 672 729 orm.FilterEq("id", commentId), 673 730 ) ··· 703 760 } 704 761 705 762 commentId := chi.URLParam(r, "commentId") 706 - comments, err := db.GetIssueComments( 763 + comments, err := db.GetComments( 707 764 rp.db, 708 765 orm.FilterEq("id", commentId), 709 766 ) ··· 739 796 } 740 797 741 798 commentId := chi.URLParam(r, "commentId") 742 - comments, err := db.GetIssueComments( 799 + comments, err := db.GetComments( 743 800 rp.db, 744 801 orm.FilterEq("id", commentId), 745 802 ) ··· 755 812 } 756 813 comment := comments[0] 757 814 758 - if comment.Did != user.Did { 815 + if comment.Did.String() != user.Did { 759 816 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 760 817 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 761 818 return ··· 768 825 769 826 // optimistic deletion 770 827 deleted := time.Now() 771 - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 828 + err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 772 829 if err != nil { 773 830 l.Error("failed to delete comment", "err", err) 774 831 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 784 841 return 785 842 } 786 843 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 787 - Collection: tangled.RepoIssueCommentNSID, 788 - Repo: user.Did, 789 - Rkey: comment.Rkey, 844 + Collection: comment.Collection.String(), 845 + Repo: comment.Did.String(), 846 + Rkey: comment.Rkey.String(), 790 847 }) 791 848 if err != nil { 792 849 l.Error("failed to delete from PDS", "err", err) ··· 794 851 } 795 852 796 853 // optimistic update for htmx 797 - comment.Body = "" 854 + comment.Body = tangled.MarkupMarkdown{} 798 855 comment.Deleted = &deleted 799 856 800 857 // 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 } ··· 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(syntax.DID(i.Did)) 150 152 151 153 for _, c := range i.Comments { 152 - addParticipant(syntax.DID(c.Did)) 154 + addParticipant(c.Did) 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
··· 1240 1240 LoggedInUser *oauth.MultiAccountUser 1241 1241 RepoInfo repoinfo.RepoInfo 1242 1242 Issue *models.Issue 1243 - Comment *models.IssueComment 1243 + Comment *models.Comment 1244 1244 } 1245 1245 1246 1246 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1251 1251 LoggedInUser *oauth.MultiAccountUser 1252 1252 RepoInfo repoinfo.RepoInfo 1253 1253 Issue *models.Issue 1254 - Comment *models.IssueComment 1254 + Comment *models.Comment 1255 1255 } 1256 1256 1257 1257 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1262 1262 LoggedInUser *oauth.MultiAccountUser 1263 1263 RepoInfo repoinfo.RepoInfo 1264 1264 Issue *models.Issue 1265 - Comment *models.IssueComment 1265 + Comment *models.Comment 1266 1266 } 1267 1267 1268 1268 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1273 1273 LoggedInUser *oauth.MultiAccountUser 1274 1274 RepoInfo repoinfo.RepoInfo 1275 1275 Issue *models.Issue 1276 - Comment *models.IssueComment 1276 + Comment *models.Comment 1277 1277 } 1278 1278 1279 1279 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 == "" {