Monorepo for Tangled tangled.org
3

Configure Feed

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

appview/pages: frontend for onboarding flow

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
committer
Tangled
date (Jul 16, 2026, 4:33 PM +0300) commit 2b8f585b parent f737be28 change-id kkrxvyzk
+379 -43
+1 -1
appview/ingester.go
··· 613 613 614 614 err = db.ValidateProfile(i.Db, &profile) 615 615 if err != nil { 616 - return fmt.Errorf("invalid profile record") 616 + return fmt.Errorf("invalid profile record: %w", err) 617 617 } 618 618 619 619 err = db.UpsertProfile(i.Db, &profile)
+36
appview/pages/pages.go
··· 271 271 return err 272 272 } 273 273 274 + func (p *Pages) executeOnboarding(name string, w io.Writer, params any) error { 275 + tpl, err := p.parseOnboardingBase(name) 276 + if err != nil { 277 + return err 278 + } 279 + 280 + return tpl.ExecuteTemplate(w, "layouts/base", params) 281 + } 282 + 274 283 func (p *Pages) execute(name string, w io.Writer, params any) error { 275 284 tpl, err := p.parseBase(name) 276 285 if err != nil { ··· 705 714 type ProfileCard struct { 706 715 UserDid string 707 716 HasProfile bool 717 + IsTangledUser bool 708 718 FollowStatus models.FollowStatus 709 719 VouchRelationship *models.VouchRelationship 710 720 Punchcard *models.Punchcard ··· 873 883 BaseParams 874 884 Profile *models.Profile 875 885 AlsoKnownAs []string 886 + // Action optionally overrides the form's hx-post target. Defaults to 887 + // /profile/bio when empty. Used by the onboarding flow to save + advance. 888 + Action string 876 889 } 877 890 878 891 func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { ··· 905 918 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 906 919 params.HxSwapOob = true 907 920 return p.executePlain("fragments/starBtn", w, params) 921 + } 922 + 923 + type OnboardingParams struct { 924 + BaseParams 925 + Step int 926 + 927 + EditBio EditBioParams 928 + 929 + PubKeys []models.PublicKey 930 + 931 + People []FollowCard 932 + TrendingRepos []models.Repo 933 + StarStatuses map[string]bool 934 + } 935 + 936 + // KeyFragment renders a single SSH key row (used to append a newly added key 937 + // to the list without a full reload). 938 + func (p *Pages) KeyFragment(w io.Writer, key models.PublicKey) error { 939 + return p.executePlain("user/settings/fragments/keyListing", w, key) 940 + } 941 + 942 + func (p *Pages) Onboarding(w io.Writer, params OnboardingParams) error { 943 + return p.executeOnboarding("onboarding/welcome", w, params) 908 944 } 909 945 910 946 type RepoIndexParams struct {
+5
appview/pages/templates/onboarding/fragments/back.html
··· 1 + {{ define "onboarding/back" }} 2 + <a href="{{ . }}" class="btn-flat w-fit flex items-center gap-2 text-sm no-underline hover:no-underline"> 3 + {{ i "arrow-left" "size-4" }} Back 4 + </a> 5 + {{ end }}
+79
appview/pages/templates/onboarding/fragments/finish.html
··· 1 + {{ define "onboarding/finish" }} 2 + <h1 class="text-2xl font-semibold dark:text-white">You're all set!</h1> 3 + <p class="text-base text-gray-500 dark:text-gray-400 mt-1">Here are a few things to explore next:</p> 4 + 5 + <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-6"> 6 + <div class="sm:col-span-2 flex flex-col gap-2 border border-gray-200 dark:border-gray-700 rounded p-4"> 7 + <div class="flex items-center gap-2"> 8 + {{ i "book-marked" "size-4" }} 9 + <h3 class=" dark:text-white">Create a repository</h3> 10 + </div> 11 + <p class="text-sm text-gray-500 dark:text-gray-400 flex-1">Kick things off by creating your first repository.</p> 12 + <a href="/repo/new" class="ml-auto text-sm w-fit flex items-center gap-1 dark:text-white hover:underline"> 13 + Create {{ i "arrow-right" "size-4" }} 14 + </a> 15 + </div> 16 + 17 + <div class="sm:row-span-2 flex flex-col gap-2 border border-gray-200 dark:border-gray-700 rounded p-4"> 18 + <div class="flex items-center gap-2"> 19 + {{ i "git-pull-request" "size-4" }} 20 + <h3 class=" dark:text-white">Contribute to the Tangled project</h3> 21 + </div> 22 + <p class="text-sm text-gray-500 dark:text-gray-400 flex-1"> 23 + Tangled is fully open-source: Open a feature request, or submit a bug-fix! 24 + <br> 25 + <br> 26 + All contributions are welcome. 27 + </p> 28 + <a href="/core" class="ml-auto text-sm w-fit flex items-center gap-1 dark:text-white hover:underline"> 29 + Contribute {{ i "arrow-right" "size-4" }} 30 + </a> 31 + </div> 32 + 33 + <div class="flex flex-col gap-2 border border-gray-200 dark:border-gray-700 rounded p-4"> 34 + <div class="flex items-center gap-2"> 35 + {{ i "server" "size-4" }} 36 + <h3 class=" dark:text-white">Host your own knot</h3> 37 + </div> 38 + <p class="text-sm text-gray-500 dark:text-gray-400 flex-1">Host repositories on your own infrastructure with knots.</p> 39 + <a href="https://docs.tangled.org/knot-self-hosting-guide.html" target="_blank" rel="noopener noreferrer" class="ml-auto text-sm w-fit flex items-center gap-1 dark:text-white hover:underline"> 40 + Read the docs {{ i "arrow-right" "size-4" }} 41 + </a> 42 + </div> 43 + 44 + <div class="flex flex-col gap-2 border border-gray-200 dark:border-gray-700 rounded p-4"> 45 + <div class="flex items-center gap-2"> 46 + {{ i "layers-2" "size-4" }} 47 + <h3 class=" dark:text-white">Host your own spindle</h3> 48 + </div> 49 + <p class="text-sm text-gray-500 dark:text-gray-400 flex-1">Run CI jobs on your own infrastructure using spindles.</p> 50 + <a href="https://docs.tangled.org/spindles.html#self-hosting-guide" target="_blank" rel="noopener noreferrer" class="ml-auto text-sm w-fit flex items-center gap-1 dark:text-white hover:underline"> 51 + Read the docs {{ i "arrow-right" "size-4" }} 52 + </a> 53 + </div> 54 + </div> 55 + 56 + <div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3"> 57 + <a href="https://bsky.app/profile/tangled.org" target="_blank" rel="noopener noreferrer" class="flex flex-col gap-1 border border-gray-200 dark:border-gray-700 rounded p-4 no-underline hover:no-underline hover:bg-gray-50 dark:hover:bg-gray-800"> 58 + <div class="flex items-center gap-2">{{ i "bluesky" "size-4" }}<span class=" dark:text-white">Bluesky</span></div> 59 + <p class="text-sm text-gray-500 dark:text-gray-400">Follow us @tangled.org on Bluesky for updates.</p> 60 + </a> 61 + <a href="https://chat.tangled.org" target="_blank" rel="noopener noreferrer" class="flex flex-col gap-1 border border-gray-200 dark:border-gray-700 rounded p-4 no-underline hover:no-underline hover:bg-gray-50 dark:hover:bg-gray-800"> 62 + <div class="flex items-center gap-2">{{ i "message-circle" "size-4" }}<span class=" dark:text-white">Discord</span></div> 63 + <p class="text-sm text-gray-500 dark:text-gray-400">Join the community on Discord.</p> 64 + </a> 65 + <a href="https://x.com/tangled_org" target="_blank" rel="noopener noreferrer" class="flex flex-col gap-1 border border-gray-200 dark:border-gray-700 rounded p-4 no-underline hover:no-underline hover:bg-gray-50 dark:hover:bg-gray-800"> 66 + <div class="flex items-center gap-2">{{ i "twitter" "size-4" }}<span class=" dark:text-white">X (Twitter)</span></div> 67 + <p class="text-sm text-gray-500 dark:text-gray-400">Follow us @tangled_org on X for updates.</p> 68 + </a> 69 + </div> 70 + 71 + <div class="flex items-center justify-between mt-6"> 72 + {{ template "onboarding/back" "/welcome/keys" }} 73 + <form hx-post="/welcome/complete" hx-swap="none"> 74 + <button type="submit" class="btn-create-flat w-fit flex items-center gap-2 text-sm"> 75 + {{ i "check" "size-4" }} Finish 76 + </button> 77 + </form> 78 + </div> 79 + {{ end }}
+39
appview/pages/templates/onboarding/fragments/keys.html
··· 1 + {{ define "onboarding/keys" }} 2 + <h1 class="text-2xl font-semibold dark:text-white">Add an SSH key</h1> 3 + <p class="text-base text-gray-500 dark:text-gray-400 mt-1">Add a public SSH key to push to your repositories over SSH.</p> 4 + 5 + <div id="keys-list" class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 my-4 [&:not(:has(*))]:hidden"> 6 + {{ range .PubKeys }} 7 + {{ template "user/settings/fragments/keyListing" . }} 8 + {{ end }} 9 + </div> 10 + 11 + <form hx-put="/settings/keys" hx-target="#keys-list" hx-swap="beforeend" hx-on:keyadded="this.reset()" class="flex flex-col gap-4 my-4 group" hx-disabled-elt="#add-key-btn"> 12 + <div class="flex flex-col gap-1"> 13 + <label for="name" class="font-medium">Key name</label> 14 + <input type="text" name="name" placeholder="my-laptop" required> 15 + </div> 16 + <div class="flex flex-col gap-1"> 17 + <label for="key" class="font-medium">Public key</label> 18 + <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..." class="font-mono text-sm"></textarea> 19 + </div> 20 + <button id="add-key-btn" type="submit" class="ml-auto btn-flat flex w-fit items-center gap-2 text-sm"> 21 + {{ i "plus" "size-4 inline group-[.htmx-request]:hidden" }} 22 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 23 + Add key 24 + </button> 25 + <p id="settings-keys" class="error"></p> 26 + </form> 27 + 28 + <p id="settings-keys-delete-error" class="error"></p> 29 + 30 + <div class="flex items-center justify-between mt-6"> 31 + {{ template "onboarding/back" "/welcome/social" }} 32 + <form hx-post="/welcome/next" hx-swap="none"> 33 + <input type="hidden" name="step" value="3"> 34 + <button type="submit" class="btn-create-flat w-fit flex items-center gap-2 text-sm"> 35 + Next {{ i "arrow-right" "size-4" }} 36 + </button> 37 + </form> 38 + </div> 39 + {{ end }}
+84
appview/pages/templates/onboarding/fragments/profile.html
··· 1 + {{ define "onboarding/profile" }} 2 + {{ $profile := .EditBio.Profile }} 3 + <h1 class="text-2xl font-semibold dark:text-white">Welcome!</h1> 4 + <p class="text-base text-gray-500 dark:text-gray-400 mt-1">Let's get your profile ready. All fields are optional.</p> 5 + 6 + <div class="flex flex-col md:flex-row gap-6 mt-6"> 7 + <div class="shrink-0 flex md:block justify-center"> 8 + <form hx-post="/profile/avatar" hx-encoding="multipart/form-data" hx-swap="none" class="group"> 9 + <input type="hidden" name="inline" value="true"> 10 + <label class="p-0 size-40 rounded-full border border-gray-300 dark:border-gray-700 11 + flex flex-col items-center justify-center gap-1 cursor-pointer overflow-hidden 12 + text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"> 13 + <img id="onb-avatar" src="{{ profileAvatarUrl $profile "" }}" 14 + class="w-full h-full object-cover rounded-full group-[.htmx-request]:hidden {{ if not $profile.Avatar }}hidden{{ end }}"> 15 + <div id="onb-avatar-placeholder" class="flex flex-col items-center justify-center gap-1 group-[.htmx-request]:hidden {{ if $profile.Avatar }}hidden{{ end }}"> 16 + {{ i "camera" "size-6" }} 17 + <span class="text-sm">Upload picture</span> 18 + </div> 19 + {{ i "loader-circle" "size-6 animate-spin hidden group-[.htmx-request]:inline" }} 20 + <span class="text-sm hidden group-[.htmx-request]:inline">Uploading picture...</span> 21 + <input type="file" name="avatar" accept="image/png,image/jpeg" class="hidden" 22 + onchange="onbPreviewAvatar(this)"> 23 + </label> 24 + <p id="avatar-error" class="text-red-500 dark:text-red-400 text-sm mt-1 text-center"></p> 25 + </form> 26 + </div> 27 + 28 + <form hx-post="/welcome/profile" hx-swap="none" id="onb-profile-form" class="flex flex-col gap-4 flex-1"> 29 + <div class="flex flex-col gap-1"> 30 + <label for="onb-bio" class="font-medium"> 31 + Bio 32 + </label> 33 + <input type="text" id="onb-bio" name="description" placeholder="Tell us about yourself" 34 + value="{{ $profile.Description }}" oninput="onbProfileChanged()"> 35 + </div> 36 + 37 + <div class="flex flex-col gap-1"> 38 + <label for="onb-pronouns" class="font-medium"> 39 + Pronouns 40 + </label> 41 + <input type="text" id="onb-pronouns" name="pronouns" placeholder="they/them" 42 + value="{{ $profile.Pronouns }}" oninput="onbProfileChanged()"> 43 + </div> 44 + 45 + <div class="flex flex-col gap-1"> 46 + <label for="onb-website" class="font-medium"> 47 + Website 48 + </label> 49 + <input type="text" id="onb-website" name="link0" placeholder="example.com" 50 + value="{{ index $profile.Links 0 }}" oninput="onbProfileChanged()"> 51 + </div> 52 + 53 + <p id="update-profile" class="error"></p> 54 + 55 + <div class="flex justify-end mt-2"> 56 + <button id="onb-next" type="submit" class="btn-flat w-fit flex items-center gap-2 text-sm"> 57 + Next {{ i "arrow-right" "size-4" }} 58 + </button> 59 + </div> 60 + </form> 61 + </div> 62 + 63 + <script> 64 + function onbProfileChanged() { 65 + const f = document.getElementById('onb-profile-form'); 66 + const filled = ['description', 'pronouns', 'link0'] 67 + .some(n => f.elements[n] && f.elements[n].value.trim() !== ''); 68 + document.getElementById('onb-next').className = 69 + (filled ? 'btn-create-flat' : 'btn-flat') + ' w-fit flex items-center gap-2 text-sm'; 70 + } 71 + onbProfileChanged(); 72 + 73 + function onbPreviewAvatar(input) { 74 + const file = input.files && input.files[0]; 75 + if (file) { 76 + const img = document.getElementById('onb-avatar'); 77 + img.src = URL.createObjectURL(file); 78 + img.classList.remove('hidden'); 79 + document.getElementById('onb-avatar-placeholder').classList.add('hidden'); 80 + } 81 + input.form.requestSubmit(); 82 + } 83 + </script> 84 + {{ end }}
+51
appview/pages/templates/onboarding/fragments/social.html
··· 1 + {{ define "onboarding/social" }} 2 + <h1 class="text-2xl font-semibold dark:text-white">Discover</h1> 3 + <p class="text-base text-gray-500 dark:text-gray-400 mt-1 mb-4">Find users to follow and repositories to star.</p> 4 + 5 + {{ if .People }} 6 + <div class="grid grid-cols-1 gap-3 mb-4"> 7 + {{ range .People }} 8 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm overflow-hidden"> 9 + {{ template "user/fragments/followCard" 10 + (dict 11 + "LoggedInUser" $.LoggedInUser 12 + "UserDid" .UserDid 13 + "Profile" .Profile 14 + "FollowStatus" .FollowStatus 15 + "FollowersCount" .FollowersCount 16 + "FollowingCount" .FollowingCount) }} 17 + </div> 18 + {{ end }} 19 + </div> 20 + {{ end }} 21 + 22 + {{ if .TrendingRepos }} 23 + <div class="grid grid-cols-1 gap-3"> 24 + {{ range .TrendingRepos }} 25 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm overflow-hidden"> 26 + {{ template "user/fragments/repoCard" 27 + (list $ . true true 28 + (dict 29 + "SubjectAt" .RepoAt 30 + "IsStarred" (index $.StarStatuses .RepoDid) 31 + "StarCount" .RepoStats.StarCount 32 + "RepoName" .Slug)) }} 33 + </div> 34 + {{ end }} 35 + </div> 36 + {{ end }} 37 + 38 + {{ if not (or .People .TrendingRepos) }} 39 + <p class="text-sm text-gray-500 dark:text-gray-400 my-4">Nothing to suggest right now — you can explore once you're set up.</p> 40 + {{ end }} 41 + 42 + <div class="flex items-center justify-between mt-6"> 43 + {{ template "onboarding/back" "/welcome/profile" }} 44 + <form hx-post="/welcome/next" hx-swap="none"> 45 + <input type="hidden" name="step" value="2"> 46 + <button type="submit" class="btn-create-flat w-fit flex items-center gap-2 text-sm"> 47 + Next {{ i "arrow-right" "size-4" }} 48 + </button> 49 + </form> 50 + </div> 51 + {{ end }}
+13
appview/pages/templates/onboarding/welcome.html
··· 1 + {{ define "title" }}Welcome &middot; Tangled{{ end }} 2 + 3 + {{ define "content" }} 4 + {{ if eq .Step 0 }} 5 + {{ template "onboarding/profile" . }} 6 + {{ else if eq .Step 1 }} 7 + {{ template "onboarding/social" . }} 8 + {{ else if eq .Step 2 }} 9 + {{ template "onboarding/keys" . }} 10 + {{ else }} 11 + {{ template "onboarding/finish" . }} 12 + {{ end }} 13 + {{ end }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 1 1 {{ define "user/fragments/editBio" }} 2 2 <form 3 - hx-post="/profile/bio" 3 + hx-post="{{ if .Action }}{{ .Action }}{{ else }}/profile/bio{{ end }}" 4 4 class="flex flex-col gap-4 my-2 max-w-full" 5 5 hx-disabled-elt="#save-btn,#cancel-btn" 6 6 hx-swap="none"
+1
appview/pages/templates/user/settings/keys.html
··· 34 34 {{ template "user/settings/fragments/keyListing" . }} 35 35 {{ end }} 36 36 </div> 37 + <div id="settings-keys-delete-error" class="text-red-500 dark:text-red-400 text-sm"></div> 37 38 {{ end }} 38 39 39 40 {{ define "addKeyButton" }}
+4 -4
appview/settings/settings.go
··· 704 704 if rkey == "" { 705 705 if err := db.DeletePublicKeyLegacy(s.Db, did, name); err != nil { 706 706 s.Logger.Error("failed to remove public key", "err", err) 707 - s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 707 + s.Pages.Notice(w, "settings-keys-delete-error", "Failed to remove public key.") 708 708 return 709 709 } 710 710 } else { 711 711 if err := db.DeletePublicKeyByRkey(s.Db, did, rkey); err != nil { 712 712 s.Logger.Error("failed to remove public key", "err", err) 713 - s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 713 + s.Pages.Notice(w, "settings-keys-delete-error", "Failed to remove public key.") 714 714 return 715 715 } 716 716 717 717 client, err := s.OAuth.AuthorizedClient(r) 718 718 if err != nil { 719 719 s.Logger.Error("failed to authorize client", "err", err) 720 - s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") 720 + s.Pages.Notice(w, "settings-keys-delete-error", "Failed to authorize client.") 721 721 return 722 722 } 723 723 ··· 731 731 // invalid record 732 732 if err != nil { 733 733 s.Logger.Error("failed to delete record", "err", err) 734 - s.Pages.Notice(w, "settings-keys", "Failed to remove key.") 734 + s.Pages.Notice(w, "settings-keys-delete-error", "Failed to remove key.") 735 735 return 736 736 } 737 737 }
+65 -37
appview/state/profile.go
··· 3 3 import ( 4 4 "cmp" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "net/http" 8 9 "slices" ··· 804 805 805 806 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 806 807 l := s.logger.With("handler", "UpdateProfileBio") 807 - user := s.oauth.GetMultiAccountUser(r) 808 808 809 - err := r.ParseForm() 810 - if err != nil { 809 + if err := r.ParseForm(); err != nil { 811 810 l.Error("invalid profile update form", "err", err) 812 811 s.pages.Notice(w, "update-profile", "Invalid form.") 813 812 return 814 813 } 815 814 815 + profile, err := s.bioFormToProfile(r) 816 + if err != nil { 817 + s.pages.Notice(w, "update-profile", err.Error()) 818 + return 819 + } 820 + 821 + s.updateProfile(profile, w, r) 822 + } 823 + 824 + // bioFormToProfile builds a validated Profile from an form, callers must have 825 + // called r.ParseForm. 826 + func (s *State) bioFormToProfile(r *http.Request) (*models.Profile, error) { 827 + user := s.oauth.GetMultiAccountUser(r) 828 + 816 829 profile, err := db.GetProfile(s.db, user.Did) 817 830 if err != nil { 818 - l.Error("getting profile data", "did", user.Did, "err", err) 831 + s.logger.Error("getting profile data", "did", user.Did, "err", err) 819 832 } 820 833 if profile == nil { 821 834 profile = &models.Profile{Did: user.Did} ··· 829 842 if rawPreferredHandle != "" { 830 843 h, err := syntax.ParseHandle(rawPreferredHandle) 831 844 if err != nil { 832 - s.pages.Notice(w, "update-profile", "Invalid handle format.") 833 - return 845 + return nil, errors.New("Invalid handle format.") 834 846 } 835 847 836 848 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did) 837 849 if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) { 838 - s.pages.Notice(w, "update-profile", "Handle not found in your DID document.") 839 - return 850 + return nil, errors.New("Handle not found in your DID document.") 840 851 } 841 852 profile.PreferredHandle = h 842 853 } else { ··· 845 856 846 857 var links [5]string 847 858 for i := range 5 { 848 - iLink := r.FormValue(fmt.Sprintf("link%d", i)) 849 - links[i] = iLink 859 + links[i] = r.FormValue(fmt.Sprintf("link%d", i)) 850 860 } 851 861 profile.Links = links 852 862 853 863 // Parse stats (exactly 2) 854 - stat0 := r.FormValue("stat0") 855 - stat1 := r.FormValue("stat1") 856 - 857 - profile.Stats[0].Kind = models.ParseVanityStatKind(stat0) 858 - profile.Stats[1].Kind = models.ParseVanityStatKind(stat1) 864 + profile.Stats[0].Kind = models.ParseVanityStatKind(r.FormValue("stat0")) 865 + profile.Stats[1].Kind = models.ParseVanityStatKind(r.FormValue("stat1")) 859 866 860 867 if err := db.ValidateProfile(s.db, profile); err != nil { 861 - l.Error("invalid profile", "err", err) 862 - s.pages.Notice(w, "update-profile", err.Error()) 863 - return 868 + s.logger.Error("invalid profile", "err", err) 869 + return nil, err 864 870 } 865 871 866 - s.updateProfile(profile, w, r) 872 + return profile, nil 867 873 } 868 874 869 875 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 904 910 } 905 911 906 912 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 907 - l := s.logger.With("handler", "updateProfile") 913 + user := s.oauth.GetMultiAccountUser(r) 914 + 915 + if err := s.writeProfile(r, profile); err != nil { 916 + s.logger.With("handler", "updateProfile").Error("failed to write profile", "err", err) 917 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 918 + return 919 + } 920 + 921 + s.pages.HxRedirect(w, "/"+user.Did) 922 + } 923 + 924 + // writeProfile persists a profile to the PDS and local DB and updates caches. 925 + // It is shared by the profile settings handler and the onboarding flow; the 926 + // response (notice/redirect) is left to the caller. 927 + func (s *State) writeProfile(r *http.Request, profile *models.Profile) error { 908 928 user := s.oauth.GetMultiAccountUser(r) 929 + l := s.logger.With("handler", "writeProfile") 909 930 910 931 client, err := s.oauth.AuthorizedClient(r) 911 932 if err != nil { 912 - l.Error("failed to get authorized client", "err", err) 913 - s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 914 - return 933 + return fmt.Errorf("failed to get authorized client: %w", err) 915 934 } 916 935 917 936 var pinnedRepoStrings []string ··· 955 974 SwapRecord: cid, 956 975 }) 957 976 if err != nil { 958 - l.Error("failed to update profile on PDS", "err", err) 959 - s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 960 - return 977 + return fmt.Errorf("failed to update profile on PDS: %w", err) 961 978 } 962 979 963 980 if err := db.UpsertProfile(s.db, profile); err != nil { ··· 977 994 pipe.Del(ctx, didKey) 978 995 } 979 996 if _, execErr := pipe.Exec(ctx); execErr != nil { 980 - l.Warn("failed to update preferred handle cache", "err", execErr) 997 + s.logger.Warn("failed to update preferred handle cache", "err", execErr) 981 998 } 982 999 } 983 1000 984 1001 s.notifier.UpdateProfile(r.Context(), profile) 985 1002 986 - s.pages.HxRedirect(w, "/"+user.Did) 1003 + return nil 987 1004 } 988 1005 989 1006 func (s *State) ProfilePopover(w http.ResponseWriter, r *http.Request) { ··· 1153 1170 1154 1171 l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String()) 1155 1172 1156 - // get current profile record from PDS to get its CID for swap 1173 + // get current profile record from PDS to get its CID for swap. A new user 1174 + // (e.g. mid-onboarding) may not have a profile record yet; treat any error 1175 + // as "no existing record" and create a fresh one with no swap. 1157 1176 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 1158 - if err != nil { 1159 - l.Error("failed to get current profile record", "err", err) 1160 - s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS") 1161 - return 1162 - } 1163 1177 1164 1178 var profileRecord *tangled.ActorProfile 1165 - if getRecordResp.Value != nil { 1179 + var swapCid *string 1180 + switch { 1181 + case err != nil: 1182 + l.Warn("no existing profile record, creating new record", "err", err) 1183 + profileRecord = &tangled.ActorProfile{} 1184 + case getRecordResp.Value != nil: 1166 1185 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { 1167 1186 profileRecord = val 1168 1187 } else { 1169 1188 l.Warn("profile record type assertion failed, creating new record") 1170 1189 profileRecord = &tangled.ActorProfile{} 1171 1190 } 1172 - } else { 1191 + swapCid = getRecordResp.Cid 1192 + default: 1173 1193 l.Warn("no existing profile record, creating new record") 1174 1194 profileRecord = &tangled.ActorProfile{} 1195 + swapCid = getRecordResp.Cid 1175 1196 } 1176 1197 1177 1198 profileRecord.Avatar = uploadBlobResp.Blob ··· 1181 1202 Repo: user.Did, 1182 1203 Rkey: "self", 1183 1204 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, 1184 - SwapRecord: getRecordResp.Cid, 1205 + SwapRecord: swapCid, 1185 1206 }) 1186 1207 1187 1208 if err != nil { ··· 1205 1226 if err != nil { 1206 1227 l.Error("failed to update profile in DB", "err", err) 1207 1228 s.pages.HxRefresh(w) 1229 + w.WriteHeader(http.StatusOK) 1230 + return 1231 + } 1232 + 1233 + // inline uploads (e.g. the onboarding step) manage their own preview 1234 + // client-side; skip the full reload so unsaved form fields aren't cleared. 1235 + if r.FormValue("inline") == "true" { 1208 1236 w.WriteHeader(http.StatusOK) 1209 1237 return 1210 1238 }