Monorepo for Tangled tangled.org

appview: introduce email notifications for @ mentions on issue/pr comments #393

closed opened by boltless.me targeting master from boltless.me/core: feat/mentions

Stacked on top of #392

Yes, I know we have stacked PRs, but I want to explicitly separate two sets of commits and review both on different places

This is MVC implementation of email notification.

Still lot of parts are missing, but this is a PR with most basic features.

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:xasnlahkri4ewmbuzly2rlc5/sh.tangled.repo.pull/3lvdu42ftpn22
+811 -276
Interdiff #4 โ†’ #5
appview/issues/issues.go

This patch was likely rebased, as context lines do not match.

+8 -3
appview/middleware/middleware.go
··· 9 9 "slices" 10 10 "strconv" 11 11 "strings" 12 + "time" 12 13 13 14 "github.com/bluesky-social/indigo/atproto/identity" 14 15 "github.com/go-chi/chi/v5" ··· 221 222 return 222 223 } 223 224 225 + ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 + ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 + ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 + ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 229 + ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 224 - ctx := context.WithValue(req.Context(), "repo", repo) 225 230 next.ServeHTTP(w, req.WithContext(ctx)) 226 231 }) 227 232 } ··· 246 251 return 247 252 } 248 253 254 + pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 249 - pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 250 255 if err != nil { 251 256 log.Println("failed to get pull and comments", err) 252 257 return ··· 287 292 return 288 293 } 289 294 295 + fullName := f.OwnerHandle() + "/" + f.RepoName 290 - fullName := f.OwnerHandle() + "/" + f.Name 291 296 292 297 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 293 298 if r.URL.Query().Get("go-get") == "1" {
appview/pulls/pulls.go

This patch was likely rebased, as context lines do not match.

+6 -6
appview/repo/artifact.go
··· 76 76 Artifact: uploadBlobResp.Blob, 77 77 CreatedAt: createdAt.Format(time.RFC3339), 78 78 Name: handler.Filename, 79 + Repo: f.RepoAt.String(), 79 - Repo: f.RepoAt().String(), 80 80 Tag: tag.Tag.Hash[:], 81 81 }, 82 82 }, ··· 100 100 artifact := db.Artifact{ 101 101 Did: user.Did, 102 102 Rkey: rkey, 103 + RepoAt: f.RepoAt, 103 - RepoAt: f.RepoAt(), 104 104 Tag: tag.Tag.Hash, 105 105 CreatedAt: createdAt, 106 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 155 156 156 artifacts, err := db.GetArtifact( 157 157 rp.db, 158 + db.FilterEq("repo_at", f.RepoAt), 158 - db.FilterEq("repo_at", f.RepoAt()), 159 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 160 db.FilterEq("name", filename), 161 161 ) ··· 197 197 198 198 artifacts, err := db.GetArtifact( 199 199 rp.db, 200 + db.FilterEq("repo_at", f.RepoAt), 200 - db.FilterEq("repo_at", f.RepoAt()), 201 201 db.FilterEq("tag", tag[:]), 202 202 db.FilterEq("name", filename), 203 203 ) ··· 239 239 defer tx.Rollback() 240 240 241 241 err = db.DeleteArtifact(tx, 242 + db.FilterEq("repo_at", f.RepoAt), 242 - db.FilterEq("repo_at", f.RepoAt()), 243 243 db.FilterEq("tag", artifact.Tag[:]), 244 244 db.FilterEq("name", filename), 245 245 ) ··· 270 270 return nil, err 271 271 } 272 272 273 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 273 - result, err := us.Tags(f.OwnerDid(), f.Name) 274 274 if err != nil { 275 275 log.Println("failed to reach knotserver", err) 276 276 return nil, err
+5 -5
appview/repo/index.go
··· 37 37 return 38 38 } 39 39 40 + result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 40 - result, err := us.Index(f.OwnerDid(), f.Name, ref) 41 41 if err != nil { 42 42 rp.pages.Error503(w) 43 43 log.Println("failed to reach knotserver", err) ··· 166 166 // first attempt to fetch from db 167 167 langs, err := db.GetRepoLanguages( 168 168 rp.db, 169 + db.FilterEq("repo_at", f.RepoAt), 169 - db.FilterEq("repo_at", f.RepoAt()), 170 170 db.FilterEq("ref", f.Ref), 171 171 ) 172 172 173 173 if err != nil || langs == nil { 174 174 // non-fatal, fetch langs from ks 175 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, f.Ref) 176 176 if err != nil { 177 177 return nil, err 178 178 } ··· 182 182 183 183 for l, s := range ls.Languages { 184 184 langs = append(langs, db.RepoLanguage{ 185 + RepoAt: f.RepoAt, 185 - RepoAt: f.RepoAt(), 186 186 Ref: f.Ref, 187 187 IsDefaultRef: isDefaultRef, 188 188 Language: l, ··· 279 279 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 280 281 281 var status types.AncestorCheckResponse 282 + forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, f.Ref, hiddenRef) 283 283 if err != nil { 284 284 log.Printf("failed to check if fork is ahead/behind: %s", err) 285 285 return nil, err
+52 -50
appview/repo/repo.go
··· 95 95 } else { 96 96 uri = "https" 97 97 } 98 + url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam)) 98 - url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 99 99 100 100 http.Redirect(w, r, url, http.StatusFound) 101 101 } ··· 123 123 return 124 124 } 125 125 126 + repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 126 - repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 127 127 if err != nil { 128 128 log.Println("failed to reach knotserver", err) 129 129 return 130 130 } 131 131 132 + tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 132 - tagResult, err := us.Tags(f.OwnerDid(), f.Name) 133 133 if err != nil { 134 134 log.Println("failed to reach knotserver", err) 135 135 return ··· 144 144 tagMap[hash] = append(tagMap[hash], tag.Name) 145 145 } 146 146 147 + branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 147 - branchResult, err := us.Branches(f.OwnerDid(), f.Name) 148 148 if err != nil { 149 149 log.Println("failed to reach knotserver", err) 150 150 return ··· 212 212 return 213 213 } 214 214 215 + repoAt := f.RepoAt 215 - repoAt := f.RepoAt() 216 216 rkey := repoAt.RecordKey().String() 217 217 if rkey == "" { 218 218 log.Println("invalid aturi for repo", err) ··· 262 262 Record: &lexutil.LexiconTypeDecoder{ 263 263 Val: &tangled.Repo{ 264 264 Knot: f.Knot, 265 + Name: f.RepoName, 265 - Name: f.Name, 266 266 Owner: user.Did, 267 + CreatedAt: f.CreatedAt, 267 - CreatedAt: f.Created.Format(time.RFC3339), 268 268 Description: &newDescription, 269 269 Spindle: &f.Spindle, 270 270 }, ··· 310 310 return 311 311 } 312 312 313 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 313 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 314 314 if err != nil { 315 315 log.Println("failed to reach knotserver", err) 316 316 return ··· 375 375 if !rp.config.Core.Dev { 376 376 protocol = "https" 377 377 } 378 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 378 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 379 379 if err != nil { 380 380 log.Println("failed to reach knotserver", err) 381 381 return ··· 405 405 user := rp.oauth.GetUser(r) 406 406 407 407 var breadcrumbs [][]string 408 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 408 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 409 409 if treePath != "" { 410 410 for idx, elem := range strings.Split(treePath, "/") { 411 411 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 436 436 return 437 437 } 438 438 439 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 439 - result, err := us.Tags(f.OwnerDid(), f.Name) 440 440 if err != nil { 441 441 log.Println("failed to reach knotserver", err) 442 442 return 443 443 } 444 444 445 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 445 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 446 446 if err != nil { 447 447 log.Println("failed grab artifacts", err) 448 448 return ··· 493 493 return 494 494 } 495 495 496 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 496 - result, err := us.Branches(f.OwnerDid(), f.Name) 497 497 if err != nil { 498 498 log.Println("failed to reach knotserver", err) 499 499 return ··· 522 522 if !rp.config.Core.Dev { 523 523 protocol = "https" 524 524 } 525 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 525 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 526 526 if err != nil { 527 527 log.Println("failed to reach knotserver", err) 528 528 return ··· 542 542 } 543 543 544 544 var breadcrumbs [][]string 545 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 545 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 546 546 if filePath != "" { 547 547 for idx, elem := range strings.Split(filePath, "/") { 548 548 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) ··· 575 575 576 576 // fetch the actual binary content like in RepoBlobRaw 577 577 578 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 578 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 579 579 contentSrc = blobURL 580 580 if !rp.config.Core.Dev { 581 581 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) ··· 612 612 if !rp.config.Core.Dev { 613 613 protocol = "https" 614 614 } 615 + blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) 615 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 616 616 resp, err := http.Get(blobURL) 617 617 if err != nil { 618 618 log.Println("failed to reach knotserver:", err) ··· 668 668 return 669 669 } 670 670 671 + repoAt := f.RepoAt 671 - repoAt := f.RepoAt() 672 672 rkey := repoAt.RecordKey().String() 673 673 if rkey == "" { 674 674 fail("Failed to resolve repo. Try again later", err) ··· 722 722 Record: &lexutil.LexiconTypeDecoder{ 723 723 Val: &tangled.Repo{ 724 724 Knot: f.Knot, 725 + Name: f.RepoName, 725 - Name: f.Name, 726 726 Owner: user.Did, 727 + CreatedAt: f.CreatedAt, 727 - CreatedAt: f.Created.Format(time.RFC3339), 728 728 Description: &f.Description, 729 729 Spindle: spindlePtr, 730 730 }, ··· 805 805 Record: &lexutil.LexiconTypeDecoder{ 806 806 Val: &tangled.RepoCollaborator{ 807 807 Subject: collaboratorIdent.DID.String(), 808 + Repo: string(f.RepoAt), 808 - Repo: string(f.RepoAt()), 809 809 CreatedAt: createdAt.Format(time.RFC3339), 810 810 }}, 811 811 }) ··· 830 830 return 831 831 } 832 832 833 + ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 833 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String()) 834 834 if err != nil { 835 835 fail("Knot was unreachable.", err) 836 836 return ··· 864 864 Did: syntax.DID(currentUser.Did), 865 865 Rkey: rkey, 866 866 SubjectDid: collaboratorIdent.DID, 867 + RepoAt: f.RepoAt, 867 - RepoAt: f.RepoAt(), 868 868 Created: createdAt, 869 869 }) 870 870 if err != nil { ··· 902 902 log.Println("failed to get authorized client", err) 903 903 return 904 904 } 905 + repoRkey := f.RepoAt.RecordKey().String() 905 906 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 906 907 Collection: tangled.RepoNSID, 907 908 Repo: user.Did, 909 + Rkey: repoRkey, 908 - Rkey: f.Rkey, 909 910 }) 910 911 if err != nil { 911 912 log.Printf("failed to delete record: %s", err) 912 913 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 913 914 return 914 915 } 916 + log.Println("removed repo record ", f.RepoAt.String()) 915 - log.Println("removed repo record ", f.RepoAt().String()) 916 917 917 918 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 918 919 if err != nil { ··· 926 927 return 927 928 } 928 929 930 + ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 929 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name) 930 931 if err != nil { 931 932 log.Printf("failed to make request to %s: %s", f.Knot, err) 932 933 return ··· 972 973 } 973 974 974 975 // remove repo from db 976 + err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 975 - err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 976 977 if err != nil { 977 978 rp.pages.Notice(w, "settings-delete", "Failed to update appview") 978 979 return ··· 1021 1022 return 1022 1023 } 1023 1024 1025 + ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1024 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch) 1025 1026 if err != nil { 1026 1027 log.Printf("failed to make request to %s: %s", f.Knot, err) 1027 1028 return ··· 1089 1090 r.Context(), 1090 1091 spindleClient, 1091 1092 &tangled.RepoAddSecret_Input{ 1093 + Repo: f.RepoAt.String(), 1092 - Repo: f.RepoAt().String(), 1093 1094 Key: key, 1094 1095 Value: value, 1095 1096 }, ··· 1107 1108 r.Context(), 1108 1109 spindleClient, 1109 1110 &tangled.RepoRemoveSecret_Input{ 1111 + Repo: f.RepoAt.String(), 1110 - Repo: f.RepoAt().String(), 1111 1112 Key: key, 1112 1113 }, 1113 1114 ) ··· 1169 1170 // return 1170 1171 // } 1171 1172 1173 + // result, err := us.Branches(f.OwnerDid(), f.RepoName) 1172 - // result, err := us.Branches(f.OwnerDid(), f.Name) 1173 1174 // if err != nil { 1174 1175 // log.Println("failed to reach knotserver", err) 1175 1176 // return ··· 1191 1192 // oauth.WithDev(rp.config.Core.Dev), 1192 1193 // ); err != nil { 1193 1194 // log.Println("failed to create spindle client", err) 1195 + // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1194 - // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1195 1196 // log.Println("failed to fetch secrets", err) 1196 1197 // } else { 1197 1198 // secrets = resp.Secrets ··· 1220 1221 return 1221 1222 } 1222 1223 1224 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 1223 - result, err := us.Branches(f.OwnerDid(), f.Name) 1224 1225 if err != nil { 1225 1226 log.Println("failed to reach knotserver", err) 1226 1227 return ··· 1274 1275 oauth.WithDev(rp.config.Core.Dev), 1275 1276 ); err != nil { 1276 1277 log.Println("failed to create spindle client", err) 1278 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil { 1277 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1278 1279 log.Println("failed to fetch secrets", err) 1279 1280 } else { 1280 1281 secrets = resp.Secrets ··· 1342 1343 } else { 1343 1344 uri = "https" 1344 1345 } 1346 + forkName := fmt.Sprintf("%s", f.RepoName) 1347 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1345 - forkName := fmt.Sprintf("%s", f.Name) 1346 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1347 1348 1348 1349 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1349 1350 if err != nil { ··· 1393 1394 return 1394 1395 } 1395 1396 1397 + forkName := fmt.Sprintf("%s", f.RepoName) 1396 - forkName := fmt.Sprintf("%s", f.Name) 1397 1398 1398 1399 // this check is *only* to see if the forked repo name already exists 1399 1400 // in the user's account. 1401 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1400 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1401 1402 if err != nil { 1402 1403 if errors.Is(err, sql.ErrNoRows) { 1403 1404 // no existing repo with this name found, we can use the name as is ··· 1428 1429 } else { 1429 1430 uri = "https" 1430 1431 } 1432 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1433 + sourceAt := f.RepoAt.String() 1431 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1432 - sourceAt := f.RepoAt().String() 1433 1434 1434 1435 rkey := tid.TID() 1435 1436 repo := &db.Repo{ ··· 1495 1496 1496 1497 1497 1498 1498 - } 1499 - log.Println("created repo record: ", atresp.Uri) 1500 1499 1500 + 1501 + 1502 + 1503 + 1504 + 1505 + 1501 - err = db.AddRepo(tx, repo) 1502 - if err != nil { 1503 - log.Println(err) 1504 1506 1505 1507 1506 1508 ··· 1548 1550 return 1549 1551 } 1550 1552 1553 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 1551 - result, err := us.Branches(f.OwnerDid(), f.Name) 1552 1554 if err != nil { 1553 1555 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1554 1556 log.Println("failed to reach knotserver", err) ··· 1578 1580 head = queryHead 1579 1581 } 1580 1582 1583 + tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1581 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1582 1584 if err != nil { 1583 1585 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1584 1586 log.Println("failed to reach knotserver", err) ··· 1640 1642 return 1641 1643 } 1642 1644 1645 + branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1643 - branches, err := us.Branches(f.OwnerDid(), f.Name) 1644 1646 if err != nil { 1645 1647 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1646 1648 log.Println("failed to reach knotserver", err) 1647 1649 return 1648 1650 } 1649 1651 1652 + tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1650 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1651 1653 if err != nil { 1652 1654 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1653 1655 log.Println("failed to reach knotserver", err) 1654 1656 return 1655 1657 } 1656 1658 1659 + formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1657 - formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1658 1660 if err != nil { 1659 1661 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1660 1662 log.Println("failed to compare", err)
+58 -25
appview/reporesolver/resolver.go
··· 12 12 "strings" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 15 16 securejoin "github.com/cyphar/filepath-securejoin" 16 17 "github.com/go-chi/chi/v5" 17 18 "tangled.sh/tangled.sh/core/appview/config" ··· 25 26 ) 26 27 27 28 type ResolvedRepo struct { 29 + Knot string 28 - db.Repo 29 30 OwnerId identity.Identity 31 + RepoName string 32 + RepoAt syntax.ATURI 33 + Description string 34 + Spindle string 35 + CreatedAt string 30 36 Ref string 31 37 CurrentDir string 32 38 ··· 45 51 } 46 52 47 53 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 + repoName := chi.URLParam(r, "repo") 55 + knot, ok := r.Context().Value("knot").(string) 48 - repo, ok := r.Context().Value("repo").(*db.Repo) 49 56 if !ok { 57 + log.Println("malformed middleware") 50 - log.Println("malformed middleware: `repo` not exist in context") 51 58 return nil, fmt.Errorf("malformed middleware") 52 59 } 53 60 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 56 63 return nil, fmt.Errorf("malformed middleware") 57 64 } 58 65 66 + repoAt, ok := r.Context().Value("repoAt").(string) 67 + if !ok { 68 + log.Println("malformed middleware") 69 + return nil, fmt.Errorf("malformed middleware") 70 + } 71 + 72 + parsedRepoAt, err := syntax.ParseATURI(repoAt) 73 + if err != nil { 74 + log.Println("malformed repo at-uri") 75 + return nil, fmt.Errorf("malformed middleware") 76 + } 77 + 59 78 ref := chi.URLParam(r, "ref") 60 79 61 80 if ref == "" { 81 + us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 62 - us, err := knotclient.NewUnsignedClient(repo.Knot, rr.config.Core.Dev) 63 82 if err != nil { 64 83 return nil, err 65 84 } 66 85 86 + defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 67 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repo.Name) 68 87 if err != nil { 69 88 return nil, err 70 89 } ··· 74 93 75 94 currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 76 95 96 + // pass through values from the middleware 97 + description, ok := r.Context().Value("repoDescription").(string) 98 + addedAt, ok := r.Context().Value("repoAddedAt").(string) 99 + spindle, ok := r.Context().Value("repoSpindle").(string) 100 + 77 101 return &ResolvedRepo{ 102 + Knot: knot, 103 + OwnerId: id, 104 + RepoName: repoName, 105 + RepoAt: parsedRepoAt, 106 + Description: description, 107 + CreatedAt: addedAt, 108 + Ref: ref, 109 + CurrentDir: currentDir, 110 + Spindle: spindle, 78 - Repo: *repo, 79 - OwnerId: id, 80 - Ref: ref, 81 - CurrentDir: currentDir, 82 111 83 112 rr: rr, 84 113 }, nil ··· 97 126 98 127 var p string 99 128 if handle != "" && !handle.IsInvalidHandle() { 129 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 100 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 101 130 } else { 131 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 102 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 103 132 } 104 133 105 134 return p 106 135 } 107 136 137 + func (f *ResolvedRepo) DidSlashRepo() string { 138 + p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 + return p 140 + } 141 + 108 142 func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 109 143 repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 110 144 if err != nil { ··· 153 187 // this function is a bit weird since it now returns RepoInfo from an entirely different 154 188 // package. we should refactor this or get rid of RepoInfo entirely. 155 189 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 156 - repoAt := f.RepoAt() 157 190 isStarred := false 158 191 if user != nil { 192 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 159 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 160 193 } 161 194 195 + starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 162 - starCount, err := db.GetStarCount(f.rr.execer, repoAt) 163 196 if err != nil { 197 + log.Println("failed to get star count for ", f.RepoAt) 164 - log.Println("failed to get star count for ", repoAt) 165 198 } 199 + issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 166 - issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 167 200 if err != nil { 201 + log.Println("failed to get issue count for ", f.RepoAt) 168 - log.Println("failed to get issue count for ", repoAt) 169 202 } 203 + pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 170 - pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 171 204 if err != nil { 205 + log.Println("failed to get issue count for ", f.RepoAt) 172 - log.Println("failed to get issue count for ", repoAt) 173 206 } 207 + source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 174 - source, err := db.GetRepoSource(f.rr.execer, repoAt) 175 208 if errors.Is(err, sql.ErrNoRows) { 176 209 source = "" 177 210 } else if err != nil { 211 + log.Println("failed to get repo source for ", f.RepoAt, err) 178 - log.Println("failed to get repo source for ", repoAt, err) 179 212 } 180 213 181 214 var sourceRepo *db.Repo ··· 200 233 if err != nil { 201 234 log.Printf("failed to create unsigned client for %s: %v", knot, err) 202 235 } else { 236 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 203 - result, err := us.Branches(f.OwnerDid(), f.Name) 204 237 if err != nil { 238 + log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 205 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.Name, err) 206 239 } 207 240 208 241 if len(result.Branches) == 0 { ··· 213 246 repoInfo := repoinfo.RepoInfo{ 214 247 OwnerDid: f.OwnerDid(), 215 248 OwnerHandle: f.OwnerHandle(), 249 + Name: f.RepoName, 250 + RepoAt: f.RepoAt, 216 - Name: f.Name, 217 - RepoAt: repoAt, 218 251 Description: f.Description, 219 252 Ref: f.Ref, 220 253 IsStarred: isStarred,
+10 -97
appview/db/repos.go
··· 16 16 17 17 18 18 19 + 20 + 21 + 22 + 23 + 24 + 19 - Knot string 20 - Rkey string 21 - Created time.Time 22 - Description string 23 - Spindle string 24 25 25 26 26 27 ··· 390 391 var description, spindle sql.NullString 391 392 392 393 row := e.QueryRow(` 394 + select did, name, knot, created, at_uri, description, spindle 393 - select did, name, knot, created, description, spindle, rkey 394 395 from repos 395 396 where did = ? and name = ? 396 397 `, ··· 399 400 ) 400 401 401 402 var createdAt string 403 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 402 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 403 404 return nil, err 404 405 } 405 406 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 420 421 var repo Repo 421 422 var nullableDescription sql.NullString 422 423 424 + row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 423 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 424 425 425 426 var createdAt string 427 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 426 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 427 428 return nil, err 428 429 } 429 430 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 442 443 _, err := e.Exec( 443 444 `insert into repos 444 445 (did, name, knot, rkey, at_uri, description, source) 445 - values (?, ?, ?, ?, ?, ?, ?)`, 446 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 447 - ) 448 - return err 449 - } 450 - 451 - 452 - 453 - 454 - 455 - 456 - 457 - 458 - 459 - 460 - 461 - 462 - 463 - 464 - 465 - 466 - var repos []Repo 467 - 468 - rows, err := e.Query( 469 - `select did, name, knot, rkey, description, created, source 470 - from repos 471 - where did = ? and source is not null and source != '' 472 - order by created desc`, 473 - 474 - 475 - 476 - 477 - 478 - 479 - 480 - 481 - 482 - 483 - var nullableDescription sql.NullString 484 - var nullableSource sql.NullString 485 - 486 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 487 - if err != nil { 488 - return nil, err 489 - } 490 - 491 - 492 - 493 - 494 - 495 - 496 - 497 - 498 - 499 - 500 - 501 - 502 - 503 - 504 - 505 - 506 - 507 - 508 - 509 - 510 - 511 - 512 - 513 - 514 - 515 - 516 - 517 - 518 - 519 - 520 - var nullableSource sql.NullString 521 - 522 - row := e.QueryRow( 523 - `select did, name, knot, rkey, description, created, source 524 - from repos 525 - where did = ? and name = ? and source is not null and source != ''`, 526 - did, name, 527 - ) 528 - 529 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 530 - if err != nil { 531 - return nil, err 532 - }
appview/state/state.go

This patch was likely rebased, as context lines do not match.

+3 -1
appview/db/star.go
··· 196 196 r.name, 197 197 r.knot, 198 198 r.rkey, 199 + r.created, 200 + r.at_uri 199 - r.created 200 201 from stars s 201 202 join repos r on s.repo_at = r.at_uri 202 203 `) ··· 221 222 &repo.Knot, 222 223 &repo.Rkey, 223 224 &repoCreatedAt, 225 + &repo.AtUri, 224 226 ); err != nil { 225 227 return nil, err 226 228 }
appview/pages/markup/markdown.go

This file has not been changed.

appview/pages/markup/markdown_at_extension.go

This file has not been changed.

appview/notify/merged_notifier.go

This file has not been changed.

appview/notify/notifier.go

This file has not been changed.

appview/posthog/notifier.go

This file has not been changed.

+47 -45
appview/email/notifier.go
··· 50 50 return repoOwnerSlashName, nil 51 51 } 52 52 53 - func (n *EmailNotifier) buildIssueEmail(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, did string) (Email, error) { 54 - // TODO: check email preferences 55 - email, err := db.GetPrimaryEmail(n.db, did) 56 - if err != nil { 57 - return Email{}, fmt.Errorf("db.GetPrimaryEmail: %w", err) 58 - } 53 + func (n *EmailNotifier) buildIssueEmail(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment) (Email, error) { 59 54 commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) 60 55 if err != nil || commentOwner.Handle.IsInvalidHandle() { 61 56 return Email{}, fmt.Errorf("resolve comment owner did: %w", err) 62 57 } 63 - // TODO: make this configurable 64 - baseUrl := "https://tangled.sh" 58 + baseUrl := n.Config.Core.AppviewHost 65 59 repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo) 66 60 if err != nil { 67 61 return Email{}, nil ··· 70 64 return Email{ 71 65 APIKey: n.Config.Resend.ApiKey, 72 66 From: n.Config.Resend.SentFrom, 73 - To: email.Address, 74 67 Subject: fmt.Sprintf("[%s] %s (issue#%d)", repoOwnerSlashName, issue.Title, issue.IssueId), 75 - Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you:</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url), 68 + Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url), 76 69 }, nil 77 70 } 78 71 79 - func (n *EmailNotifier) buildPullEmail(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, did string) (Email, error) { 80 - // TODO: check email preferences 81 - email, err := db.GetPrimaryEmail(n.db, did) 82 - if err != nil { 83 - return Email{}, fmt.Errorf("db.GetPrimaryEmail: %w", err) 84 - } 72 + func (n *EmailNotifier) buildPullEmail(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment) (Email, error) { 85 73 commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) 86 74 if err != nil || commentOwner.Handle.IsInvalidHandle() { 87 75 return Email{}, fmt.Errorf("resolve comment owner did: %w", err) ··· 90 78 if err != nil { 91 79 return Email{}, nil 92 80 } 93 - // TODO: make this configurable 94 - baseUrl := "https://tangled.sh" 81 + baseUrl := n.Config.Core.AppviewHost 95 82 url := fmt.Sprintf("%s/%s/pulls/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.PullId, comment.ID) 96 83 return Email{ 97 84 APIKey: n.Config.Resend.ApiKey, 98 85 From: n.Config.Resend.SentFrom, 99 - To: email.Address, 100 86 Subject: fmt.Sprintf("[%s] %s (pr#%d)", repoOwnerSlashName, pull.Title, pull.PullId), 101 - Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you:</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url), 87 + Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url), 102 88 }, nil 103 89 } 104 90 105 - func (n *EmailNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { 106 - resolvedIds := n.idResolver.ResolveIdents(ctx, mentions) 107 - handleDidMap := make(map[string]string) 108 - for _, identity := range resolvedIds { 109 - if !identity.Handle.IsInvalidHandle() { 110 - handleDidMap[identity.Handle.String()] = identity.DID.String() 111 - } 112 - } 113 - for _, handle := range mentions { 91 + func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string { 92 + recipients := []string{} 93 + for _, handle := range handles { 114 94 id, err := n.idResolver.ResolveIdent(ctx, handle) 115 - email, err := n.buildIssueEmail(ctx, repo, issue, comment, id.DID.String()) 116 95 if err != nil { 117 - log.Println("failed to create issue-email:", err) 96 + log.Println("failed to resolve handle:", err) 97 + continue 98 + } 99 + emailPreference, err := db.GetUserEmailPreference(n.db, id.DID.String()) 100 + if err != nil { 101 + log.Println("failed to get user email preference:", err) 102 + continue 103 + } 104 + if emailPreference == db.EmailNotifDisabled { 105 + continue 106 + } 107 + email, err := db.GetPrimaryEmail(n.db, id.DID.String()) 108 + if err != nil { 109 + log.Println("failed to get primary email:", err) 110 + continue 118 111 } 119 - SendEmail(email) 112 + recipients = append(recipients, email.Address) 113 + } 114 + return recipients 115 + } 116 + 117 + func (n *EmailNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { 118 + email, err := n.buildIssueEmail(ctx, repo, issue, comment) 119 + if err != nil { 120 + log.Println("failed to create issue-email:", err) 121 + return 122 + } 123 + // TODO: get issue-subscribed user DIDs and merge with mentioned users 124 + recipients := n.gatherRecipientEmails(ctx, mentions) 125 + log.Println("sending email to:", recipients) 126 + if err = SendEmail(email, recipients...); err != nil { 127 + log.Println("error sending email:", err) 120 128 } 121 129 } 122 130 123 131 func (n *EmailNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 124 - resolvedIds := n.idResolver.ResolveIdents(ctx, mentions) 125 - handleDidMap := make(map[string]string) 126 - for _, identity := range resolvedIds { 127 - if !identity.Handle.IsInvalidHandle() { 128 - handleDidMap[identity.Handle.String()] = identity.DID.String() 129 - } 132 + email, err := n.buildPullEmail(ctx, repo, pull, comment) 133 + if err != nil { 134 + log.Println("failed to create pull-email:", err) 130 135 } 131 - for _, handle := range mentions { 132 - id, err := n.idResolver.ResolveIdent(ctx, handle) 133 - email, err := n.buildPullEmail(ctx, repo, pull, comment, id.DID.String()) 134 - if err != nil { 135 - log.Println("failed to create issue-email:", err) 136 - } 137 - SendEmail(email) 136 + recipients := n.gatherRecipientEmails(ctx, mentions) 137 + log.Println("sending email to:", recipients) 138 + if err = SendEmail(email); err != nil { 139 + log.Println("error sending email:", err) 138 140 } 139 141 }
+248 -12
appview/db/issues.go
··· 94 94 return nil 95 95 } 96 96 97 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 98 - var issueAt string 99 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 100 - return issueAt, err 101 - } 102 - 103 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 104 - var ownerDid string 105 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 106 - return ownerDid, err 107 - } 108 - 109 97 func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 110 98 var issues []Issue 111 99 openValue := 0 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + 130 + 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + 316 + 317 + 318 + 319 + 320 + 321 + 322 + 323 + 324 + 325 + 326 + 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + var issue Issue 343 + var createdAt string 344 + issue.IssueId = issueId 345 + err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 346 + if err != nil { 347 + return nil, err
+3 -8
appview/email/email.go
··· 1 1 package email 2 2 3 3 import ( 4 - "fmt" 5 4 "net" 6 5 "regexp" 7 6 "strings" ··· 11 10 12 11 type Email struct { 13 12 From string 14 - To string 15 13 Subject string 16 14 Text string 17 15 Html string 18 16 APIKey string 19 17 } 20 18 21 - func SendEmail(email Email) error { 19 + func SendEmail(email Email, recipients ...string) error { 22 20 client := resend.NewClient(email.APIKey) 23 21 _, err := client.Emails.Send(&resend.SendEmailRequest{ 24 22 From: email.From, 25 - To: []string{email.To}, 23 + To: recipients, 26 24 Subject: email.Subject, 27 25 Text: email.Text, 28 26 Html: email.Html, 29 27 }) 30 - if err != nil { 31 - return fmt.Errorf("error sending email: %w", err) 32 - } 33 - return nil 28 + return err 34 29 } 35 30 36 31 func IsValidEmail(email string) bool {
+285 -13
appview/settings/settings.go
··· 42 42 43 43 44 44 45 + r.Delete("/", s.keys) 46 + }) 47 + 48 + r.Post("/email/preference", s.emailPreference) 49 + 50 + r.Route("/emails", func(r chi.Router) { 51 + r.Put("/", s.emails) 52 + r.Delete("/", s.emails) 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + log.Println(err) 66 + } 67 + 68 + preference, err := db.GetUserEmailPreference(s.Db, user.Did) 69 + 70 + emails, err := db.GetAllEmails(s.Db, user.Did) 71 + if err != nil { 72 + log.Println(err) 73 + } 74 + 75 + s.Pages.Settings(w, pages.SettingsParams{ 76 + LoggedInUser: user, 77 + PubKeys: pubKeys, 78 + Emails: emails, 79 + EmailNotifPreference: preference, 80 + }) 81 + } 82 + 83 + 84 + 85 + 86 + 87 + return email.Email{ 88 + APIKey: s.Config.Resend.ApiKey, 89 + From: s.Config.Resend.SentFrom, 90 + Subject: "Verify your Tangled email", 91 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 92 + ` + verifyURL, 93 + 94 + 95 + 96 + 97 + 98 + 99 + func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 100 + emailToSend := s.buildVerificationEmail(emailAddr, did, code) 101 + 102 + err := email.SendEmail(emailToSend, emailAddr) 103 + if err != nil { 104 + log.Printf("failed to send email: %s", err) 105 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 106 + return err 107 + } 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + 130 + 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 45 182 46 183 47 184 ··· 79 216 80 217 81 218 82 - return email.Email{ 83 - APIKey: s.Config.Resend.ApiKey, 84 - From: s.Config.Resend.SentFrom, 85 - To: emailAddr, 86 - Subject: "Verify your Tangled email", 87 - Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 88 - ` + verifyURL, 89 219 90 220 91 221 92 222 93 223 94 224 95 - func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 96 - emailToSend := s.buildVerificationEmail(emailAddr, did, code) 97 225 98 - err := email.SendEmail(emailToSend) 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + 316 + 317 + 318 + 319 + 320 + 321 + 322 + 323 + 324 + 325 + 326 + 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + s.Pages.HxLocation(w, "/settings") 347 + } 348 + 349 + func (s *Settings) emailPreference(w http.ResponseWriter, r *http.Request) { 350 + did := s.OAuth.GetDid(r) 351 + preferenceValue := r.FormValue("preference") 352 + var preference db.EmailPreference 353 + switch preferenceValue { 354 + case "enable": 355 + preference = db.EmailNotifEnabled 356 + case "mention": 357 + preference = db.EmailNotifMention 358 + case "disable": 359 + preference = db.EmailNotifDisabled 360 + default: 361 + log.Printf("Incorrect email preference value") 362 + return 363 + } 364 + 365 + err := db.UpdateSettingsEmailPreference(s.Db, did, preference) 99 366 if err != nil { 100 - log.Printf("sending email: %s", err) 101 - s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 102 - return err 367 + log.Printf("failed to update email preference setting: %v", err) 368 + s.Pages.Notice(w, "settings-keys", "Failed to update email preference. Try again later.") 369 + return 103 370 } 371 + } 372 + 373 + func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { 374 + switch r.Method { 375 + case http.MethodGet:
+1 -2
appview/signup/signup.go
··· 149 149 em := email.Email{ 150 150 APIKey: s.config.Resend.ApiKey, 151 151 From: s.config.Resend.SentFrom, 152 - To: emailId, 153 152 Subject: "Verify your Tangled account", 154 153 Text: `Copy and paste this code below to verify your account on Tangled. 155 154 ` + code, ··· 157 156 <p><code>` + code + `</code></p>`, 158 157 } 159 158 160 - err = email.SendEmail(em) 159 + err = email.SendEmail(em, emailId) 161 160 if err != nil { 162 161 s.l.Error("failed to send email", "error", err) 163 162 s.pages.Notice(w, noticeId, "Failed to send email.")
+7
appview/db/db.go
··· 678 678 return err 679 679 }) 680 680 681 + runMigration(conn, "add-email-notif-preference-to-profile", func(tx *sql.Tx) error { 682 + _, err := tx.Exec(` 683 + alter table profile add column email_notif_preference integer not null default 0 check (email_notif_preference in (0, 1, 2)); -- disable, metion, enable 684 + `) 685 + return err 686 + }) 687 + 681 688 return &DB{db}, nil 682 689 } 683 690
+22
appview/db/email.go
··· 299 299 _, err := e.Exec(query, code, did, email) 300 300 return err 301 301 } 302 + 303 + func GetUserEmailPreference(e Execer, did string) (EmailPreference, error) { 304 + var preference EmailPreference 305 + err := e.QueryRow(` 306 + select email_notif_preference 307 + from profile 308 + where did = ? 309 + `, did).Scan(&preference) 310 + if err != nil { 311 + return preference, err 312 + } 313 + return preference, nil 314 + } 315 + 316 + func UpdateSettingsEmailPreference(e Execer, did string, preference EmailPreference) error { 317 + _, err := e.Exec(` 318 + update profile 319 + set email_notif_preference = ? 320 + where did = ? 321 + `, preference, did) 322 + return err 323 + }
+32 -6
appview/db/profile.go
··· 183 183 Links [5]string 184 184 Stats [2]VanityStat 185 185 PinnedRepos [6]syntax.ATURI 186 + 187 + // settings 188 + EmailNotifPreference EmailPreference 189 + } 190 + 191 + type EmailPreference int 192 + 193 + const ( 194 + EmailNotifDisabled EmailPreference = iota 195 + EmailNotifMention 196 + EmailNotifEnabled 197 + ) 198 + 199 + func (p EmailPreference) IsDisabled() bool { 200 + return p == EmailNotifDisabled 201 + } 202 + 203 + func (p EmailPreference) IsMention() bool { 204 + return p == EmailNotifMention 205 + } 206 + 207 + func (p EmailPreference) IsEnabled() bool { 208 + return p == EmailNotifEnabled 186 209 } 187 210 188 211 func (p Profile) IsLinksEmpty() bool { ··· 280 303 did, 281 304 description, 282 305 include_bluesky, 283 - location 306 + location, 307 + email_notif_preference 284 308 ) 285 - values (?, ?, ?, ?)`, 309 + values (?, ?, ?, ?, ?)`, 286 310 profile.Did, 287 311 profile.Description, 288 312 includeBskyValue, 289 313 profile.Location, 314 + profile.EmailNotifPreference, 290 315 ) 291 316 292 317 if err != nil { ··· 367 392 did, 368 393 description, 369 394 include_bluesky, 370 - location 395 + location, 396 + email_notif_preference 371 397 from 372 398 profile 373 399 %s`, ··· 383 409 var profile Profile 384 410 var includeBluesky int 385 411 386 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 412 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 387 413 if err != nil { 388 414 return nil, err 389 415 } ··· 462 488 463 489 includeBluesky := 0 464 490 err := e.QueryRow( 465 - `select description, include_bluesky, location from profile where did = ?`, 491 + `select description, include_bluesky, location, email_notif_preference from profile where did = ?`, 466 492 did, 467 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 493 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 468 494 if err == sql.ErrNoRows { 469 495 profile := Profile{} 470 496 profile.Did = did
+4 -3
appview/pages/pages.go
··· 307 307 } 308 308 309 309 type SettingsParams struct { 310 - LoggedInUser *oauth.User 311 - PubKeys []db.PublicKey 312 - Emails []db.Email 310 + LoggedInUser *oauth.User 311 + PubKeys []db.PublicKey 312 + Emails []db.Email 313 + EmailNotifPreference db.EmailPreference 313 314 } 314 315 315 316 func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
+20
appview/pages/templates/settings.html
··· 93 93 {{ define "emails" }} 94 94 <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 95 <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 + <form 97 + hx-post="/settings/email/preference" 98 + hx-swap="none" 99 + hx-indicator="#email-preference-spinner" 100 + > 101 + <select 102 + name="preference" 103 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 104 + > 105 + <option value="enable" {{ if .EmailNotifPreference.IsEnabled }}selected{{ end }}>Enable Email Notifications</option> 106 + <option value="mention" {{ if .EmailNotifPreference.IsMention }}selected{{ end }}>Only on Mentions</option> 107 + <option value="disable" {{ if .EmailNotifPreference.IsDisabled }}selected{{ end }}>Disable Email Notifications</option> 108 + </select> 109 + <button type="submit" class="btn text-base"> 110 + <span>Save Preference</span> 111 + <span id="email-preference-spinner" class="group"> 112 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 113 + </span> 114 + </button> 115 + </form> 96 116 <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 117 <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 118 {{ range $index, $email := .Emails }}

History

7 rounds 4 comments
sign up or login to add to the discussion
7 commits
expand
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: db/issues: set IssueId on GetIssue
appview: email: support multiple recipients on SendEmail
appview: email: send email notification on mention
appview: settings: add email preference setting
appview: email: notify mentioned users on pull-comments
expand 1 comment
closed without merging
7 commits
expand
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: db/issues: set IssueId on GetIssue
appview: email: support multiple recipients on SendEmail
appview: email: send email notification on mention
appview: settings: add email preference setting
appview: email: notify mentioned users on pull-comments
expand 0 comments
7 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview: db/repos: remove AtUri from Repo
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: notify mentioned users on pull-comments
expand 0 comments
7 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview: db/repos: remove AtUri from Repo
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: notify mentioned users on pull-comments
expand 0 comments
7 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview: db/repos: remove AtUri from Repo
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: notify mentioned users on pull-comments
expand 0 comments
6 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: notify mentioned users on pull-comments
expand 1 comment

@oppi.li I addressed your last two points. Thank you again for the detailed review.

for regex part, I'm not sure if we can share part of regex and modify by our needs.

7 commits
expand
appview: reporesolver: pass entire Repo object through context
apview: replace all use of db.Repo.AtUri with db.Repo.RepoAt()
appview/pages: markup: add @ user-mention parsing in markdown
appview: add NewIssueComment event
appview: email: send email notification on mention
appview: email: add source to email notification
appview: email: notify mentioned users on pull-comments
expand 2 comments

i'd prefer if this was stacked! it would make it slightly easier to review:

  • in 1b6628ab: can we use the handle regex from the userutil module and customize it a bit for atRegex?
  • 18b248e5 and 9eebc110 seem to be modifying the same file, it would be nice to merge the two changes (running jj absorb when editing the second change should automatically solve it!).
  • similarly, dfa71348 seems to update the logic for issue-comment emails. would be nice to collapse or clean up the diffs.

this was only a first pass of reviews, but ill go through and have a closer look at the logic in the code, and give this a go locally. thanks again for working on this, its looking pretty cool already!

@oppi.li Thank you for the review!

and yeah I agree that I should have made this PR stacked.

Tell me if you think complete resubmission in form of stacked PR will be better.