diff --git a/appview/db/db.go b/appview/db/db.go index 91421c62..bed57875 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1409,6 +1409,21 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + orm.RunMigration(conn, logger, "add-newsletter-preferences", func(tx *sql.Tx) error { + _, err := tx.Exec(` + create table if not exists newsletter_preferences ( + id integer primary key autoincrement, + user_did text not null unique, + status text not null check (status in ('subscribed', 'dismissed')), + email text, + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + create index if not exists idx_newsletter_prefs_user_did + on newsletter_preferences(user_did); + `) + return err + }) + return &DB{ db, logger, diff --git a/appview/db/newsletter.go b/appview/db/newsletter.go new file mode 100644 index 00000000..b60a914f --- /dev/null +++ b/appview/db/newsletter.go @@ -0,0 +1,90 @@ +package db + +import ( + "database/sql" + "fmt" + "time" +) + +// Newsletter preference status values. Both states hide the signup widget; +// they're distinguished so we can later reconcile with Resend or re-prompt +// dismissed-but-not-subscribed users if we ever want to. +const ( + NewsletterStatusSubscribed = "subscribed" + NewsletterStatusDismissed = "dismissed" +) + +type NewsletterPref struct { + ID int64 + UserDid string + Status string + Email string + UpdatedAt time.Time +} + +// GetNewsletterPref returns the newsletter preference row for a user, or nil +// when no row exists (the caller should treat nil as "show the widget"). +func GetNewsletterPref(e Execer, did string) (*NewsletterPref, error) { + var ( + pref NewsletterPref + email sql.NullString + updatedAt string + ) + + err := e.QueryRow( + `select id, user_did, status, email, updated_at + from newsletter_preferences + where user_did = ?`, + did, + ).Scan(&pref.ID, &pref.UserDid, &pref.Status, &email, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if email.Valid { + pref.Email = email.String + } + // Best-effort: the column's default format is ISO-8601, but older rows + // (or manual inserts) might use other layouts. A parse failure is not + // fatal — the zero time is acceptable for a UI gating check. + if t, perr := time.Parse("2006-01-02T15:04:05Z", updatedAt); perr == nil { + pref.UpdatedAt = t + } + + return &pref, nil +} + +// UpsertNewsletterPref writes or replaces a user's newsletter preference and +// refreshes updated_at. Passing an empty email is fine — the column is +// nullable and is only meaningful for subscribed rows. +func UpsertNewsletterPref(e Execer, did, status, email string) error { + if status != NewsletterStatusSubscribed && status != NewsletterStatusDismissed { + return fmt.Errorf("invalid newsletter status %q", status) + } + + var emailArg any + if email != "" { + emailArg = email + } else { + emailArg = nil + } + + _, err := e.Exec( + `insert into newsletter_preferences (user_did, status, email, updated_at) + values (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + on conflict(user_did) do update set + status = excluded.status, + email = coalesce(excluded.email, newsletter_preferences.email), + updated_at = excluded.updated_at`, + did, + status, + emailArg, + ) + if err != nil { + return fmt.Errorf("upsert newsletter pref: %w", err) + } + return nil +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 15dff888..d86486f7 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -407,6 +407,11 @@ type TimelineParams struct { Repos []models.Repo GfiLabel *models.LabelDefinition BlueskyPosts []models.BskyPost + // ShowNewsletter controls whether the newsletter widget/CTA is rendered. + // For logged-in users it reflects their newsletter_preferences row; for + // anonymous visitors it is always true (dismissal falls back to + // localStorage on the client). + ShowNewsletter bool } func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { diff --git a/appview/pages/templates/timeline/home.html b/appview/pages/templates/timeline/home.html index a0ca103b..4e0ef24f 100644 --- a/appview/pages/templates/timeline/home.html +++ b/appview/pages/templates/timeline/home.html @@ -29,8 +29,7 @@ {{ template "features1" . }} {{ template "features2" . }} {{ template "recentUpdates" . }} - {{ template "newsletter" . }} - + {{ if .ShowNewsletter }}{{ template "newsletter" . }}{{ end }} {{ end }} diff --git a/appview/pages/templates/timeline/timeline.html b/appview/pages/templates/timeline/timeline.html index e2d6f344..03466cbd 100644 --- a/appview/pages/templates/timeline/timeline.html +++ b/appview/pages/templates/timeline/timeline.html @@ -26,19 +26,22 @@ row 1: [ gfi-banner (col-span-2) ] [ newsletter (col 3) ] row 2: [ timeline (col-span-2) ] [ trendingSidebar (col 3) ] - If the newsletter is dismissed, the gfi-banner widens to col-span-3 - (see the small init script at the bottom of this block). + When the newsletter is hidden (server-side via .ShowNewsletter, or + client-side via the dismiss button + localStorage), the gfi-banner + widens to col-span-3. */}}
+ {{ if .ShowNewsletter }}
{{ template "timeline/fragments/newsletterWidget" . }}
+ {{ end }}
+ class="order-2 lg:order-none lg:row-start-1 {{ if .ShowNewsletter }}lg:col-span-2{{ else }}lg:col-span-3 lg:max-w-4xl lg:mx-auto{{ end }}"> {{ template "timeline/fragments/goodfirstissues" . }}
@@ -55,6 +58,7 @@
+{{ if .ShowNewsletter }} {{ end }} +{{ end }} diff --git a/appview/state/router.go b/appview/state/router.go index 8825de85..df931238 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -151,6 +151,7 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Get("/timeline", s.Timeline) r.Get("/upgradeBanner", s.UpgradeBanner) r.Post("/newsletter/signup", s.NewsletterSignup) + r.Post("/newsletter/dismiss", s.NewsletterDismiss) // special-case handler for serving tangled.org/core r.Get("/core", s.Core()) diff --git a/appview/state/state.go b/appview/state/state.go index 1df87f24..9ac1abfc 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -347,6 +347,15 @@ func (s *State) NewsletterSignup(w http.ResponseWriter, r *http.Request) { return } + // For logged-in users, persist the signup locally so the widget stays + // hidden across devices. The DB row is the render-time source of truth; + // Resend still owns the mailing list itself. + if user := s.oauth.GetMultiAccountUser(r); user != nil { + if err := db.UpsertNewsletterPref(s.db, user.Did, db.NewsletterStatusSubscribed, emailAddr); err != nil { + s.logger.Error("failed to persist newsletter preference", "did", user.Did, "err", err) + } + } + if s.config.Resend.ApiKey != "" && s.config.Resend.NewsletterSegmentId != "" { go func() { if err := email.AddNewsletterContact(s.config.Resend.ApiKey, s.config.Resend.NewsletterSegmentId, emailAddr); err != nil { @@ -358,6 +367,22 @@ func (s *State) NewsletterSignup(w http.ResponseWriter, r *http.Request) { s.pages.NewsletterResponse(w, pages.NewsletterResponseParams{Id: target}) } +// NewsletterDismiss records that a logged-in user has dismissed the newsletter +// widget so it stays hidden across their devices. Anonymous callers get a 204 +// with no DB write — localStorage handles the per-browser fallback. +func (s *State) NewsletterDismiss(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetMultiAccountUser(r) + if user == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + if err := db.UpsertNewsletterPref(s.db, user.Did, db.NewsletterStatusDismissed, ""); err != nil { + s.logger.Error("failed to persist newsletter dismissal", "did", user.Did, "err", err) + } + w.WriteHeader(http.StatusNoContent) +} + func (s *State) Keys(w http.ResponseWriter, r *http.Request) { user := chi.URLParam(r, "user") user = strings.TrimPrefix(user, "@") diff --git a/appview/state/timeline.go b/appview/state/timeline.go index 39754a9c..ff27862c 100644 --- a/appview/state/timeline.go +++ b/appview/state/timeline.go @@ -4,6 +4,7 @@ import ( "net/http" "tangled.org/core/appview/db" + "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/orm" ) @@ -27,9 +28,10 @@ func (s *State) Home(w http.ResponseWriter, r *http.Request) { } s.pages.Home(w, pages.TimelineParams{ - LoggedInUser: user, - Timeline: timeline, - BlueskyPosts: blueskyPosts, + LoggedInUser: user, + Timeline: timeline, + BlueskyPosts: blueskyPosts, + ShowNewsletter: s.showNewsletter(user), }) } func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { @@ -69,9 +71,26 @@ func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { } s.pages.Timeline(w, pages.TimelineParams{ - LoggedInUser: user, - Timeline: timeline, - Repos: repos, - GfiLabel: gfiLabel, + LoggedInUser: user, + Timeline: timeline, + Repos: repos, + GfiLabel: gfiLabel, + ShowNewsletter: s.showNewsletter(user), }) } + +// showNewsletter decides whether the newsletter widget/CTA should render. +// Anonymous visitors always see it (they can dismiss via localStorage); +// logged-in users whose newsletter_preferences row exists (either +// subscribed or dismissed) do not. +func (s *State) showNewsletter(user *oauth.MultiAccountUser) bool { + if user == nil { + return true + } + pref, err := db.GetNewsletterPref(s.db, user.Did) + if err != nil { + s.logger.Error("failed to read newsletter preference", "did", user.Did, "err", err) + return true + } + return pref == nil +}