Monorepo for Tangled
0

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>

oppiliappan (Jul 7, 2026, 5:44 PM +0100) 2e8242bf 488e120e

+374 -39
+1 -1
appview/ingester.go
··· 605 605 606 606 err = db.ValidateProfile(i.Db, &profile) 607 607 if err != nil { 608 - return fmt.Errorf("invalid profile record") 608 + return fmt.Errorf("invalid profile record: %w", err) 609 609 } 610 610 611 611 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 }}
+37
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-3 my-4 group" hx-disabled-elt="#add-key-btn"> 12 + <div class="flex flex-col gap-1"> 13 + <label for="name">Key name</label> 14 + <input type="text" name="name" placeholder="my-laptop" required class="w-full"> 15 + </div> 16 + <div class="flex flex-col gap-1"> 17 + <label for="key">Public key</label> 18 + <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..." class="w-full 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 + <div class="flex items-center justify-between mt-6"> 29 + {{ template "onboarding/back" "/welcome/social" }} 30 + <form hx-post="/welcome/next" hx-swap="none"> 31 + <input type="hidden" name="step" value="3"> 32 + <button type="submit" class="btn-create-flat w-fit flex items-center gap-2 text-sm"> 33 + Next {{ i "arrow-right" "size-4" }} 34 + </button> 35 + </form> 36 + </div> 37 + {{ 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="flex items-center gap-2 text-sm font-medium dark:text-white"> 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="flex items-center gap-2 text-sm font-medium dark:text-white"> 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="flex items-center gap-2 text-sm font-medium dark:text-white"> 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 }}
+53
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">Find users to follow and repositories to star.</p> 4 + 5 + {{ if .People }} 6 + <h3 class="text-sm font-medium mt-4 mb-2 dark:text-white">Popular users</h3> 7 + <div class="grid grid-cols-1 gap-3"> 8 + {{ range .People }} 9 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm overflow-hidden"> 10 + {{ template "user/fragments/followCard" 11 + (dict 12 + "LoggedInUser" $.LoggedInUser 13 + "UserDid" .UserDid 14 + "Profile" .Profile 15 + "FollowStatus" .FollowStatus 16 + "FollowersCount" .FollowersCount 17 + "FollowingCount" .FollowingCount) }} 18 + </div> 19 + {{ end }} 20 + </div> 21 + {{ end }} 22 + 23 + {{ if .TrendingRepos }} 24 + <h3 class="text-sm font-medium mt-6 mb-2 dark:text-white">Trending repositories</h3> 25 + <div class="grid grid-cols-1 gap-3"> 26 + {{ range .TrendingRepos }} 27 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm overflow-hidden"> 28 + {{ template "user/fragments/repoCard" 29 + (list $ . true true 30 + (dict 31 + "SubjectAt" .RepoAt 32 + "IsStarred" (index $.StarStatuses .RepoDid) 33 + "StarCount" .RepoStats.StarCount 34 + "RepoName" .Slug)) }} 35 + </div> 36 + {{ end }} 37 + </div> 38 + {{ end }} 39 + 40 + {{ if not (or .People .TrendingRepos) }} 41 + <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> 42 + {{ end }} 43 + 44 + <div class="flex items-center justify-between mt-6"> 45 + {{ template "onboarding/back" "/welcome/profile" }} 46 + <form hx-post="/welcome/next" hx-swap="none"> 47 + <input type="hidden" name="step" value="2"> 48 + <button type="submit" class="btn-create-flat w-fit flex items-center gap-2 text-sm"> 49 + Next {{ i "arrow-right" "size-4" }} 50 + </button> 51 + </form> 52 + </div> 53 + {{ 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"
+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" ··· 793 794 794 795 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 795 796 l := s.logger.With("handler", "UpdateProfileBio") 796 - user := s.oauth.GetMultiAccountUser(r) 797 797 798 - err := r.ParseForm() 799 - if err != nil { 798 + if err := r.ParseForm(); err != nil { 800 799 l.Error("invalid profile update form", "err", err) 801 800 s.pages.Notice(w, "update-profile", "Invalid form.") 802 801 return 803 802 } 804 803 804 + profile, err := s.bioFormToProfile(r) 805 + if err != nil { 806 + s.pages.Notice(w, "update-profile", err.Error()) 807 + return 808 + } 809 + 810 + s.updateProfile(profile, w, r) 811 + } 812 + 813 + // bioFormToProfile builds a validated Profile from an form, callers must have 814 + // called r.ParseForm. 815 + func (s *State) bioFormToProfile(r *http.Request) (*models.Profile, error) { 816 + user := s.oauth.GetMultiAccountUser(r) 817 + 805 818 profile, err := db.GetProfile(s.db, user.Did) 806 819 if err != nil { 807 - l.Error("getting profile data", "did", user.Did, "err", err) 820 + s.logger.Error("getting profile data", "did", user.Did, "err", err) 808 821 } 809 822 if profile == nil { 810 823 profile = &models.Profile{Did: user.Did} ··· 818 831 if rawPreferredHandle != "" { 819 832 h, err := syntax.ParseHandle(rawPreferredHandle) 820 833 if err != nil { 821 - s.pages.Notice(w, "update-profile", "Invalid handle format.") 822 - return 834 + return nil, errors.New("Invalid handle format.") 823 835 } 824 836 825 837 ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did) 826 838 if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) { 827 - s.pages.Notice(w, "update-profile", "Handle not found in your DID document.") 828 - return 839 + return nil, errors.New("Handle not found in your DID document.") 829 840 } 830 841 profile.PreferredHandle = h 831 842 } else { ··· 834 845 835 846 var links [5]string 836 847 for i := range 5 { 837 - iLink := r.FormValue(fmt.Sprintf("link%d", i)) 838 - links[i] = iLink 848 + links[i] = r.FormValue(fmt.Sprintf("link%d", i)) 839 849 } 840 850 profile.Links = links 841 851 842 852 // Parse stats (exactly 2) 843 - stat0 := r.FormValue("stat0") 844 - stat1 := r.FormValue("stat1") 845 - 846 - profile.Stats[0].Kind = models.ParseVanityStatKind(stat0) 847 - profile.Stats[1].Kind = models.ParseVanityStatKind(stat1) 853 + profile.Stats[0].Kind = models.ParseVanityStatKind(r.FormValue("stat0")) 854 + profile.Stats[1].Kind = models.ParseVanityStatKind(r.FormValue("stat1")) 848 855 849 856 if err := db.ValidateProfile(s.db, profile); err != nil { 850 - l.Error("invalid profile", "err", err) 851 - s.pages.Notice(w, "update-profile", err.Error()) 852 - return 857 + s.logger.Error("invalid profile", "err", err) 858 + return nil, err 853 859 } 854 860 855 - s.updateProfile(profile, w, r) 861 + return profile, nil 856 862 } 857 863 858 864 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 893 899 } 894 900 895 901 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 896 - l := s.logger.With("handler", "updateProfile") 902 + user := s.oauth.GetMultiAccountUser(r) 903 + 904 + if err := s.writeProfile(r, profile); err != nil { 905 + s.logger.With("handler", "updateProfile").Error("failed to write profile", "err", err) 906 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 907 + return 908 + } 909 + 910 + s.pages.HxRedirect(w, "/"+user.Did) 911 + } 912 + 913 + // writeProfile persists a profile to the PDS and local DB and updates caches. 914 + // It is shared by the profile settings handler and the onboarding flow; the 915 + // response (notice/redirect) is left to the caller. 916 + func (s *State) writeProfile(r *http.Request, profile *models.Profile) error { 897 917 user := s.oauth.GetMultiAccountUser(r) 918 + l := s.logger.With("handler", "writeProfile") 898 919 899 920 client, err := s.oauth.AuthorizedClient(r) 900 921 if err != nil { 901 - l.Error("failed to get authorized client", "err", err) 902 - s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 903 - return 922 + return fmt.Errorf("failed to get authorized client: %w", err) 904 923 } 905 924 906 925 var pinnedRepoStrings []string ··· 944 963 SwapRecord: cid, 945 964 }) 946 965 if err != nil { 947 - l.Error("failed to update profile on PDS", "err", err) 948 - s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 949 - return 966 + return fmt.Errorf("failed to update profile on PDS: %w", err) 950 967 } 951 968 952 969 if err := db.UpsertProfile(s.db, profile); err != nil { ··· 966 983 pipe.Del(ctx, didKey) 967 984 } 968 985 if _, execErr := pipe.Exec(ctx); execErr != nil { 969 - l.Warn("failed to update preferred handle cache", "err", execErr) 986 + s.logger.Warn("failed to update preferred handle cache", "err", execErr) 970 987 } 971 988 } 972 989 973 990 s.notifier.UpdateProfile(r.Context(), profile) 974 991 975 - s.pages.HxRedirect(w, "/"+user.Did) 992 + return nil 976 993 } 977 994 978 995 func (s *State) ProfilePopover(w http.ResponseWriter, r *http.Request) { ··· 1142 1159 1143 1160 l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String()) 1144 1161 1145 - // get current profile record from PDS to get its CID for swap 1162 + // get current profile record from PDS to get its CID for swap. A new user 1163 + // (e.g. mid-onboarding) may not have a profile record yet; treat any error 1164 + // as "no existing record" and create a fresh one with no swap. 1146 1165 getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 1147 - if err != nil { 1148 - l.Error("failed to get current profile record", "err", err) 1149 - s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS") 1150 - return 1151 - } 1152 1166 1153 1167 var profileRecord *tangled.ActorProfile 1154 - if getRecordResp.Value != nil { 1168 + var swapCid *string 1169 + switch { 1170 + case err != nil: 1171 + l.Warn("no existing profile record, creating new record", "err", err) 1172 + profileRecord = &tangled.ActorProfile{} 1173 + case getRecordResp.Value != nil: 1155 1174 if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { 1156 1175 profileRecord = val 1157 1176 } else { 1158 1177 l.Warn("profile record type assertion failed, creating new record") 1159 1178 profileRecord = &tangled.ActorProfile{} 1160 1179 } 1161 - } else { 1180 + swapCid = getRecordResp.Cid 1181 + default: 1162 1182 l.Warn("no existing profile record, creating new record") 1163 1183 profileRecord = &tangled.ActorProfile{} 1184 + swapCid = getRecordResp.Cid 1164 1185 } 1165 1186 1166 1187 profileRecord.Avatar = uploadBlobResp.Blob ··· 1170 1191 Repo: user.Did, 1171 1192 Rkey: "self", 1172 1193 Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, 1173 - SwapRecord: getRecordResp.Cid, 1194 + SwapRecord: swapCid, 1174 1195 }) 1175 1196 1176 1197 if err != nil { ··· 1194 1215 if err != nil { 1195 1216 l.Error("failed to update profile in DB", "err", err) 1196 1217 s.pages.HxRefresh(w) 1218 + w.WriteHeader(http.StatusOK) 1219 + return 1220 + } 1221 + 1222 + // inline uploads (e.g. the onboarding step) manage their own preview 1223 + // client-side; skip the full reload so unsaved form fields aren't cleared. 1224 + if r.FormValue("inline") == "true" { 1197 1225 w.WriteHeader(http.StatusOK) 1198 1226 return 1199 1227 }