Monorepo for Tangled
0
fork

Configure Feed

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

Merge remote-tracking branch 'upstream/master'

Signed-off-by: Will Andrews <did:plc:dadhhalkfcq3gucaq25hjqon>

+1108 -188
+1
.gitignore
··· 6 6 appview/pages/static/* 7 7 result 8 8 !.gitkeep 9 + !appview/pages/static/topbar-search.js 9 10 out/ 10 11 node_modules/ 11 12 patches
+81 -1
api/tangled/cbor_gen.go
··· 2467 2467 } 2468 2468 2469 2469 cw := cbg.NewCborWriter(w) 2470 - fieldCount := 4 2470 + fieldCount := 5 2471 + 2472 + if t.Evidences == nil { 2473 + fieldCount-- 2474 + } 2471 2475 2472 2476 if t.Reason == nil { 2473 2477 fieldCount-- ··· 2573 2577 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2574 2578 return err 2575 2579 } 2580 + 2581 + // t.Evidences ([]string) (slice) 2582 + if t.Evidences != nil { 2583 + 2584 + if len("evidences") > 1000000 { 2585 + return xerrors.Errorf("Value in field \"evidences\" was too long") 2586 + } 2587 + 2588 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("evidences"))); err != nil { 2589 + return err 2590 + } 2591 + if _, err := cw.WriteString(string("evidences")); err != nil { 2592 + return err 2593 + } 2594 + 2595 + if len(t.Evidences) > 8192 { 2596 + return xerrors.Errorf("Slice value in field t.Evidences was too long") 2597 + } 2598 + 2599 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Evidences))); err != nil { 2600 + return err 2601 + } 2602 + for _, v := range t.Evidences { 2603 + if len(v) > 1000000 { 2604 + return xerrors.Errorf("Value in field v was too long") 2605 + } 2606 + 2607 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2608 + return err 2609 + } 2610 + if _, err := cw.WriteString(string(v)); err != nil { 2611 + return err 2612 + } 2613 + 2614 + } 2615 + } 2576 2616 return nil 2577 2617 } 2578 2618 ··· 2670 2710 } 2671 2711 2672 2712 t.CreatedAt = string(sval) 2713 + } 2714 + // t.Evidences ([]string) (slice) 2715 + case "evidences": 2716 + 2717 + maj, extra, err = cr.ReadHeader() 2718 + if err != nil { 2719 + return err 2720 + } 2721 + 2722 + if extra > 8192 { 2723 + return fmt.Errorf("t.Evidences: array too large (%d)", extra) 2724 + } 2725 + 2726 + if maj != cbg.MajArray { 2727 + return fmt.Errorf("expected cbor array") 2728 + } 2729 + 2730 + if extra > 0 { 2731 + t.Evidences = make([]string, extra) 2732 + } 2733 + 2734 + for i := 0; i < int(extra); i++ { 2735 + { 2736 + var maj byte 2737 + var extra uint64 2738 + var err error 2739 + _ = maj 2740 + _ = extra 2741 + _ = err 2742 + 2743 + { 2744 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2745 + if err != nil { 2746 + return err 2747 + } 2748 + 2749 + t.Evidences[i] = string(sval) 2750 + } 2751 + 2752 + } 2673 2753 } 2674 2754 2675 2755 default:
+2
api/tangled/graphvouch.go
··· 19 19 type GraphVouch struct { 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.graph.vouch" cborgen:"$type,const=sh.tangled.graph.vouch"` 21 21 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // evidences: Optional list of ATURIs serving as evidence for this vouch (ex. issues, PRs) 23 + Evidences []string `json:"evidences,omitempty" cborgen:"evidences,omitempty"` 22 24 // kind: Whether this user is being vouched for or denounced 23 25 Kind string `json:"kind" cborgen:"kind"` 24 26 // reason: The reason for this vouch/denouncement
+49 -5
appview/db/db.go
··· 651 651 foreign key (repo_at) references repos(at_uri) on delete cascade 652 652 ); 653 653 654 - create table if not exists migrations ( 655 - id integer primary key autoincrement, 656 - name text unique 657 - ); 658 - 659 654 create table if not exists punchcard_preferences ( 660 655 id integer primary key autoincrement, 661 656 user_did text not null unique, ··· 669 664 status text not null check (status in ('subscribed', 'dismissed')), 670 665 email text, 671 666 updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 667 + ); 668 + 669 + create table if not exists vouch_evidences ( 670 + id integer primary key autoincrement, 671 + vouch_id integer not null, 672 + at_uri text not null, 673 + unique(vouch_id, at_uri), 674 + foreign key (vouch_id) references vouches(id) on delete cascade 675 + ); 676 + 677 + create table if not exists vouch_skips ( 678 + did text not null, 679 + subject_did text not null, 680 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 681 + primary key (did, subject_did), 682 + check (did <> subject_did) 683 + ); 684 + 685 + 686 + create table if not exists migrations ( 687 + id integer primary key autoincrement, 688 + name text unique 672 689 ); 673 690 674 691 -- indexes for better performance ··· 1474 1491 `) 1475 1492 return err 1476 1493 }) 1494 + 1495 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1496 + orm.RunMigration(conn, logger, "add-id-to-vouches", func(tx *sql.Tx) error { 1497 + _, err := tx.Exec(` 1498 + create table vouches_new ( 1499 + id integer primary key autoincrement, 1500 + did text not null, 1501 + subject_did text not null, 1502 + cid text not null, 1503 + kind text not null default 'vouch', 1504 + reason text, 1505 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1506 + unique(did, subject_did), 1507 + check (did <> subject_did), 1508 + check (kind in ('vouch', 'denounce')) 1509 + ); 1510 + 1511 + insert into vouches_new (did, subject_did, cid, kind, reason, created_at) 1512 + select did, subject_did, cid, kind, reason, created_at 1513 + from vouches; 1514 + 1515 + drop table vouches; 1516 + alter table vouches_new rename to vouches; 1517 + `) 1518 + return err 1519 + }) 1520 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1477 1521 1478 1522 return &DB{ 1479 1523 db,
+97 -10
appview/db/vouch.go
··· 15 15 ) 16 16 17 17 func AddVouch(e Execer, vouch *models.Vouch) error { 18 - query := `insert or replace into vouches (did, subject_did, cid, kind, reason) values (?, ?, ?, ?, ?)` 19 - _, err := e.Exec(query, vouch.Did, vouch.SubjectDid, vouch.Cid.String(), vouch.Kind, vouch.Reason) 20 - return err 18 + // insert if not exists 19 + _, err := e.Exec( 20 + `insert or ignore into vouches (did, subject_did, cid, kind, reason) values (?, ?, ?, ?, ?)`, 21 + vouch.Did, vouch.SubjectDid, vouch.Cid.String(), vouch.Kind, vouch.Reason, 22 + ) 23 + if err != nil { 24 + return err 25 + } 26 + 27 + // then update 28 + _, err = e.Exec( 29 + `update vouches set cid = ?, kind = ?, reason = ? where did = ? and subject_did = ?`, 30 + vouch.Cid.String(), vouch.Kind, vouch.Reason, vouch.Did, vouch.SubjectDid, 31 + ) 32 + if err != nil { 33 + return err 34 + } 35 + 36 + // replace evidences: delete all existing, then insert new ones. 37 + _, err = e.Exec( 38 + `delete from vouch_evidences where vouch_id = (select id from vouches where did = ? and subject_did = ?)`, 39 + vouch.Did, vouch.SubjectDid, 40 + ) 41 + if err != nil { 42 + return err 43 + } 44 + for _, uri := range vouch.Evidences { 45 + _, err = e.Exec( 46 + `insert into vouch_evidences (vouch_id, at_uri) 47 + values ((select id from vouches where did = ? and subject_did = ?), ?)`, 48 + vouch.Did, vouch.SubjectDid, uri.String(), 49 + ) 50 + if err != nil { 51 + return err 52 + } 53 + } 54 + return nil 21 55 } 22 56 23 57 func GetVouch(e Execer, did, subjectDid string) (*models.Vouch, error) { ··· 101 135 return vouches, nil 102 136 } 103 137 138 + func GetVouchEvidences(e Execer, did, subjectDid string) ([]syntax.ATURI, error) { 139 + rows, err := e.Query( 140 + `select at_uri from vouch_evidences 141 + where vouch_id = (select id from vouches where did = ? and subject_did = ?) 142 + order by id asc`, 143 + did, subjectDid, 144 + ) 145 + if err != nil { 146 + return nil, err 147 + } 148 + defer rows.Close() 149 + 150 + var evidences []syntax.ATURI 151 + for rows.Next() { 152 + var uri string 153 + if err := rows.Scan(&uri); err != nil { 154 + log.Println("error scanning vouch evidence:", err) 155 + continue 156 + } 157 + evidences = append(evidences, syntax.ATURI(uri)) 158 + } 159 + return evidences, nil 160 + } 161 + 104 162 func DeleteVouch(e Execer, did, subjectDid string) error { 105 163 _, err := e.Exec(`delete from vouches where did = ? and subject_did = ?`, did, subjectDid) 106 164 return err ··· 118 176 } 119 177 120 178 query := fmt.Sprintf( 121 - `select did, subject_did, cid, kind, reason, created_at 122 - from vouches 179 + `select v.did, v.subject_did, v.cid, v.kind, v.reason, v.created_at, 180 + group_concat(ve.at_uri, '|') as evidences 181 + from vouches v 182 + left join vouch_evidences ve on ve.vouch_id = v.id 123 183 where ( 124 - subject_did = ? and did in (select subject_did from vouches where did = ? and kind = 'vouch') 184 + v.subject_did = ? and v.did in (select subject_did from vouches where did = ? and kind = 'vouch') 125 185 ) or ( 126 - did = ? and subject_did in (select subject_did from vouches where did = ? and kind = 'vouch') 186 + v.did = ? and v.subject_did in (select subject_did from vouches where did = ? and kind = 'vouch') 127 187 ) 128 - order by created_at desc 188 + group by v.did, v.subject_did 189 + order by v.created_at desc 129 190 %s`, 130 191 pageClause) 131 192 ··· 141 202 var cidStr string 142 203 var createdAt string 143 204 var reason sql.NullString 205 + var evidences sql.NullString 144 206 145 - if err := rows.Scan(&v.Did, &v.SubjectDid, &cidStr, &v.Kind, &reason, &createdAt); err != nil { 207 + if err := rows.Scan(&v.Did, &v.SubjectDid, &cidStr, &v.Kind, &reason, &createdAt, &evidences); err != nil { 146 208 log.Println("error scanning vouch:", err) 147 209 continue 148 210 } ··· 165 227 v.Reason = &reason.String 166 228 } 167 229 230 + if evidences.Valid && evidences.String != "" { 231 + for _, s := range strings.Split(evidences.String, "|") { 232 + v.Evidences = append(v.Evidences, syntax.ATURI(s)) 233 + } 234 + } 235 + 168 236 vouches = append(vouches, v) 169 237 } 170 238 return vouches, nil ··· 240 308 return batch[subjectDid], nil 241 309 } 242 310 311 + func IsVouchSkipped(e Execer, did, subjectDid string) (bool, error) { 312 + var exists bool 313 + err := e.QueryRow( 314 + `select exists(select 1 from vouch_skips where did = ? and subject_did = ?)`, 315 + did, subjectDid, 316 + ).Scan(&exists) 317 + return exists, err 318 + } 319 + 320 + func SkipVouchSuggestion(e Execer, did, subjectDid string) error { 321 + _, err := e.Exec( 322 + `insert or ignore into vouch_skips (did, subject_did) values (?, ?)`, 323 + did, subjectDid, 324 + ) 325 + return err 326 + } 327 + 243 328 // priority: 244 329 // 1. collaborator invites sent 245 330 // 2. knot member invites sent ··· 322 407 ) 323 408 where did not in ( 324 409 select subject_did from vouches where vouches.did = ? 410 + union 411 + select subject_did from vouch_skips where vouch_skips.did = ? 325 412 ) 326 413 group by did 327 414 order by min(priority) asc, max(created) desc ··· 337 424 did, did, // issue_comments 338 425 did, did, // follows 339 426 did, did, // stars 340 - did, // vouches exclusion 427 + did, did, // existing vouches + skips exclusion 341 428 limit, 342 429 } 343 430
+28 -1
appview/ingester.go
··· 260 260 return fmt.Errorf("invalid cid: %w", err) 261 261 } 262 262 263 - err = db.AddVouch(i.Db, &models.Vouch{ 263 + var evidences []syntax.ATURI 264 + for _, raw := range record.Evidences { 265 + uri, parseErr := syntax.ParseATURI(raw) 266 + if parseErr != nil { 267 + l.Warn("invalid evidence AT-URI, skipping", "uri", raw, "err", parseErr) 268 + continue 269 + } 270 + evidences = append(evidences, uri) 271 + } 272 + 273 + ddb, ok := i.Db.Execer.(*db.DB) 274 + if !ok { 275 + return fmt.Errorf("failed to ingest vouch record, invalid db cast") 276 + } 277 + 278 + tx, txErr := ddb.Begin() 279 + if txErr != nil { 280 + return fmt.Errorf("failed to start transaction: %w", txErr) 281 + } 282 + 283 + addErr := db.AddVouch(tx, &models.Vouch{ 264 284 Did: syntax.DID(did), 265 285 SubjectDid: subjectId.DID, 266 286 Cid: recordCid, 267 287 Kind: kind, 268 288 Reason: record.Reason, 289 + Evidences: evidences, 269 290 }) 291 + if addErr != nil { 292 + tx.Rollback() 293 + err = addErr 294 + } else { 295 + err = tx.Commit() 296 + } 270 297 271 298 case jmodels.CommitOperationDelete: 272 299 err = db.DeleteVouchByRkey(i.Db, did, e.Commit.RKey)
+1
appview/models/vouch.go
··· 38 38 Cid cid.Cid 39 39 Kind VouchKind 40 40 Reason *string 41 + Evidences []syntax.ATURI 41 42 CreatedAt time.Time 42 43 } 43 44
+27 -6
appview/pages/pages.go
··· 712 712 } 713 713 714 714 type ProfileVouchesParams struct { 715 - LoggedInUser *oauth.MultiAccountUser 716 - Vouches []models.Vouch 717 - Suggestions []models.VouchSuggestion 718 - Card *ProfileCard 719 - Page pagination.Page 720 - Active string 715 + LoggedInUser *oauth.MultiAccountUser 716 + Vouches []models.Vouch 717 + Suggestions []models.VouchSuggestion 718 + Card *ProfileCard 719 + Page pagination.Page 720 + Active string 721 + EvidencePulls map[syntax.ATURI]*models.Pull 722 + EvidenceIssues map[syntax.ATURI]*models.Issue 721 723 } 722 724 723 725 func (p *Pages) ProfileVouches(w io.Writer, params ProfileVouchesParams) error { ··· 1384 1386 1385 1387 LabelDefs map[string]*models.LabelDefinition 1386 1388 VouchRelationships map[syntax.DID]*models.VouchRelationship 1389 + VouchSkips map[syntax.DID]bool 1387 1390 } 1388 1391 1389 1392 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1672 1675 1673 1676 func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error { 1674 1677 return p.execute("search/search", w, params) 1678 + } 1679 + 1680 + type SearchQuickParams struct { 1681 + Repos []models.Repo 1682 + Query string 1683 + Total int 1684 + } 1685 + 1686 + func (p *Pages) SearchQuick(w io.Writer, params SearchQuickParams) error { 1687 + return p.executePlain("search/fragments/quick", w, params) 1688 + } 1689 + 1690 + func (p *Pages) SearchQuickMobile(w io.Writer, params SearchQuickParams) error { 1691 + tpl, err := p.parse("search/fragments/quick") 1692 + if err != nil { 1693 + return err 1694 + } 1695 + return tpl.ExecuteTemplate(w, "search/fragments/quickMobile", params) 1675 1696 } 1676 1697 1677 1698 func (p *Pages) Home(w io.Writer, params TimelineParams) error {
+245
appview/pages/static/topbar-search.js
··· 1 + (() => { 2 + if (window._navSearchReady) return; 3 + window._navSearchReady = true; 4 + 5 + const $ = (id) => document.getElementById(id); 6 + 7 + const submitFromInput = (input) => { 8 + const query = input.value.trim(); 9 + if (query) 10 + window.location.href = `/search?q=${encodeURIComponent(query)}`; 11 + }; 12 + 13 + // mobile-related code 14 + let savedScrollY = 0; 15 + let touchMoveHandler = null; 16 + 17 + const updateOverlayHeight = () => { 18 + const overlay = $("mobile-search-overlay"); 19 + if (!overlay) return; 20 + 21 + const layoutHeight = window.innerHeight; 22 + const visibleHeight = window.visualViewport?.height ?? layoutHeight; 23 + 24 + overlay.style.height = `${layoutHeight}px`; 25 + overlay.style.top = "0px"; 26 + 27 + const spacer = $("mobile-search-spacer"); 28 + if (spacer) 29 + spacer.style.height = `${Math.max(0, layoutHeight - visibleHeight)}px`; 30 + }; 31 + 32 + const openMobile = () => { 33 + const overlay = $("mobile-search-overlay"); 34 + if (!overlay || overlay.classList.contains("opacity-100")) return; 35 + 36 + overlay.classList.remove("opacity-0", "pointer-events-none"); 37 + overlay.classList.add("opacity-100", "pointer-events-auto"); 38 + overlay.setAttribute("aria-hidden", "false"); 39 + 40 + savedScrollY = window.scrollY; 41 + Object.assign(document.body.style, { 42 + position: "fixed", 43 + top: `-${savedScrollY}px`, 44 + width: "100%", 45 + }); 46 + updateOverlayHeight(); 47 + 48 + if (window.visualViewport) { 49 + window.visualViewport.addEventListener( 50 + "resize", 51 + updateOverlayHeight, 52 + ); 53 + } 54 + 55 + $("mobile-search-input")?.focus({ preventScroll: true }); 56 + 57 + const results = $("mobile-search-results"); 58 + if (results && !touchMoveHandler) { 59 + touchMoveHandler = (e) => e.preventDefault(); 60 + results.addEventListener("touchmove", touchMoveHandler, { 61 + passive: false, 62 + }); 63 + } 64 + }; 65 + 66 + const closeMobile = () => { 67 + const overlay = $("mobile-search-overlay"); 68 + if (!overlay) return; 69 + 70 + overlay.classList.remove("opacity-100", "pointer-events-auto"); 71 + overlay.classList.add("opacity-0", "pointer-events-none"); 72 + overlay.setAttribute("aria-hidden", "true"); 73 + overlay.style.height = ""; 74 + overlay.style.top = ""; 75 + 76 + const spacer = $("mobile-search-spacer"); 77 + if (spacer) { 78 + spacer.style.height = ""; 79 + spacer.classList.add("hidden"); 80 + } 81 + 82 + if (window.visualViewport) { 83 + window.visualViewport.removeEventListener( 84 + "resize", 85 + updateOverlayHeight, 86 + ); 87 + } 88 + 89 + Object.assign(document.body.style, { 90 + position: "", 91 + top: "", 92 + width: "", 93 + }); 94 + window.scrollTo(0, savedScrollY); 95 + 96 + const input = $("mobile-search-input"); 97 + if (input) { 98 + input.value = ""; 99 + input.blur(); 100 + } 101 + 102 + $("mobile-search-results")?.replaceChildren(); 103 + }; 104 + 105 + // desktop-related things 106 + const clearDesktop = () => { 107 + $("topbar-search-results")?.replaceChildren(); 108 + 109 + const box = $("topbar-search-box"); 110 + box?.classList.add("rounded"); 111 + box?.classList.remove("rounded-t"); 112 + }; 113 + 114 + // events 115 + document.addEventListener("click", ({ target }) => { 116 + // mobile: open/close overlay via data-action buttons 117 + const action = target 118 + .closest("[data-action]") 119 + ?.getAttribute("data-action"); 120 + if (action === "open-mobile-search") { 121 + openMobile(); 122 + return; 123 + } 124 + if (action === "close-mobile-search") { 125 + closeMobile(); 126 + return; 127 + } 128 + 129 + // desktop: clicking outside the search container clears results 130 + const container = $("topbar-search-container"); 131 + if (container && !container.contains(target)) clearDesktop(); 132 + }); 133 + 134 + // desktop: defer so a click on a result fires before results are cleared 135 + document.addEventListener("focusout", ({ target, relatedTarget }) => { 136 + const container = $("topbar-search-container"); 137 + if (container?.contains(target) && !container.contains(relatedTarget)) { 138 + setTimeout(clearDesktop, 0); 139 + } 140 + }); 141 + 142 + document.addEventListener("htmx:afterSwap", ({ detail: { target } }) => { 143 + if (!target) return; 144 + 145 + // desktop: toggle rounded corners based on whether results are open 146 + if (target.id === "topbar-search-results") { 147 + const box = $("topbar-search-box"); 148 + const open = target.children.length > 0; 149 + box?.classList.toggle("rounded", !open); 150 + box?.classList.toggle("rounded-t", open); 151 + return; 152 + } 153 + 154 + // mobile: restore touch listener and show spacer when results arrive 155 + if (target.id === "mobile-search-results") { 156 + if (touchMoveHandler) { 157 + target.removeEventListener("touchmove", touchMoveHandler); 158 + touchMoveHandler = null; 159 + } 160 + 161 + const hasResults = !!target.querySelector("[data-results-footer]"); 162 + $("mobile-search-spacer")?.classList.toggle("hidden", !hasResults); 163 + } 164 + }); 165 + 166 + document.addEventListener("keydown", (e) => { 167 + const { key, metaKey, ctrlKey } = e; 168 + const input = $("topbar-search-input"); 169 + const results = $("topbar-search-results"); 170 + const mobileOverlay = $("mobile-search-overlay"); 171 + const mobileInput = $("mobile-search-input"); 172 + const active = document.activeElement; 173 + 174 + // desktop: ⌘K / Ctrl+K focuses the search input 175 + if ((metaKey || ctrlKey) && key === "k") { 176 + e.preventDefault(); 177 + input?.focus(); 178 + input?.select(); 179 + return; 180 + } 181 + 182 + if (key === "Enter") { 183 + if (active === input) { 184 + e.preventDefault(); 185 + submitFromInput(input); 186 + return; 187 + } // desktop 188 + if (active === mobileInput) { 189 + e.preventDefault(); 190 + submitFromInput(mobileInput); 191 + return; 192 + } // mobile 193 + } 194 + 195 + if (key === "Escape") { 196 + // mobile: close the overlay 197 + if ( 198 + mobileOverlay && 199 + !mobileOverlay.classList.contains("opacity-0") 200 + ) { 201 + e.preventDefault(); 202 + closeMobile(); 203 + return; 204 + } 205 + // desktop: clear results and blur 206 + if (input) { 207 + const links = results 208 + ? [...results.querySelectorAll("[data-nav-result]")] 209 + : []; 210 + if (active === input || links.includes(active)) { 211 + e.preventDefault(); 212 + clearDesktop(); 213 + input.blur(); 214 + } 215 + } 216 + } 217 + 218 + // desktop: arrow key navigation through results 219 + if (!input || !results) return; 220 + 221 + const links = [...results.querySelectorAll("[data-nav-result]")]; 222 + const inputFocused = active === input; 223 + const focusedIndex = links.indexOf(active); 224 + 225 + if (key === "ArrowDown") { 226 + if (inputFocused && links.length) { 227 + e.preventDefault(); 228 + links[0].focus(); 229 + } else if (focusedIndex >= 0 && focusedIndex < links.length - 1) { 230 + e.preventDefault(); 231 + links[focusedIndex + 1].focus(); 232 + } 233 + } 234 + 235 + if (key === "ArrowUp") { 236 + if (focusedIndex === 0) { 237 + e.preventDefault(); 238 + input.focus(); 239 + } else if (focusedIndex > 0) { 240 + e.preventDefault(); 241 + links[focusedIndex - 1].focus(); 242 + } 243 + } 244 + }); 245 + })();
+4 -3
appview/pages/templates/layouts/base.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/> 7 7 <meta name="description" content="The next-generation social coding platform."/> 8 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 9 9 ··· 23 23 <script defer src="/static/htmx.min.js"></script> 24 24 <script defer src="/static/htmx-ext-ws.min.js"></script> 25 25 <script defer src="/static/actor-typeahead.js" type="module"></script> 26 + <script defer src="/static/topbar-search.js"></script> 26 27 27 28 <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 28 29 <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> ··· 68 69 </head> 69 70 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200 {{ block "bodyClasses" . }} {{ end }}"> 70 71 {{ block "topbarLayout" . }} 71 - <header class="w-full col-span-full md:col-span-1 md:col-start-2 drop-shadow-sm dark:text-white bg-white dark:bg-gray-800" style="z-index: 20;"> 72 + <header class="w-full col-span-full md:col-span-1 md:col-start-2 shadow-sm dark:text-white bg-white dark:bg-gray-800 pt-[env(safe-area-inset-top)]" style="z-index: 20;"> 72 73 73 74 {{ if .LoggedInUser }} 74 75 <div id="upgrade-banner" ··· 100 101 {{ end }} 101 102 102 103 {{ block "footerLayout" . }} 103 - <footer class="mt-12"> 104 + <footer class="mt-12 pb-[env(safe-area-inset-bottom)]"> 104 105 {{ template "layouts/fragments/footer" . }} 105 106 </footer> 106 107 {{ end }}
+90 -6
appview/pages/templates/layouts/fragments/topbar.html
··· 9 9 10 10 <div id="right-items" class="flex items-center gap-4"> 11 11 {{ with .LoggedInUser }} 12 - {{ block "newButton" . }} {{ end }} 13 - {{ template "notifications/fragments/bell" }} 14 - {{ block "searchButton" . }} {{ end }} 15 - {{ block "profileDropdown" . }} {{ end }} 12 + <div class="flex items-center order-1 md:order-3">{{ block "newButton" . }} {{ end }}</div> 13 + <div class="flex items-center order-2 md:order-4">{{ template "notifications/fragments/bell" }}</div> 14 + <div class="flex items-center order-3 md:order-1">{{ block "search" . }} {{ end }}</div> 15 + <div class="hidden md:block md:order-2 w-px h-6 bg-gray-200 dark:bg-gray-700 self-center"></div> 16 + <div class="flex items-center order-4 md:order-5">{{ block "profileDropdown" . }} {{ end }}</div> 16 17 {{ else }} 17 18 <a href="/login">login</a> 18 19 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 43 44 </details> 44 45 {{ end }} 45 46 46 - {{ define "searchButton" }} 47 - <a href="/search">{{ i "search" "size-5 text-gray-500 dark:text-gray-400" }}</a> 47 + {{ define "search" }} 48 + <div class="relative hidden md:block" id="topbar-search-container"> 49 + <div id="topbar-search-box" class="relative flex items-center gap-2 px-2 pr-1.5 py-4 max-h-[30px] md:w-80 lg:w-96 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-900 focus-within:z-[51] before:content-[''] before:absolute before:inset-0 before:rounded-md before:invisible focus-within:before:visible before:ring-2 before:ring-transparent before:ring-offset-transparent before:ring-offset-1 before:pointer-events-none focus-within:before:ring-gray-300 dark:focus-within:before:ring-gray-600"> 50 + {{ i "search" "size-4 text-gray-400" }} 51 + <input 52 + type="text" 53 + id="topbar-search-input" 54 + name="q" 55 + placeholder="search..." 56 + autocomplete="off" 57 + class="flex-1 border-none bg-transparent p-0 focus:outline-none focus:[box-shadow:none] text-sm text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 peer" 58 + hx-get="/search/quick" 59 + hx-trigger="input changed delay:10ms" 60 + hx-target="#topbar-search-results" 61 + hx-swap="innerHTML" 62 + hx-indicator="#topbar-search-indicator" 63 + /> 64 + <kbd class="pointer-events-none flex items-center text-xs border border-gray-200 dark:border-gray-600 rounded border-b-2 px-1 font-sans leading-5 text-gray-400 dark:text-gray-500 peer-focus:hidden peer-[:not(:placeholder-shown)]:hidden">⌘K</kbd> 65 + <span id="topbar-search-indicator" class="shrink-0"> 66 + {{ i "loader-circle" "size-4 text-gray-400 animate-spin" }} 67 + </span> 68 + <button 69 + type="button" 70 + class="hidden peer-[:not(:placeholder-shown)]:block text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 71 + onclick="const i=document.getElementById('topbar-search-input');i.value='';htmx.trigger(i,'input');i.focus()" 72 + >{{ i "x" "size-4" }}</button> 73 + </div> 74 + <div id="topbar-search-results" class="absolute w-full z-50"></div> 75 + </div> 76 + 77 + <button id="mobile-search-btn" class="md:hidden" type="button" data-action="open-mobile-search"> 78 + {{ i "search" "size-5 text-gray-500 dark:text-gray-400" }} 79 + </button> 80 + 81 + <div id="mobile-search-overlay" 82 + class="md:hidden fixed inset-x-0 top-0 h-dvh z-50 83 + bg-white dark:bg-gray-900 84 + flex flex-col 85 + opacity-0 pointer-events-none 86 + transition-opacity duration-150 87 + pt-[env(safe-area-inset-top)]" 88 + aria-hidden="true"> 89 + <div class="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700 shrink-0"> 90 + {{ i "search" "size-4 text-gray-400 shrink-0" }} 91 + <input 92 + type="text" 93 + id="mobile-search-input" 94 + name="q" 95 + placeholder="search..." 96 + autocomplete="off" 97 + inputmode="search" 98 + class="flex-1 min-w-0 border-none bg-transparent p-0 focus:outline-none focus:[box-shadow:none] text-[16px] text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 peer" 99 + hx-get="/search/quick/mobile" 100 + hx-trigger="input changed delay:10ms" 101 + hx-target="#mobile-search-results" 102 + hx-swap="innerHTML" 103 + hx-indicator="#mobile-search-indicator" 104 + /> 105 + <span id="mobile-search-indicator" class="shrink-0"> 106 + {{ i "loader-circle" "size-4 text-gray-400 animate-spin" }} 107 + </span> 108 + <button type="button" class="p-1 -mr-1 hit-area hit-area-4" data-action="close-mobile-search"> 109 + {{ i "x" "size-5 text-gray-400" }} 110 + </button> 111 + </div> 112 + <div id="mobile-search-results" class="flex-1 flex flex-col overflow-hidden pb-[env(safe-area-inset-bottom)]"></div> 113 + <div id="mobile-search-spacer" class="bg-gray-50 dark:bg-gray-800 hidden"></div> 114 + </div> 115 + <style> 116 + #topbar-search-indicator, 117 + #mobile-search-indicator { 118 + display: none; 119 + opacity: 0; 120 + transition-property: opacity, display; 121 + transition-duration: 150ms, 150ms; 122 + transition-timing-function: ease-out; 123 + transition-behavior: allow-discrete; 124 + } 125 + #topbar-search-indicator.htmx-request, 126 + #mobile-search-indicator.htmx-request { 127 + display: block; 128 + opacity: 1; 129 + transition-duration: 0s, 0s; 130 + } 131 + </style> 48 132 {{ end }} 49 133 50 134 {{ define "profileDropdown" }}
+1 -1
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 39 39 {{ i "x" "w-4 h-4" }} 40 40 cancel 41 41 </a> 42 - <button type="submit" class="btnkcreate flex items-center gap-2"> 42 + <button type="submit" class="btn-create flex items-center gap-2"> 43 43 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 44 44 {{ if eq .Action "edit" }} 45 45 {{ i "pencil" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
+38
appview/pages/templates/repo/pulls/fragments/pullVouchNudge.html
··· 1 + {{ define "repo/pulls/fragments/pullVouchNudge" }} 2 + {{ $owner := .Pull.OwnerDid }} 3 + {{ $vr := index .VouchRelationships (did $owner) }} 4 + {{ $skipped := index .VouchSkips (did $owner) }} 5 + 6 + {{ if and 7 + .LoggedInUser 8 + (ne .LoggedInUser.Did $owner) 9 + (or .Pull.State.IsOpen .Pull.State.IsMerged) 10 + (or (not $vr) $vr.IsEmpty) 11 + (not $skipped) }} 12 + 13 + <div class="border border-gray-200 dark:border-gray-700 rounded px-2 pl-4 py-2 flex items-center gap-4 text-sm text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"> 14 + <span class="flex-1 flex items-center gap-2 flex-wrap"> 15 + {{ i "shield-question-mark" "size-4" }} 16 + Would you like to vouch for 17 + <a href="/{{ resolve $owner }}" class="inline-flex items-center gap-1 font-medium text-gray-900 dark:text-white hover:underline"> 18 + <img src="{{ tinyAvatar $owner }}" class="rounded-full size-4 inline" /> 19 + {{ resolve $owner }} 20 + </a> 21 + for their contribution? 22 + </span> 23 + <button 24 + hx-post="/vouch/skip?subject={{ $owner }}" 25 + hx-trigger="click" 26 + hx-disabled-elt="this" 27 + title="Skip suggestion" 28 + class="group shrink-0 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 p-0.5" 29 + > 30 + <span class="group-[.htmx-request]:hidden">dismiss</span> 31 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:block" }} 32 + </button> 33 + <div class="shrink-0"> 34 + {{ template "user/fragments/vouch" (dict "VouchRelationship" $vr "Evidences" (list .Pull.AtUri.String) "PopoverId" (printf "vouch-nudge-modal-%s" (normalizeForHtmlId $owner))) }} 35 + </div> 36 + </div> 37 + {{ end }} 38 + {{ end }}
+6 -3
appview/pages/templates/repo/pulls/pull.html
··· 80 80 81 81 {{ define "repoContentLayout" }} 82 82 <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 83 - <section class="bg-white col-span-1 md:col-span-8 dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 84 - {{ block "repoContent" . }}{{ end }} 85 - </section> 83 + <div class="col-span-1 md:col-span-8 flex flex-col gap-4"> 84 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 85 + {{ block "repoContent" . }}{{ end }} 86 + </section> 87 + {{ template "repo/pulls/fragments/pullVouchNudge" . }} 88 + </div> 86 89 <div class="flex flex-col gap-6 col-span-1 md:col-span-2"> 87 90 {{ template "repo/fragments/labelPanel" 88 91 (dict "RepoInfo" $.RepoInfo
+45
appview/pages/templates/search/fragments/quick.html
··· 1 + {{define "search/fragments/quick"}} 2 + {{- if .Repos -}} 3 + <div class="bg-white dark:bg-gray-900 border border-t-0 border-gray-200 dark:border-gray-700 rounded-b-md shadow-xl overflow-hidden"> 4 + <div class="max-h-[70vh] overflow-y-auto overscroll-contain divide-y divide-gray-100 dark:divide-gray-700"> 5 + {{ template "search/fragments/quickPage" . }} 6 + </div> 7 + {{ template "search/fragments/quickFooter" . }} 8 + </div> 9 + {{- else if .Query -}} 10 + <div class="bg-white dark:bg-gray-900 border border-t-0 border-gray-200 dark:border-gray-700 rounded-b-md shadow-xl overflow-hidden px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"> 11 + no results for "{{ .Query }}" 12 + </div> 13 + {{- end -}} 14 + {{end}} 15 + 16 + {{define "search/fragments/quickMobile"}} 17 + {{- if .Repos -}} 18 + <div class="flex flex-col flex-1 min-h-0 overscroll-contain"> 19 + <div class="flex-1 overflow-y-auto overscroll-contain [&>*+*]:border-t [&>*+*]:border-gray-200 dark:[&>*+*]:border-gray-700"> 20 + {{ template "search/fragments/quickPage" . }} 21 + </div> 22 + {{ template "search/fragments/quickFooter" . }} 23 + </div> 24 + {{- else if .Query -}} 25 + <div class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"> 26 + no results for "{{ .Query }}" 27 + </div> 28 + {{- end -}} 29 + {{end}} 30 + 31 + {{define "search/fragments/quickPage"}} 32 + {{- range .Repos -}} 33 + {{ template "user/fragments/repoCard" (list $ . true false (dict) true) }} 34 + {{- end -}} 35 + {{end}} 36 + 37 + {{define "search/fragments/quickFooter"}} 38 + <div data-results-footer class="bg-gray-50 dark:bg-gray-800 px-4 py-2 flex justify-between items-center text-sm text-gray-600 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700"> 39 + <span>showing {{ len .Repos }} of {{ .Total }}</span> 40 + <a href="/search?q={{ urlquery .Query }}" 41 + class="flex items-center gap-1 hover:text-gray-600 dark:hover:text-gray-300 no-underline hover:no-underline"> 42 + all results {{ i "arrow-right" "size-4" }} 43 + </a> 44 + </div> 45 + {{end}}
+1 -1
appview/pages/templates/timeline/fragments/vouchSuggestions.html
··· 19 19 <span class="text-xs text-gray-500 dark:text-gray-400 truncate" title="{{ .Reason }}">{{ .Reason }}</span> 20 20 </div> 21 21 <div class="shrink-0"> 22 - {{ template "user/fragments/vouch" . }} 22 + {{ template "user/fragments/vouch" (dict "VouchRelationship" .VouchRelationship) }} 23 23 </div> 24 24 </div> 25 25 {{ end }}
+21
appview/pages/templates/user/fragments/issueEvent.html
··· 1 + {{ define "user/fragments/issueEvent" }} 2 + {{ $repoOwner := resolve .Repo.Did }} 3 + {{ $repoUrl := printf "%s/%s" $repoOwner .Repo.Name }} 4 + <div class="flex items-center gap-2 text-gray-600 dark:text-gray-300 overflow-hidden"> 5 + {{ if .Open }} 6 + <span class="text-green-600 dark:text-green-500 shrink-0"> 7 + {{ i "circle-dot" "w-4 h-4" }} 8 + </span> 9 + {{ else }} 10 + <span class="text-gray-500 dark:text-gray-400 shrink-0"> 11 + {{ i "ban" "w-4 h-4" }} 12 + </span> 13 + {{ end }} 14 + <span class="shrink-0 text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 15 + <span class="truncate"> 16 + <a href="/{{ $repoUrl }}/issues/{{ .IssueId }}" class="no-underline hover:underline">{{- .Title -}}</a> 17 + on 18 + <a href="/{{ $repoUrl }}" class="no-underline hover:underline">{{ $repoUrl }}</a> 19 + </span> 20 + </div> 21 + {{ end }}
+8 -2
appview/pages/templates/user/fragments/picLink.html
··· 7 7 {{ $vr = index . 2 }} 8 8 {{ end }} 9 9 <div class="relative inline-block" 10 + {{ if $vr }} 10 11 onmouseenter="(function(el){ 11 12 clearTimeout(el._pht); 12 13 el._pht = setTimeout(function(){ ··· 21 22 var modal=document.getElementById('vouch-modal-'+p.dataset.vouchModalId); 22 23 if(modal&&modal.matches(':popover-open'))return; 23 24 p.classList.add('hidden'); 24 - })(this)"> 25 + })(this)" 26 + {{ end }}> 25 27 <a href="/{{ $handle }}" title="{{ $handle }}" 28 + {{ if $vr }} 26 29 hx-get="/profile/popover?did={{ $did }}" 27 30 hx-trigger="mouseenter once" 28 31 hx-target="next [data-profile-popover-content]" 29 - hx-swap="innerHTML"> 32 + hx-swap="innerHTML" 33 + {{ end }}> 30 34 <img 31 35 src="{{ tinyAvatar $did }}" 32 36 alt="" ··· 55 59 </button> 56 60 {{ end }} 57 61 {{ end }} 62 + {{ if $vr }} 58 63 <div data-profile-popover 59 64 data-vouch-modal-id="{{ normalizeForHtmlId $did }}" 60 65 class="hidden z-[9999] pt-1 w-80" ··· 66 71 </div> 67 72 </div> 68 73 </div> 74 + {{ end }} 69 75 </div> 70 76 {{ if $vr }} 71 77 {{ if ne $did $vr.ViewerDid }}
+1 -1
appview/pages/templates/user/fragments/profileCard.html
··· 109 109 </div> 110 110 111 111 {{ if ne .FollowStatus.String "IsSelf" }} 112 - {{ template "user/fragments/vouch" . }} 112 + {{ template "user/fragments/vouch" (dict "VouchRelationship" .VouchRelationship) }} 113 113 114 114 {{ if .VouchRelationship }} 115 115 {{ if .VouchRelationship.IndirectVouches }}
+2 -2
appview/pages/templates/user/fragments/profilePopover.html
··· 39 39 {{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }} 40 40 <div class="flex items-center gap-2"> 41 41 {{ template "user/fragments/follow" . }} 42 - {{ template "user/fragments/vouchButton" . }} 42 + {{ template "user/fragments/vouchButton" (dict "VouchRelationship" .VouchRelationship) }} 43 43 </div> 44 44 45 45 {{ if .VouchRelationship }} ··· 54 54 55 55 {{/* render the vouch modal outside the popover content div so the popover api places it in the top layer correctly */}} 56 56 {{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }} 57 - {{ template "user/fragments/vouchPopover" . }} 57 + {{ template "user/fragments/vouchPopover" (dict "VouchRelationship" .VouchRelationship) }} 58 58 {{ end }} 59 59 {{ end }}
+25
appview/pages/templates/user/fragments/pullEvent.html
··· 1 + {{ define "user/fragments/pullEvent" }} 2 + {{ $repoOwner := resolve .Repo.Did }} 3 + {{ $repoUrl := printf "%s/%s" $repoOwner .Repo.Name }} 4 + <div class="flex items-center gap-2 text-gray-600 dark:text-gray-300 overflow-hidden"> 5 + {{ if .State.IsOpen }} 6 + <span class="text-green-600 dark:text-green-500 shrink-0"> 7 + {{ i "git-pull-request" "w-4 h-4" }} 8 + </span> 9 + {{ else if .State.IsMerged }} 10 + <span class="text-purple-600 dark:text-purple-500 shrink-0"> 11 + {{ i "git-merge" "w-4 h-4" }} 12 + </span> 13 + {{ else }} 14 + <span class="text-gray-600 dark:text-gray-300 shrink-0"> 15 + {{ i "git-pull-request-closed" "w-4 h-4" }} 16 + </span> 17 + {{ end }} 18 + <span class="shrink-0 text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 19 + <span class="truncate"> 20 + <a href="/{{ $repoUrl }}/pulls/{{ .PullId }}" class="no-underline hover:underline">{{- .Title -}}</a> 21 + on 22 + <a href="/{{ $repoUrl }}" class="no-underline hover:underline">{{ $repoUrl }}</a> 23 + </span> 24 + </div> 25 + {{ end }}
+7 -3
appview/pages/templates/user/fragments/repoCard.html
··· 5 5 {{ $fullName := index . 2 }} 6 6 {{ $starButton := false }} 7 7 {{ $starData := dict }} 8 + {{ $compact := false }} 8 9 {{ if gt (len .) 3 }} 9 10 {{ $starButton = index . 3 }} 10 11 {{ if gt (len .) 4 }} 11 12 {{ $starData = index . 4 }} 13 + {{ if gt (len .) 5 }} 14 + {{ $compact = index . 5 }} 15 + {{ end }} 12 16 {{ end }} 13 17 {{ end }} 14 18 15 19 {{ with $repo }} 16 - <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800 min-h-32"> 20 + <div class="{{ if not $compact }} min-h-32 {{ end }} {{ if $compact }} focus-within:bg-gray-100 dark:focus-within:bg-gray-800/80 {{ end }} py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800"> 17 21 <div class="font-medium dark:text-white flex items-center justify-between"> 18 22 <div class="flex items-center min-w-0 flex-1 mr-2"> 19 23 {{ if .Source }} ··· 23 27 {{ end }} 24 28 {{ $repoOwner := resolve .Did }} 25 29 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 30 + <a href="/{{ $repoOwner }}/{{ .Name }}" data-nav-result class="truncate min-w-0 {{ if $compact }} focus:outline-none {{ end }}">{{ $repoOwner }}/{{ .Name }}</a> 27 31 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 32 + <a href="/{{ $repoOwner }}/{{ .Name }}" data-nav-result class="truncate min-w-0 {{ if $compact }} focus:outline-none {{ end }}">{{ .Name }}</a> 29 33 {{- end -}} 30 34 </div> 31 35 {{ if and $starButton $root.LoggedInUser }}
+4 -1
appview/pages/templates/user/fragments/vouch.html
··· 1 1 {{ define "user/fragments/vouch" }} 2 2 <div class="relative"> 3 + {{ $vr := index . "VouchRelationship" }} 4 + {{ $ev := index . "Evidences" }} 5 + {{ $popoverId := index . "PopoverId" }} 3 6 {{ template "user/fragments/vouchButton" . }} 4 - {{ template "user/fragments/vouchPopover" . }} 7 + {{ template "user/fragments/vouchPopover" (dict "VouchRelationship" $vr "Evidences" $ev "PopoverId" $popoverId) }} 5 8 </div> 6 9 {{ end }}
+3 -1
appview/pages/templates/user/fragments/vouchButton.html
··· 13 13 {{ with .VouchRelationship }} 14 14 {{ $userDid = .SubjectDid.String }} 15 15 {{ end }} 16 + {{ $popoverId := index . "PopoverId" }} 17 + {{ if not $popoverId }}{{ $popoverId = printf "vouch-modal-%s" (normalizeForHtmlId $userDid) }}{{ end }} 16 18 <button 17 19 id="vouch-btn-{{ normalizeForHtmlId $userDid }}" 18 20 type="button" 19 - popovertarget="vouch-modal-{{ normalizeForHtmlId $userDid }}" 21 + popovertarget="{{ $popoverId }}" 20 22 popovertargetaction="toggle" 21 23 class="{{ if $isVouched }}btn-create{{else if $isDenounced}}btn-cancel{{else}}btn{{end}} w-full flex gap-2 items-center justify-center"> 22 24 {{ if $isVouched }}
+26 -19
appview/pages/templates/user/fragments/vouchPopover.html
··· 1 1 {{ define "user/fragments/vouchPopover" }} 2 + {{ $vr := index . "VouchRelationship" }} 3 + {{ $ev := index . "Evidences" }} 2 4 {{ $isVouched := false }} 3 5 {{ $isDenounced := false }} 4 6 {{ $isUndecided := false }} 5 - {{ if .VouchRelationship }} 6 - {{ if .VouchRelationship.IsDirectVouch }} 7 + {{ if $vr }} 8 + {{ if $vr.IsDirectVouch }} 7 9 {{ $isVouched = true }} 8 - {{ else if .VouchRelationship.IsDirectDenounce }} 10 + {{ else if $vr.IsDirectDenounce }} 9 11 {{ $isDenounced = true }} 10 12 {{ else }} 11 13 {{ $isUndecided = true }} ··· 16 18 17 19 {{ $userIdent := "" }} 18 20 {{ $userDid := "" }} 19 - {{ with .VouchRelationship }} 21 + {{ with $vr }} 20 22 {{ $userDid = .SubjectDid.String }} 21 23 {{ $userIdent = resolve .SubjectDid.String }} 22 24 {{ end }} 25 + {{ $popoverId := index . "PopoverId" }} 26 + {{ if not $popoverId }}{{ $popoverId = printf "vouch-modal-%s" (normalizeForHtmlId $userDid) }}{{ end }} 23 27 <div 24 - id="vouch-modal-{{ normalizeForHtmlId $userDid }}" 28 + id="{{ $popoverId }}" 25 29 popover 26 30 class="bg-white w-[95%] md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 space-y-2"> 27 31 ··· 34 38 <div> 35 39 <p class="font-semibold mb-1 text-base">Vouch for {{ $userIdent }}</p> 36 40 <p class="text-gray-600 dark:text-gray-400 text-sm"> 37 - {{ with .VouchRelationship }} 41 + {{ with $vr }} 38 42 {{ with .GetDirectVouch }} 39 43 You {{if $isVouched}}vouched{{else}}denounced{{end}} {{ $userIdent }} {{ relTimeFmt .CreatedAt }}. 40 44 You can change your decision below. ··· 46 50 </p> 47 51 </div> 48 52 49 - {{ if .VouchRelationship }} 50 - {{ if .VouchRelationship.IndirectVouches }} 53 + {{ if $vr }} 54 + {{ if $vr.IndirectVouches }} 51 55 <div> 52 56 <p class="font-semibold mb-1 text-sm">From your network:</p> 53 - {{ template "user/fragments/networkVouches" .VouchRelationship }} 57 + {{ template "user/fragments/networkVouches" $vr }} 54 58 </div> 55 59 {{ end }} 56 60 {{ end }} 57 61 58 62 <input type="hidden" name="subject" value="{{ $userDid }}" /> 63 + {{ range $ev }} 64 + <input type="hidden" name="evidences" value="{{ . }}" /> 65 + {{ end }} 59 66 60 67 {{ $labelClass := "grid grid-cols-[auto_1fr_auto] items-center gap-2 rounded p-2 py- ring-1 ring-gray-200 dark:ring-gray-700 cursor-pointer" }} 61 68 62 69 <div class="grid grid-cols-3 gap-2"> 63 70 <input 64 - id="vouch-{{ normalizeForHtmlId $userDid }}" 71 + id="vouch-{{ $popoverId }}" 65 72 type="radio" 66 73 name="kind" 67 74 value="vouch" 68 75 {{ if $isVouched }}checked{{ end }} 69 76 class="peer/vouch hidden appearance-none" /> 70 77 <input 71 - id="denounce-{{ normalizeForHtmlId $userDid }}" 78 + id="denounce-{{ $popoverId }}" 72 79 type="radio" 73 80 name="kind" 74 81 value="denounce" 75 82 {{ if $isDenounced }}checked{{ end }} 76 83 class="peer/denounce hidden appearance-none" /> 77 84 <input 78 - id="none-{{ normalizeForHtmlId $userDid }}" 85 + id="none-{{ $popoverId }}" 79 86 type="radio" 80 87 name="kind" 81 88 value="none" ··· 83 90 class="peer/none hidden appearance-none" /> 84 91 85 92 <label 86 - for="vouch-{{ normalizeForHtmlId $userDid }}" 93 + for="vouch-{{ $popoverId }}" 87 94 class=" 88 95 {{ $labelClass }} 89 96 hover:bg-gray-100 peer-checked/vouch:bg-green-200 peer-checked/vouch:text-green-800 peer-checked/vouch:ring-green-400 ··· 92 99 <span class="text-sm font-medium">Vouch</span> 93 100 </label> 94 101 <label 95 - for="denounce-{{ normalizeForHtmlId $userDid }}" 102 + for="denounce-{{ $popoverId }}" 96 103 class=" 97 104 {{ $labelClass }} 98 105 hover:bg-gray-100 peer-checked/denounce:bg-red-200 peer-checked/denounce:text-red-800 peer-checked/denounce:ring-red-400 ··· 101 108 <span class="text-sm font-medium">Denounce</span> 102 109 </label> 103 110 <label 104 - for="none-{{ normalizeForHtmlId $userDid }}" 111 + for="none-{{ $popoverId }}" 105 112 class=" 106 113 {{ $labelClass }} 107 114 hover:bg-gray-100 peer-checked/none:bg-gray-200 peer-checked/none:text-gray-800 peer-checked/none:ring-gray-400 ··· 111 118 </label> 112 119 113 120 <textarea 114 - id="reason-{{ normalizeForHtmlId $userDid }}" 121 + id="reason-{{ $popoverId }}" 115 122 name="reason" 116 123 rows="1" 117 124 maxlength="300" 118 125 placeholder="Write a reason..." 119 126 class="w-full resize-none col-span-3 peer-checked/none:hidden"> 120 - {{- with .VouchRelationship -}} 127 + {{- with $vr -}} 121 128 {{- with .GetDirectVouch -}} 122 129 {{- with .Reason -}} 123 130 {{- . -}} ··· 130 137 <div class="flex gap-2"> 131 138 <button 132 139 type="button" 133 - popovertarget="vouch-modal-{{ normalizeForHtmlId $userDid }}" 140 + popovertarget="{{ $popoverId }}" 134 141 popovertargetaction="hide" 135 142 class="btn flex-1 flex items-center justify-center gap-2"> 136 143 {{ i "x" "size-4" }} ··· 141 148 class="btn-create flex-1 flex items-center justify-center gap-2 text-white group"> 142 149 {{ i "check" "size-4 inline group-[.htmx-request]:hidden" }} 143 150 {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 144 - {{ if and .VouchRelationship .VouchRelationship.GetDirectVouch }} update {{ else }} save {{end}} 151 + {{ if and $vr $vr.GetDirectVouch }} update {{ else }} save {{end}} 145 152 </button> 146 153 </div> 147 154 </form>
+2 -58
appview/pages/templates/user/overview.html
··· 129 129 </summary> 130 130 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 131 131 {{ range $items }} 132 - {{ $repoOwner := resolve .Repo.Did }} 133 - {{ $repoName := .Repo.Name }} 134 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 135 - 136 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 137 - {{ if .Open }} 138 - <span class="text-green-600 dark:text-green-500"> 139 - {{ i "circle-dot" "w-4 h-4" }} 140 - </span> 141 - {{ else }} 142 - <span class="text-gray-500 dark:text-gray-400"> 143 - {{ i "ban" "w-4 h-4" }} 144 - </span> 145 - {{ end }} 146 - <div class="flex-none min-w-8 text-right"> 147 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 148 - </div> 149 - <div class="break-words max-w-full"> 150 - <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 151 - {{ .Title -}} 152 - </a> 153 - on 154 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 155 - {{$repoUrl}} 156 - </a> 157 - </div> 158 - </div> 132 + {{ template "user/fragments/issueEvent" . }} 159 133 {{ end }} 160 134 </div> 161 135 </details> ··· 198 172 </summary> 199 173 <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 200 174 {{ range $items }} 201 - {{ $repoOwner := resolve .Repo.Did }} 202 - {{ $repoName := .Repo.Name }} 203 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 204 - 205 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 206 - {{ if .State.IsOpen }} 207 - <span class="text-green-600 dark:text-green-500"> 208 - {{ i "git-pull-request" "w-4 h-4" }} 209 - </span> 210 - {{ else if .State.IsMerged }} 211 - <span class="text-purple-600 dark:text-purple-500"> 212 - {{ i "git-merge" "w-4 h-4" }} 213 - </span> 214 - {{ else }} 215 - <span class="text-gray-600 dark:text-gray-300"> 216 - {{ i "git-pull-request-closed" "w-4 h-4" }} 217 - </span> 218 - {{ end }} 219 - <div class="flex-none min-w-8 text-right"> 220 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 221 - </div> 222 - <div class="break-words max-w-full"> 223 - <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 224 - {{ .Title -}} 225 - </a> 226 - on 227 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 228 - {{$repoUrl}} 229 - </a> 230 - </div> 231 - </div> 175 + {{ template "user/fragments/pullEvent" . }} 232 176 {{ end }} 233 177 </div> 234 178 </details>
+37 -10
appview/pages/templates/user/vouches.html
··· 33 33 <div class="relative flex flex-col gap-6 ml-5 px-4 border border-transparent"> 34 34 <div class="absolute top-8 bottom-8 w-0.5 bg-gray-200 dark:bg-gray-700 z-0"></div> 35 35 {{ range .Vouches }} 36 - {{ template "vouchTimelineItem" (list $.Card.UserDid $.LoggedInUser.Did .) }} 36 + {{ template "vouchTimelineItem" (list $.Card.UserDid $.LoggedInUser.Did . $.EvidencePulls $.EvidenceIssues) }} 37 37 {{ end }} 38 38 </div> 39 39 {{ end }} ··· 43 43 {{ if .Suggestions }} 44 44 <div class="flex flex-col gap-2"> 45 45 {{ range .Suggestions }} 46 - <div class="flex items-center gap-3 border border-gray-200 dark:border-gray-700 rounded-sm p-4 bg-white dark:bg-gray-800"> 46 + <div class="flex items-center gap-4 border border-gray-200 dark:border-gray-700 rounded-sm p-4 bg-white dark:bg-gray-800"> 47 47 <img src="{{ tinyAvatar .Did.String }}" alt="" class="rounded-full size-10 border border-gray-300 dark:border-gray-700 shrink-0" /> 48 48 <div class="flex flex-col gap-0.5 min-w-0 flex-1"> 49 49 <a href="/{{ resolve .Did.String }}" class="font-medium hover:underline dark:text-white truncate">{{ resolve .Did.String }}</a> 50 50 <span class="text-sm text-gray-500 dark:text-gray-400">{{ .Reason }}</span> 51 51 </div> 52 52 <div class="shrink-0 w-32"> 53 - {{ template "user/fragments/vouch" . }} 53 + {{ template "user/fragments/vouch" (dict "VouchRelationship" .VouchRelationship) }} 54 54 </div> 55 + <button 56 + hx-post="/vouch/skip?subject={{ .Did.String }}" 57 + hx-trigger="click" 58 + hx-disabled-elt="this" 59 + title="Skip suggestion" 60 + class="group hit-area hit-area-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 p-0.5" 61 + > 62 + {{ i "x" "size-4 block group-[.htmx-request]:hidden" }} 63 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:block" }} 64 + </button> 55 65 </div> 56 66 {{ end }} 57 67 </div> ··· 62 72 {{ $profileDid := index . 0 }} 63 73 {{ $viewerDid := index . 1 }} 64 74 {{ $v := index . 2 }} 75 + {{ $evidencePulls := index . 3 }} 76 + {{ $evidenceIssues := index . 4 }} 65 77 {{ $isOutgoing := eq $v.Did $profileDid }} 66 78 {{ $isVouch := $v.IsVouch }} 67 79 {{ $isSelf := eq $profileDid $viewerDid }} 68 80 69 - <div class="-ml-5 flex items-center gap-3 relative z-10"> 70 - <div class="flex-shrink-0 flex items-center justify-center size-10 rounded-full 71 - {{ if $isVouch }}bg-green-200 dark:bg-green-800{{ else }}bg-red-200 dark:bg-red-800{{ end }}"> 81 + <div class="-ml-4 flex items-start gap-3 relative z-10"> 82 + <div class="flex-shrink-0 flex items-center justify-center size-8 rounded-full 83 + {{ if $isVouch }}bg-green-200 dark:bg-green-800{{ else }}bg-red-200 dark:bg-red-800{{ end }} 84 + border border-gray-300 dark:border-gray-700 85 + "> 72 86 {{ if $isVouch }} 73 87 {{ if $isOutgoing }} 74 88 {{ i "arrow-up-right" "size-5 text-green-500 dark:text-green-400" }} ··· 84 98 {{ end }} 85 99 </div> 86 100 87 - <div class="flex flex-col gap-1"> 88 - <div class="flex flex-wrap items-center gap-1 text-sm dark:text-white"> 101 + <div class="flex flex-col gap-1 mt-1"> 102 + <div class="flex flex-wrap items-center gap-1 dark:text-white"> 89 103 {{ if and $isSelf $isOutgoing }} 90 104 <span class="font-medium text-gray-700 dark:text-gray-300">you</span> 91 105 {{ else }} ··· 99 113 {{ else }} 100 114 <a href="/{{ resolve $v.SubjectDid.String }}" class="font-medium hover:underline">{{ resolve $v.SubjectDid.String }}</a> 101 115 {{ end }} 102 - <span class="text-gray-400 dark:text-gray-500 text-xs">{{ relTimeFmt $v.CreatedAt }}</span> 116 + <span class="text-gray-400 dark:text-gray-500 text-sm">{{ relTimeFmt $v.CreatedAt }}</span> 103 117 </div> 104 118 {{ with $v.Reason }} 105 - <p class="text-gray-500 dark:text-gray-400">{{ . }}</p> 119 + <p class="text-gray-500 dark:text-gray-400 text-sm">{{ . }}</p> 120 + {{ end }} 121 + {{ if $v.Evidences }} 122 + <div class="flex flex-col gap-2 mt-1 text-sm"> 123 + {{ range $v.Evidences }} 124 + {{ $pull := index $evidencePulls . }} 125 + {{ $issue := index $evidenceIssues . }} 126 + {{ if $pull }} 127 + {{ template "user/fragments/pullEvent" $pull }} 128 + {{ else if $issue }} 129 + {{ template "user/fragments/issueEvent" $issue }} 130 + {{ end }} 131 + {{ end }} 132 + </div> 106 133 {{ end }} 107 134 </div> 108 135 </div>
+1
appview/pulls/create.go
··· 300 300 PullSource: pullSource, 301 301 State: models.PullOpen, 302 302 Created: now, 303 + Repo: repo, 303 304 } 304 305 305 306 record := pull.AsRecord()
+8
appview/pulls/single.go
··· 189 189 } 190 190 191 191 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 192 + vouchSkips := make(map[syntax.DID]bool) 192 193 if user != nil { 193 194 participants := pull.Participants() 194 195 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants) 195 196 if err != nil { 196 197 l.Error("failed to fetch vouch relationships", "err", err) 197 198 } 199 + ownerDid := syntax.DID(pull.OwnerDid) 200 + skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid) 201 + if err != nil { 202 + l.Error("failed to check vouch skip", "err", err) 203 + } 204 + vouchSkips[ownerDid] = skipped 198 205 } 199 206 200 207 patch := pull.Submissions[roundIdInt].CombinedPatch() ··· 239 246 240 247 LabelDefs: defs, 241 248 VouchRelationships: vouchRelationships, 249 + VouchSkips: vouchSkips, 242 250 }) 243 251 if err != nil { 244 252 l.Error("failed to render page", "err", err)
+43 -5
appview/state/profile.go
··· 438 438 } 439 439 } 440 440 441 + var pullAts, issueAts []syntax.ATURI 442 + for _, v := range vouches { 443 + for _, ev := range v.Evidences { 444 + switch ev.Collection().String() { 445 + case tangled.RepoPullNSID: 446 + pullAts = append(pullAts, ev) 447 + case tangled.RepoIssueNSID: 448 + issueAts = append(issueAts, ev) 449 + } 450 + } 451 + } 452 + 453 + evidencePulls := make(map[syntax.ATURI]*models.Pull) 454 + if len(pullAts) > 0 { 455 + pulls, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAts)) 456 + if err != nil { 457 + l.Error("failed to get evidence pulls", "err", err) 458 + } else { 459 + for _, p := range pulls { 460 + evidencePulls[p.AtUri()] = p 461 + } 462 + } 463 + } 464 + 465 + evidenceIssues := make(map[syntax.ATURI]*models.Issue) 466 + if len(issueAts) > 0 { 467 + issues, err := db.GetIssues(s.db, orm.FilterIn("at_uri", issueAts)) 468 + if err != nil { 469 + l.Error("failed to get evidence issues", "err", err) 470 + } else { 471 + for i := range issues { 472 + evidenceIssues[issues[i].AtUri()] = &issues[i] 473 + } 474 + } 475 + } 476 + 441 477 err = s.pages.ProfileVouches(w, pages.ProfileVouchesParams{ 442 - LoggedInUser: loggedInUser, 443 - Vouches: vouches, 444 - Suggestions: suggestions, 445 - Card: profile, 446 - Page: page, 478 + LoggedInUser: loggedInUser, 479 + Vouches: vouches, 480 + Suggestions: suggestions, 481 + Card: profile, 482 + Page: page, 483 + EvidencePulls: evidencePulls, 484 + EvidenceIssues: evidenceIssues, 447 485 }) 448 486 if err != nil { 449 487 l.Error("failed to render page", "err", err)
+3
appview/state/router.go
··· 165 165 r.Post("/logout", s.Logout) 166 166 167 167 r.With(middleware.Paginate).Get("/search", s.Search) 168 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick", s.SearchQuick) 169 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick/mobile", s.SearchQuickMobile) 168 170 169 171 r.Post("/account/switch", s.SwitchAccount) 170 172 r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount) ··· 187 189 188 190 r.With(middleware.AuthMiddleware(s.oauth)).Route("/vouch", func(r chi.Router) { 189 191 r.Post("/", s.Vouch) 192 + r.Post("/skip", s.SkipVouchSuggestion) 190 193 }) 191 194 192 195 r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
+79 -10
appview/state/search.go
··· 1 1 package state 2 2 3 3 import ( 4 + "cmp" 4 5 "net/http" 6 + "slices" 5 7 "strings" 6 8 "time" 7 9 ··· 68 70 return 69 71 } 70 72 71 - // sort repos to match search result order (by relevance) 72 - repoMap := make(map[int64]models.Repo, len(repos)) 73 - for _, repo := range repos { 74 - repoMap[repo.Id] = repo 73 + hitIdx := make(map[int64]int, len(res.Hits)) 74 + for i, id := range res.Hits { 75 + hitIdx[id] = i 75 76 } 76 - repos = make([]models.Repo, 0, len(res.Hits)) 77 - for _, id := range res.Hits { 78 - if repo, ok := repoMap[id]; ok { 79 - repos = append(repos, repo) 80 - } 81 - } 77 + slices.SortFunc(repos, func(a, b models.Repo) int { 78 + return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id]) 79 + }) 82 80 } 83 81 resultCount = int(res.Total) 84 82 ··· 155 153 }) 156 154 if err != nil { 157 155 l.Error("failed to render page", "err", err) 156 + } 157 + } 158 + 159 + func (s *State) SearchQuick(w http.ResponseWriter, r *http.Request) { 160 + s.searchQuick(w, r, false) 161 + } 162 + 163 + func (s *State) SearchQuickMobile(w http.ResponseWriter, r *http.Request) { 164 + s.searchQuick(w, r, true) 165 + } 166 + 167 + func (s *State) searchQuick(w http.ResponseWriter, r *http.Request, mobile bool) { 168 + rawQuery := r.URL.Query().Get("q") 169 + if rawQuery == "" { 170 + w.WriteHeader(http.StatusOK) 171 + return 172 + } 173 + 174 + const pageSize = 5 175 + 176 + query := searchquery.Parse(rawQuery) 177 + tf := searchquery.ExtractTextFilters(query) 178 + 179 + searchOpts := models.RepoSearchOptions{ 180 + Keywords: tf.Keywords, 181 + Phrases: tf.Phrases, 182 + NegatedKeywords: tf.NegatedKeywords, 183 + NegatedPhrases: tf.NegatedPhrases, 184 + Page: pagination.Page{Limit: pageSize}, 185 + } 186 + 187 + var repos []models.Repo 188 + var total int 189 + 190 + if searchOpts.HasSearchFilters() { 191 + res, err := s.indexer.Repos.Search(r.Context(), searchOpts) 192 + if err != nil { 193 + s.logger.Error("failed quick search", "err", err) 194 + http.Error(w, "search failed", http.StatusInternalServerError) 195 + return 196 + } 197 + total = int(res.Total) 198 + if len(res.Hits) > 0 { 199 + repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits)) 200 + if err != nil { 201 + s.logger.Error("failed to get repos for quick search", "err", err) 202 + http.Error(w, "search failed", http.StatusInternalServerError) 203 + return 204 + } 205 + hitIdx := make(map[int64]int, len(res.Hits)) 206 + for i, id := range res.Hits { 207 + hitIdx[id] = i 208 + } 209 + slices.SortFunc(repos, func(a, b models.Repo) int { 210 + return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id]) 211 + }) 212 + } 213 + } 214 + 215 + params := pages.SearchQuickParams{ 216 + Repos: repos, 217 + Query: rawQuery, 218 + Total: total, 219 + } 220 + 221 + render := s.pages.SearchQuick 222 + if mobile { 223 + render = s.pages.SearchQuickMobile 224 + } 225 + if err := render(w, params); err != nil { 226 + s.logger.Error("failed to render quick search", "err", err) 158 227 } 159 228 } 160 229
+2 -3
appview/state/timeline.go
··· 1 1 package state 2 2 3 3 import ( 4 - "fmt" 5 4 "net/http" 6 5 7 6 "github.com/bluesky-social/indigo/atproto/syntax" ··· 30 29 s.logger.Error("failed to get bluesky posts", "err", err) 31 30 } 32 31 33 - fmt.Println(s.pages.Home(w, pages.TimelineParams{ 32 + s.pages.Home(w, pages.TimelineParams{ 34 33 LoggedInUser: user, 35 34 Timeline: timeline, 36 35 BlueskyPosts: blueskyPosts, 37 36 ShowNewsletter: s.showNewsletter(user), 38 - })) 37 + }) 39 38 } 40 39 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 41 40 if s.oauth.GetMultiAccountUser(r) != nil {
+66 -1
appview/state/vouch.go
··· 14 14 "tangled.org/core/log" 15 15 ) 16 16 17 + func (s *State) SkipVouchSuggestion(w http.ResponseWriter, r *http.Request) { 18 + l := log.SubLogger(s.logger, "skipVouchSuggestion") 19 + currentUser := s.oauth.GetMultiAccountUser(r) 20 + 21 + subject := r.FormValue("subject") 22 + if subject == "" { 23 + l.Warn("missing subject") 24 + s.pages.Notice(w, "error", "Missing subject user.") 25 + return 26 + } 27 + 28 + subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 29 + if err != nil { 30 + l.Error("failed to resolve subject", "subject", subject, "err", err) 31 + s.pages.Notice(w, "error", "Could not find that user.") 32 + return 33 + } 34 + 35 + if currentUser.Did == subjectIdent.DID.String() { 36 + l.Warn("cannot skip yourself") 37 + s.pages.Notice(w, "error", "You cannot skip yourself.") 38 + return 39 + } 40 + 41 + if err := db.SkipVouchSuggestion(s.db, currentUser.Did, subjectIdent.DID.String()); err != nil { 42 + l.Error("failed to skip vouch suggestion", "err", err) 43 + s.pages.Notice(w, "error", "Failed to skip suggestion.") 44 + return 45 + } 46 + 47 + s.pages.HxRefresh(w) 48 + } 49 + 17 50 func (s *State) Vouch(w http.ResponseWriter, r *http.Request) { 18 51 l := log.SubLogger(s.logger, "vouch") 19 52 l = s.logger.With("handler", "Vouch") ··· 103 136 reasonPtr = &reason 104 137 } 105 138 139 + var evidences []syntax.ATURI 140 + for _, raw := range r.Form["evidences"] { 141 + uri, err := syntax.ParseATURI(raw) 142 + if err != nil { 143 + l.Warn("invalid evidence AT-URI, skipping", "uri", raw, "err", err) 144 + continue 145 + } 146 + evidences = append(evidences, uri) 147 + } 148 + 106 149 var swapCid *string 107 150 existingVouch, err := db.GetVouch(s.db, currentUser.Did, subjectDid) 108 151 if err == nil { ··· 120 163 Kind: string(kind), 121 164 Reason: reasonPtr, 122 165 CreatedAt: createdAt, 166 + Evidences: func() []string { 167 + ss := make([]string, len(evidences)) 168 + for i, e := range evidences { 169 + ss[i] = e.String() 170 + } 171 + return ss 172 + }(), 123 173 }}, 124 174 }) 125 175 if err != nil { ··· 143 193 Cid: newCid, 144 194 Kind: kind, 145 195 Reason: reasonPtr, 196 + Evidences: evidences, 146 197 } 147 198 148 - err = db.AddVouch(s.db, vouch) 199 + tx, err := s.db.Begin() 200 + if err != nil { 201 + l.Error("failed to start transaction", "err", err) 202 + s.pages.Notice(w, "error", "Failed to save vouch.") 203 + return 204 + } 205 + defer tx.Rollback() 206 + 207 + err = db.AddVouch(tx, vouch) 149 208 if err != nil { 150 209 l.Error("failed to add vouch to db", "err", err) 210 + s.pages.Notice(w, "error", "Failed to save vouch.") 211 + return 212 + } 213 + 214 + if err = tx.Commit(); err != nil { 215 + l.Error("failed to commit vouch transaction", "err", err) 151 216 s.pages.Notice(w, "error", "Failed to save vouch.") 152 217 return 153 218 }
+18 -17
docs/DOCS.md
··· 170 170 171 171 You'll see something like: 172 172 173 - ``` 174 - origin git@github.com:username/my-project (fetch) 175 - origin git@github.com:username/my-project (push) 173 + ```bash 174 + origin git@github.com:username/my-project.git (fetch) 175 + origin git@github.com:username/my-project.git (push) 176 176 ``` 177 177 178 178 Update the remote URL to point to tangled: ··· 189 189 190 190 You should now see: 191 191 192 - ``` 192 + ```bash 193 193 origin git@tangled.org:user.tngl.sh/my-project (fetch) 194 194 origin git@tangled.org:user.tngl.sh/my-project (push) 195 195 ``` ··· 209 209 If you want to maintain your repository on multiple forges 210 210 simultaneously, for example, keeping your primary repository 211 211 on GitHub while mirroring to Tangled for backup or 212 - redundancy, you can do so by adding multiple remotes. 212 + redundancy, you can do so by adding [multiple remotes](https://git-scm.com/docs/git-push#_remotes). 213 213 214 214 You can configure your local repository to push to both 215 215 Tangled and, say, GitHub. You may already have the following 216 216 setup: 217 217 218 - ``` 218 + ```bash 219 219 $ git remote -v 220 - origin git@github.com:username/my-project (fetch) 221 - origin git@github.com:username/my-project (push) 220 + origin git@github.com:username/my-project.git (fetch) 221 + origin git@github.com:username/my-project.git (push) 222 222 ``` 223 223 224 224 Now add Tangled as an additional push URL to the same ··· 229 229 ``` 230 230 231 231 You also need to re-add the original URL as a push 232 - destination (Git replaces the push URL when you use `--add` 233 - the first time): 232 + destination (Git will now use the original URL to fetch only): 234 233 235 234 ```bash 236 - git remote set-url --add --push origin git@github.com:username/my-project 235 + git remote set-url --add --push origin git@github.com:username/my-project.git 237 236 ``` 238 237 239 238 Verify your configuration: 240 239 241 - ``` 240 + ```bash 242 241 $ git remote -v 243 - origin git@github.com:username/repo (fetch) 244 - origin git@tangled.org:username/my-project (push) 245 - origin git@github.com:username/repo (push) 242 + origin git@github.com:username/my-project.git (fetch) 243 + origin git@tangled.org:user.tngl.sh/my-project (push) 244 + origin git@github.com:username/my-project.git (push) 246 245 ``` 247 246 248 247 Notice that there's one fetch URL (the primary remote) and ··· 267 266 you can maintain separate remotes: 268 267 269 268 ```bash 270 - git remote add github git@github.com:username/my-project 271 - git remote add tangled git@tangled.org:username/my-project 269 + git remote add github git@github.com:username/my-project.git 270 + git remote add tangled git@tangled.org:user.tngl.sh/my-project 272 271 ``` 273 272 274 273 Then push to each explicitly: ··· 831 830 832 831 - `CI` - Always set to `true` to indicate a CI environment 833 832 - `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline 833 + - `TANGLED_PIPELINE_KIND` - One of `push`, `pull_request` or 834 + `manual` 834 835 - `TANGLED_REPO_KNOT` - The repository's knot hostname 835 836 - `TANGLED_REPO_DID` - The DID of the repository owner 836 837 - `TANGLED_REPO_NAME` - The name of the repository
+1 -1
input.css
··· 100 100 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; 101 101 } 102 102 input[type="checkbox"] { 103 - @apply appearance-none size-4 rounded 103 + @apply appearance-none size-4 p-0 rounded 104 104 bg-transparent border border-gray-200 105 105 hover:bg-gray-100 106 106 checked:bg-gray-900 checked:border-transparent
+18 -13
knotserver/ingester.go
··· 87 87 88 88 // returns a repo path on disk if present, and error if not 89 89 type targetRepo struct { 90 - RepoPath string 91 - OwnerDid string 92 - RepoName string 93 - RepoDid string 90 + RepoPath string 91 + OwnerDid string 92 + RepoName string 93 + RepoDid string 94 + DefaultBranch string // default branch 94 95 } 95 96 96 97 func (h *Knot) validatePullRecord(ctx context.Context, record *tangled.RepoPull) (*targetRepo, error) { ··· 161 162 return nil, fmt.Errorf("ignoring pull record: target has neither repo nor repoDid") 162 163 } 163 164 164 - _, err := git.Open(repoPath, record.Source.Branch) 165 + gr, err := git.Open(repoPath, record.Source.Branch) 165 166 if err != nil { 166 167 return nil, fmt.Errorf("failed to open git repository: %w", err) 167 168 } 169 + 170 + defaultBranch, _ := gr.FindMainBranch() 168 171 169 172 return &targetRepo{ 170 - RepoPath: repoPath, 171 - OwnerDid: ownerDid, 172 - RepoName: repoName, 173 - RepoDid: repoDid, 173 + RepoPath: repoPath, 174 + OwnerDid: ownerDid, 175 + RepoName: repoName, 176 + RepoDid: repoDid, 177 + DefaultBranch: defaultBranch, 174 178 }, nil 175 179 } 176 180 ··· 267 271 Kind: string(workflow.TriggerKindPullRequest), 268 272 PullRequest: &trigger, 269 273 Repo: &tangled.Pipeline_TriggerRepo{ 270 - Knot: h.c.Server.Hostname, 271 - RepoDid: &targetRepo.RepoDid, 272 - Did: targetRepo.OwnerDid, 273 - Repo: &targetRepo.RepoName, 274 + Knot: h.c.Server.Hostname, 275 + RepoDid: &targetRepo.RepoDid, 276 + Did: targetRepo.OwnerDid, 277 + Repo: &targetRepo.RepoName, 278 + DefaultBranch: targetRepo.DefaultBranch, 274 279 }, 275 280 }, 276 281 }
+7 -4
knotserver/internal.go
··· 361 361 }) 362 362 } 363 363 364 + defaultBranch, _ := gr.FindMainBranch() 365 + 364 366 trigger := tangled.Pipeline_PushTriggerData{ 365 367 Ref: line.Ref, 366 368 OldSha: line.OldSha.String(), ··· 368 370 } 369 371 370 372 triggerRepo := &tangled.Pipeline_TriggerRepo{ 371 - Did: ownerDid, 372 - Knot: h.c.Server.Hostname, 373 - Repo: &repoName, 374 - RepoDid: &repoDid, 373 + Did: ownerDid, 374 + Knot: h.c.Server.Hostname, 375 + Repo: &repoName, 376 + RepoDid: &repoDid, 377 + DefaultBranch: defaultBranch, 375 378 } 376 379 377 380 compiler := workflow.Compiler{
+9
lexicons/graph/vouch.json
··· 29 29 "createdAt": { 30 30 "type": "string", 31 31 "format": "datetime" 32 + }, 33 + "evidences": { 34 + "type": "array", 35 + "description": "Optional list of ATURIs serving as evidence for this vouch (ex. issues, PRs)", 36 + "maxLength": 10, 37 + "items": { 38 + "type": "string", 39 + "format": "at-uri" 40 + } 32 41 } 33 42 } 34 43 }
+1
spindle/models/pipeline_env.go
··· 21 21 env["CI"] = "true" 22 22 23 23 env["TANGLED_PIPELINE_ID"] = pipelineId.AtUri().String() 24 + env["TANGLED_PIPELINE_KIND"] = tr.Kind 24 25 25 26 // Repo info 26 27 if tr.Repo != nil {