From 6c6ca65b2e841d28be771b6e3f3dfe281bb6af05 Mon Sep 17 00:00:00 2001 From: eti Date: Tue, 21 Apr 2026 17:45:24 +0200 Subject: [PATCH] appview: persist newsletter signup/dismiss per-user for cross-device hiding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the newsletter widget used only localStorage to remember whether a user had signed up or dismissed it, so the cta kept reappearing whenever a user opened tangled on another device or browser. for logged-in users, store the state in a newsletter_preferences table keyed on did with an enum status ('subscribed' | 'dismissed') and the email they gave us. the home and timeline handlers read this row to decide whether to render the widget, and the server-rendered gfi banner widens when the widget is gone so the grid doesn't leave an empty column. resend stays the source of truth for the mailing list itself (sending, bounces, one-click unsubscribes) — the new table only answers the render-time question 'should this did see the widget right now?', which resend cannot cheaply answer because it's keyed on email rather than did and would add a network hop to every timeline render. anonymous visitors keep the localStorage fallback. the client-side 'already dismissed in a past session' path deliberately only calls hide() (not dismiss()) so that a stale localStorage flag can't clobber a subscribed row set from another device. Signed-off-by: eti --- appview/db/db.go | 15 ++++ appview/db/newsletter.go | 90 +++++++++++++++++++ appview/pages/pages.go | 5 ++ appview/pages/templates/timeline/home.html | 3 +- .../pages/templates/timeline/timeline.html | 30 +++++-- appview/state/router.go | 1 + appview/state/state.go | 25 ++++++ appview/state/timeline.go | 33 +++++-- 8 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 appview/db/newsletter.go 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 +} -- 2.51.2