diff --git a/appview/ingester.go b/appview/ingester.go index 6e5608ca..e6bdea74 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -613,7 +613,7 @@ func (i *Ingester) ingestProfile(ctx context.Context, e *jmodels.Event, l *slog. err = db.ValidateProfile(i.Db, &profile) if err != nil { - return fmt.Errorf("invalid profile record") + return fmt.Errorf("invalid profile record: %w", err) } err = db.UpsertProfile(i.Db, &profile) diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 5bae2c3b..fbfa0ac6 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -271,6 +271,15 @@ func (p *Pages) executeLogin(name string, w io.Writer, params any) error { return err } +func (p *Pages) executeOnboarding(name string, w io.Writer, params any) error { + tpl, err := p.parseOnboardingBase(name) + if err != nil { + return err + } + + return tpl.ExecuteTemplate(w, "layouts/base", params) +} + func (p *Pages) execute(name string, w io.Writer, params any) error { tpl, err := p.parseBase(name) if err != nil { @@ -705,6 +714,7 @@ func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { type ProfileCard struct { UserDid string HasProfile bool + IsTangledUser bool FollowStatus models.FollowStatus VouchRelationship *models.VouchRelationship Punchcard *models.Punchcard @@ -873,6 +883,9 @@ type EditBioParams struct { BaseParams Profile *models.Profile AlsoKnownAs []string + // Action optionally overrides the form's hx-post target. Defaults to + // /profile/bio when empty. Used by the onboarding flow to save + advance. + Action string } func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { @@ -907,6 +920,29 @@ func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error return p.executePlain("fragments/starBtn", w, params) } +type OnboardingParams struct { + BaseParams + Step int + + EditBio EditBioParams + + PubKeys []models.PublicKey + + People []FollowCard + TrendingRepos []models.Repo + StarStatuses map[string]bool +} + +// KeyFragment renders a single SSH key row (used to append a newly added key +// to the list without a full reload). +func (p *Pages) KeyFragment(w io.Writer, key models.PublicKey) error { + return p.executePlain("user/settings/fragments/keyListing", w, key) +} + +func (p *Pages) Onboarding(w io.Writer, params OnboardingParams) error { + return p.executeOnboarding("onboarding/welcome", w, params) +} + type RepoIndexParams struct { BaseParams RepoInfo repoinfo.RepoInfo diff --git a/appview/pages/templates/onboarding/fragments/back.html b/appview/pages/templates/onboarding/fragments/back.html new file mode 100644 index 00000000..e2698f7f --- /dev/null +++ b/appview/pages/templates/onboarding/fragments/back.html @@ -0,0 +1,5 @@ +{{ define "onboarding/back" }} + + {{ i "arrow-left" "size-4" }} Back + +{{ end }} diff --git a/appview/pages/templates/onboarding/fragments/finish.html b/appview/pages/templates/onboarding/fragments/finish.html new file mode 100644 index 00000000..b9297ea4 --- /dev/null +++ b/appview/pages/templates/onboarding/fragments/finish.html @@ -0,0 +1,79 @@ +{{ define "onboarding/finish" }} +

You're all set!

+

Here are a few things to explore next:

+ +
+
+
+ {{ i "book-marked" "size-4" }} +

Create a repository

+
+

Kick things off by creating your first repository.

+ + Create {{ i "arrow-right" "size-4" }} + +
+ +
+
+ {{ i "git-pull-request" "size-4" }} +

Contribute to the Tangled project

+
+

+ Tangled is fully open-source: Open a feature request, or submit a bug-fix! +
+
+ All contributions are welcome. +

+ + Contribute {{ i "arrow-right" "size-4" }} + +
+ +
+
+ {{ i "server" "size-4" }} +

Host your own knot

+
+

Host repositories on your own infrastructure with knots.

+ + Read the docs {{ i "arrow-right" "size-4" }} + +
+ +
+
+ {{ i "layers-2" "size-4" }} +

Host your own spindle

+
+

Run CI jobs on your own infrastructure using spindles.

+ + Read the docs {{ i "arrow-right" "size-4" }} + +
+
+ +
+ +
{{ i "bluesky" "size-4" }}Bluesky
+

Follow us @tangled.org on Bluesky for updates.

+
+ +
{{ i "message-circle" "size-4" }}Discord
+

Join the community on Discord.

+
+ +
{{ i "twitter" "size-4" }}X (Twitter)
+

Follow us @tangled_org on X for updates.

+
+
+ +
+ {{ template "onboarding/back" "/welcome/keys" }} +
+ +
+
+{{ end }} diff --git a/appview/pages/templates/onboarding/fragments/keys.html b/appview/pages/templates/onboarding/fragments/keys.html new file mode 100644 index 00000000..efa2e33b --- /dev/null +++ b/appview/pages/templates/onboarding/fragments/keys.html @@ -0,0 +1,39 @@ +{{ define "onboarding/keys" }} +

Add an SSH key

+

Add a public SSH key to push to your repositories over SSH.

+ +
+ {{ range .PubKeys }} + {{ template "user/settings/fragments/keyListing" . }} + {{ end }} +
+ +
+
+ + +
+
+ + +
+ +

+
+ +

+ +
+ {{ template "onboarding/back" "/welcome/social" }} +
+ + +
+
+{{ end }} diff --git a/appview/pages/templates/onboarding/fragments/profile.html b/appview/pages/templates/onboarding/fragments/profile.html new file mode 100644 index 00000000..fc674e0e --- /dev/null +++ b/appview/pages/templates/onboarding/fragments/profile.html @@ -0,0 +1,84 @@ +{{ define "onboarding/profile" }} + {{ $profile := .EditBio.Profile }} +

Welcome!

+

Let's get your profile ready. All fields are optional.

+ +
+
+
+ + +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +

+ +
+ +
+
+
+ + +{{ end }} diff --git a/appview/pages/templates/onboarding/fragments/social.html b/appview/pages/templates/onboarding/fragments/social.html new file mode 100644 index 00000000..f4fbabbc --- /dev/null +++ b/appview/pages/templates/onboarding/fragments/social.html @@ -0,0 +1,51 @@ +{{ define "onboarding/social" }} +

Discover

+

Find users to follow and repositories to star.

+ + {{ if .People }} +
+ {{ range .People }} +
+ {{ template "user/fragments/followCard" + (dict + "LoggedInUser" $.LoggedInUser + "UserDid" .UserDid + "Profile" .Profile + "FollowStatus" .FollowStatus + "FollowersCount" .FollowersCount + "FollowingCount" .FollowingCount) }} +
+ {{ end }} +
+ {{ end }} + + {{ if .TrendingRepos }} +
+ {{ range .TrendingRepos }} +
+ {{ template "user/fragments/repoCard" + (list $ . true true + (dict + "SubjectAt" .RepoAt + "IsStarred" (index $.StarStatuses .RepoDid) + "StarCount" .RepoStats.StarCount + "RepoName" .Slug)) }} +
+ {{ end }} +
+ {{ end }} + + {{ if not (or .People .TrendingRepos) }} +

Nothing to suggest right now — you can explore once you're set up.

+ {{ end }} + +
+ {{ template "onboarding/back" "/welcome/profile" }} +
+ + +
+
+{{ end }} diff --git a/appview/pages/templates/onboarding/welcome.html b/appview/pages/templates/onboarding/welcome.html new file mode 100644 index 00000000..13977375 --- /dev/null +++ b/appview/pages/templates/onboarding/welcome.html @@ -0,0 +1,13 @@ +{{ define "title" }}Welcome · Tangled{{ end }} + +{{ define "content" }} + {{ if eq .Step 0 }} + {{ template "onboarding/profile" . }} + {{ else if eq .Step 1 }} + {{ template "onboarding/social" . }} + {{ else if eq .Step 2 }} + {{ template "onboarding/keys" . }} + {{ else }} + {{ template "onboarding/finish" . }} + {{ end }} +{{ end }} diff --git a/appview/pages/templates/user/fragments/editBio.html b/appview/pages/templates/user/fragments/editBio.html index 8673a671..30406181 100644 --- a/appview/pages/templates/user/fragments/editBio.html +++ b/appview/pages/templates/user/fragments/editBio.html @@ -1,6 +1,6 @@ {{ define "user/fragments/editBio" }}
+
{{ end }} {{ define "addKeyButton" }} diff --git a/appview/settings/settings.go b/appview/settings/settings.go index 50a679e8..d211c5bc 100644 --- a/appview/settings/settings.go +++ b/appview/settings/settings.go @@ -704,20 +704,20 @@ func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { if rkey == "" { if err := db.DeletePublicKeyLegacy(s.Db, did, name); err != nil { s.Logger.Error("failed to remove public key", "err", err) - s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") + s.Pages.Notice(w, "settings-keys-delete-error", "Failed to remove public key.") return } } else { if err := db.DeletePublicKeyByRkey(s.Db, did, rkey); err != nil { s.Logger.Error("failed to remove public key", "err", err) - s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") + s.Pages.Notice(w, "settings-keys-delete-error", "Failed to remove public key.") return } client, err := s.OAuth.AuthorizedClient(r) if err != nil { s.Logger.Error("failed to authorize client", "err", err) - s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") + s.Pages.Notice(w, "settings-keys-delete-error", "Failed to authorize client.") return } @@ -731,7 +731,7 @@ func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { // invalid record if err != nil { s.Logger.Error("failed to delete record", "err", err) - s.Pages.Notice(w, "settings-keys", "Failed to remove key.") + s.Pages.Notice(w, "settings-keys-delete-error", "Failed to remove key.") return } } diff --git a/appview/state/profile.go b/appview/state/profile.go index 28573669..95392a27 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -3,6 +3,7 @@ package state import ( "cmp" "context" + "errors" "fmt" "net/http" "slices" @@ -804,18 +805,30 @@ func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, autho func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "UpdateProfileBio") - user := s.oauth.GetMultiAccountUser(r) - err := r.ParseForm() - if err != nil { + if err := r.ParseForm(); err != nil { l.Error("invalid profile update form", "err", err) s.pages.Notice(w, "update-profile", "Invalid form.") return } + profile, err := s.bioFormToProfile(r) + if err != nil { + s.pages.Notice(w, "update-profile", err.Error()) + return + } + + s.updateProfile(profile, w, r) +} + +// bioFormToProfile builds a validated Profile from an form, callers must have +// called r.ParseForm. +func (s *State) bioFormToProfile(r *http.Request) (*models.Profile, error) { + user := s.oauth.GetMultiAccountUser(r) + profile, err := db.GetProfile(s.db, user.Did) if err != nil { - l.Error("getting profile data", "did", user.Did, "err", err) + s.logger.Error("getting profile data", "did", user.Did, "err", err) } if profile == nil { profile = &models.Profile{Did: user.Did} @@ -829,14 +842,12 @@ func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { if rawPreferredHandle != "" { h, err := syntax.ParseHandle(rawPreferredHandle) if err != nil { - s.pages.Notice(w, "update-profile", "Invalid handle format.") - return + return nil, errors.New("Invalid handle format.") } ident, err := s.idResolver.ResolveIdent(r.Context(), user.Did) if err != nil || !slices.Contains(ident.AlsoKnownAs, "at://"+rawPreferredHandle) { - s.pages.Notice(w, "update-profile", "Handle not found in your DID document.") - return + return nil, errors.New("Handle not found in your DID document.") } profile.PreferredHandle = h } else { @@ -845,25 +856,20 @@ func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { var links [5]string for i := range 5 { - iLink := r.FormValue(fmt.Sprintf("link%d", i)) - links[i] = iLink + links[i] = r.FormValue(fmt.Sprintf("link%d", i)) } profile.Links = links // Parse stats (exactly 2) - stat0 := r.FormValue("stat0") - stat1 := r.FormValue("stat1") - - profile.Stats[0].Kind = models.ParseVanityStatKind(stat0) - profile.Stats[1].Kind = models.ParseVanityStatKind(stat1) + profile.Stats[0].Kind = models.ParseVanityStatKind(r.FormValue("stat0")) + profile.Stats[1].Kind = models.ParseVanityStatKind(r.FormValue("stat1")) if err := db.ValidateProfile(s.db, profile); err != nil { - l.Error("invalid profile", "err", err) - s.pages.Notice(w, "update-profile", err.Error()) - return + s.logger.Error("invalid profile", "err", err) + return nil, err } - s.updateProfile(profile, w, r) + return profile, nil } func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { @@ -904,16 +910,29 @@ func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { } func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { - l := s.logger.With("handler", "updateProfile") user := s.oauth.GetMultiAccountUser(r) - client, err := s.oauth.AuthorizedClient(r) - if err != nil { - l.Error("failed to get authorized client", "err", err) + if err := s.writeProfile(r, profile); err != nil { + s.logger.With("handler", "updateProfile").Error("failed to write profile", "err", err) s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") return } + s.pages.HxRedirect(w, "/"+user.Did) +} + +// writeProfile persists a profile to the PDS and local DB and updates caches. +// It is shared by the profile settings handler and the onboarding flow; the +// response (notice/redirect) is left to the caller. +func (s *State) writeProfile(r *http.Request, profile *models.Profile) error { + user := s.oauth.GetMultiAccountUser(r) + l := s.logger.With("handler", "writeProfile") + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + return fmt.Errorf("failed to get authorized client: %w", err) + } + var pinnedRepoStrings []string for _, r := range profile.PinnedRepos { if r != "" { @@ -955,9 +974,7 @@ func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r SwapRecord: cid, }) if err != nil { - l.Error("failed to update profile on PDS", "err", err) - s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") - return + return fmt.Errorf("failed to update profile on PDS: %w", err) } if err := db.UpsertProfile(s.db, profile); err != nil { @@ -977,13 +994,13 @@ func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r pipe.Del(ctx, didKey) } if _, execErr := pipe.Exec(ctx); execErr != nil { - l.Warn("failed to update preferred handle cache", "err", execErr) + s.logger.Warn("failed to update preferred handle cache", "err", execErr) } } s.notifier.UpdateProfile(r.Context(), profile) - s.pages.HxRedirect(w, "/"+user.Did) + return nil } func (s *State) ProfilePopover(w http.ResponseWriter, r *http.Request) { @@ -1153,25 +1170,29 @@ func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) { l.Info("uploaded avatar blob", "cid", uploadBlobResp.Blob.Ref.String()) - // get current profile record from PDS to get its CID for swap + // get current profile record from PDS to get its CID for swap. A new user + // (e.g. mid-onboarding) may not have a profile record yet; treat any error + // as "no existing record" and create a fresh one with no swap. getRecordResp, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") - if err != nil { - l.Error("failed to get current profile record", "err", err) - s.pages.Notice(w, "avatar-error", "Failed to get current profile from your PDS") - return - } var profileRecord *tangled.ActorProfile - if getRecordResp.Value != nil { + var swapCid *string + switch { + case err != nil: + l.Warn("no existing profile record, creating new record", "err", err) + profileRecord = &tangled.ActorProfile{} + case getRecordResp.Value != nil: if val, ok := getRecordResp.Value.Val.(*tangled.ActorProfile); ok { profileRecord = val } else { l.Warn("profile record type assertion failed, creating new record") profileRecord = &tangled.ActorProfile{} } - } else { + swapCid = getRecordResp.Cid + default: l.Warn("no existing profile record, creating new record") profileRecord = &tangled.ActorProfile{} + swapCid = getRecordResp.Cid } profileRecord.Avatar = uploadBlobResp.Blob @@ -1181,7 +1202,7 @@ func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) { Repo: user.Did, Rkey: "self", Record: &lexutil.LexiconTypeDecoder{Val: profileRecord}, - SwapRecord: getRecordResp.Cid, + SwapRecord: swapCid, }) if err != nil { @@ -1209,6 +1230,13 @@ func (s *State) UploadProfileAvatar(w http.ResponseWriter, r *http.Request) { return } + // inline uploads (e.g. the onboarding step) manage their own preview + // client-side; skip the full reload so unsaved form fields aren't cleared. + if r.FormValue("inline") == "true" { + w.WriteHeader(http.StatusOK) + return + } + s.pages.HxRedirect(w, r.Header.Get("Referer")) }