Monorepo for Tangled
0
fork

Configure Feed

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

appview: persist newsletter signup/dismiss per-user for cross-device hiding

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 <eti@eti.tf>

authored by

eti and committed by
Tangled
6c6ca65b 97ca49ba

+188 -14
+15
appview/db/db.go
··· 1409 1409 return err 1410 1410 }) 1411 1411 1412 + orm.RunMigration(conn, logger, "add-newsletter-preferences", func(tx *sql.Tx) error { 1413 + _, err := tx.Exec(` 1414 + create table if not exists newsletter_preferences ( 1415 + id integer primary key autoincrement, 1416 + user_did text not null unique, 1417 + status text not null check (status in ('subscribed', 'dismissed')), 1418 + email text, 1419 + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 1420 + ); 1421 + create index if not exists idx_newsletter_prefs_user_did 1422 + on newsletter_preferences(user_did); 1423 + `) 1424 + return err 1425 + }) 1426 + 1412 1427 return &DB{ 1413 1428 db, 1414 1429 logger,
+90
appview/db/newsletter.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "time" 7 + ) 8 + 9 + // Newsletter preference status values. Both states hide the signup widget; 10 + // they're distinguished so we can later reconcile with Resend or re-prompt 11 + // dismissed-but-not-subscribed users if we ever want to. 12 + const ( 13 + NewsletterStatusSubscribed = "subscribed" 14 + NewsletterStatusDismissed = "dismissed" 15 + ) 16 + 17 + type NewsletterPref struct { 18 + ID int64 19 + UserDid string 20 + Status string 21 + Email string 22 + UpdatedAt time.Time 23 + } 24 + 25 + // GetNewsletterPref returns the newsletter preference row for a user, or nil 26 + // when no row exists (the caller should treat nil as "show the widget"). 27 + func GetNewsletterPref(e Execer, did string) (*NewsletterPref, error) { 28 + var ( 29 + pref NewsletterPref 30 + email sql.NullString 31 + updatedAt string 32 + ) 33 + 34 + err := e.QueryRow( 35 + `select id, user_did, status, email, updated_at 36 + from newsletter_preferences 37 + where user_did = ?`, 38 + did, 39 + ).Scan(&pref.ID, &pref.UserDid, &pref.Status, &email, &updatedAt) 40 + if err == sql.ErrNoRows { 41 + return nil, nil 42 + } 43 + if err != nil { 44 + return nil, err 45 + } 46 + 47 + if email.Valid { 48 + pref.Email = email.String 49 + } 50 + // Best-effort: the column's default format is ISO-8601, but older rows 51 + // (or manual inserts) might use other layouts. A parse failure is not 52 + // fatal — the zero time is acceptable for a UI gating check. 53 + if t, perr := time.Parse("2006-01-02T15:04:05Z", updatedAt); perr == nil { 54 + pref.UpdatedAt = t 55 + } 56 + 57 + return &pref, nil 58 + } 59 + 60 + // UpsertNewsletterPref writes or replaces a user's newsletter preference and 61 + // refreshes updated_at. Passing an empty email is fine — the column is 62 + // nullable and is only meaningful for subscribed rows. 63 + func UpsertNewsletterPref(e Execer, did, status, email string) error { 64 + if status != NewsletterStatusSubscribed && status != NewsletterStatusDismissed { 65 + return fmt.Errorf("invalid newsletter status %q", status) 66 + } 67 + 68 + var emailArg any 69 + if email != "" { 70 + emailArg = email 71 + } else { 72 + emailArg = nil 73 + } 74 + 75 + _, err := e.Exec( 76 + `insert into newsletter_preferences (user_did, status, email, updated_at) 77 + values (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 78 + on conflict(user_did) do update set 79 + status = excluded.status, 80 + email = coalesce(excluded.email, newsletter_preferences.email), 81 + updated_at = excluded.updated_at`, 82 + did, 83 + status, 84 + emailArg, 85 + ) 86 + if err != nil { 87 + return fmt.Errorf("upsert newsletter pref: %w", err) 88 + } 89 + return nil 90 + }
+5
appview/pages/pages.go
··· 407 407 Repos []models.Repo 408 408 GfiLabel *models.LabelDefinition 409 409 BlueskyPosts []models.BskyPost 410 + // ShowNewsletter controls whether the newsletter widget/CTA is rendered. 411 + // For logged-in users it reflects their newsletter_preferences row; for 412 + // anonymous visitors it is always true (dismissal falls back to 413 + // localStorage on the client). 414 + ShowNewsletter bool 410 415 } 411 416 412 417 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
+1 -2
appview/pages/templates/timeline/home.html
··· 29 29 {{ template "features1" . }} 30 30 {{ template "features2" . }} 31 31 {{ template "recentUpdates" . }} 32 - {{ template "newsletter" . }} 33 - 32 + {{ if .ShowNewsletter }}{{ template "newsletter" . }}{{ end }} 34 33 </div> 35 34 {{ end }} 36 35
+25 -5
appview/pages/templates/timeline/timeline.html
··· 26 26 row 1: [ gfi-banner (col-span-2) ] [ newsletter (col 3) ] 27 27 row 2: [ timeline (col-span-2) ] [ trendingSidebar (col 3) ] 28 28 29 - If the newsletter is dismissed, the gfi-banner widens to col-span-3 30 - (see the small init script at the bottom of this block). 29 + When the newsletter is hidden (server-side via .ShowNewsletter, or 30 + client-side via the dismiss button + localStorage), the gfi-banner 31 + widens to col-span-3. 31 32 */}} 32 33 <div id="timeline-grid" 33 34 class="flex flex-col gap-4 lg:grid lg:grid-cols-3 lg:gap-x-2 lg:gap-y-6"> 34 35 36 + {{ if .ShowNewsletter }} 35 37 <div id="newsletter-col" 36 38 class="order-1 lg:order-none lg:col-start-3 lg:row-start-1 lg:pl-8"> 37 39 {{ template "timeline/fragments/newsletterWidget" . }} 38 40 </div> 41 + {{ end }} 39 42 40 43 <div id="gfi-banner" 41 - class="order-2 lg:order-none lg:col-span-2 lg:row-start-1"> 44 + 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 }}"> 42 45 {{ template "timeline/fragments/goodfirstissues" . }} 43 46 </div> 44 47 ··· 55 58 </div> 56 59 </div> 57 60 61 + {{ if .ShowNewsletter }} 58 62 <script> 59 63 (function() { 60 64 var DISMISS_KEY = 'newsletter-dismissed'; ··· 68 72 gfi.classList.add('lg:col-span-3', 'lg:max-w-4xl', 'lg:mx-auto'); 69 73 } 70 74 71 - function dismiss() { 75 + // hide removes the widget from the DOM without persisting anything. 76 + // Used when this browser's localStorage says we already dismissed in a 77 + // past session — the server has already told us .ShowNewsletter is true 78 + // (no DB row), so we deliberately do NOT re-POST /newsletter/dismiss 79 + // here: that would clobber a 'subscribed' row created from another 80 + // device after this localStorage entry was set. 81 + function hide() { 72 82 newsletterCol.remove(); 73 83 widenGfi(); 84 + } 85 + 86 + // dismiss is the user-initiated path. Persists both locally and (for 87 + // logged-in users) server-side so the widget stays hidden on all their 88 + // devices. 89 + function dismiss() { 90 + hide(); 74 91 try { localStorage.setItem(DISMISS_KEY, '1'); } catch (e) {} 92 + fetch('/newsletter/dismiss', { method: 'POST', credentials: 'same-origin' }) 93 + .catch(function () { /* localStorage is the fallback */ }); 75 94 } 76 95 77 96 try { 78 97 if (localStorage.getItem(DISMISS_KEY) === '1') { 79 - dismiss(); 98 + hide(); 80 99 return; 81 100 } 82 101 } catch (e) { /* storage disabled; keep widget visible */ } ··· 87 106 })(); 88 107 </script> 89 108 {{ end }} 109 + {{ end }}
+1
appview/state/router.go
··· 151 151 r.Get("/timeline", s.Timeline) 152 152 r.Get("/upgradeBanner", s.UpgradeBanner) 153 153 r.Post("/newsletter/signup", s.NewsletterSignup) 154 + r.Post("/newsletter/dismiss", s.NewsletterDismiss) 154 155 155 156 // special-case handler for serving tangled.org/core 156 157 r.Get("/core", s.Core())
+25
appview/state/state.go
··· 347 347 return 348 348 } 349 349 350 + // For logged-in users, persist the signup locally so the widget stays 351 + // hidden across devices. The DB row is the render-time source of truth; 352 + // Resend still owns the mailing list itself. 353 + if user := s.oauth.GetMultiAccountUser(r); user != nil { 354 + if err := db.UpsertNewsletterPref(s.db, user.Did, db.NewsletterStatusSubscribed, emailAddr); err != nil { 355 + s.logger.Error("failed to persist newsletter preference", "did", user.Did, "err", err) 356 + } 357 + } 358 + 350 359 if s.config.Resend.ApiKey != "" && s.config.Resend.NewsletterSegmentId != "" { 351 360 go func() { 352 361 if err := email.AddNewsletterContact(s.config.Resend.ApiKey, s.config.Resend.NewsletterSegmentId, emailAddr); err != nil { ··· 356 365 } 357 366 358 367 s.pages.NewsletterResponse(w, pages.NewsletterResponseParams{Id: target}) 368 + } 369 + 370 + // NewsletterDismiss records that a logged-in user has dismissed the newsletter 371 + // widget so it stays hidden across their devices. Anonymous callers get a 204 372 + // with no DB write — localStorage handles the per-browser fallback. 373 + func (s *State) NewsletterDismiss(w http.ResponseWriter, r *http.Request) { 374 + user := s.oauth.GetMultiAccountUser(r) 375 + if user == nil { 376 + w.WriteHeader(http.StatusNoContent) 377 + return 378 + } 379 + 380 + if err := db.UpsertNewsletterPref(s.db, user.Did, db.NewsletterStatusDismissed, ""); err != nil { 381 + s.logger.Error("failed to persist newsletter dismissal", "did", user.Did, "err", err) 382 + } 383 + w.WriteHeader(http.StatusNoContent) 359 384 } 360 385 361 386 func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
+26 -7
appview/state/timeline.go
··· 4 4 "net/http" 5 5 6 6 "tangled.org/core/appview/db" 7 + "tangled.org/core/appview/oauth" 7 8 "tangled.org/core/appview/pages" 8 9 "tangled.org/core/orm" 9 10 ) ··· 27 28 } 28 29 29 30 s.pages.Home(w, pages.TimelineParams{ 30 - LoggedInUser: user, 31 - Timeline: timeline, 32 - BlueskyPosts: blueskyPosts, 31 + LoggedInUser: user, 32 + Timeline: timeline, 33 + BlueskyPosts: blueskyPosts, 34 + ShowNewsletter: s.showNewsletter(user), 33 35 }) 34 36 } 35 37 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { ··· 69 71 } 70 72 71 73 s.pages.Timeline(w, pages.TimelineParams{ 72 - LoggedInUser: user, 73 - Timeline: timeline, 74 - Repos: repos, 75 - GfiLabel: gfiLabel, 74 + LoggedInUser: user, 75 + Timeline: timeline, 76 + Repos: repos, 77 + GfiLabel: gfiLabel, 78 + ShowNewsletter: s.showNewsletter(user), 76 79 }) 77 80 } 81 + 82 + // showNewsletter decides whether the newsletter widget/CTA should render. 83 + // Anonymous visitors always see it (they can dismiss via localStorage); 84 + // logged-in users whose newsletter_preferences row exists (either 85 + // subscribed or dismissed) do not. 86 + func (s *State) showNewsletter(user *oauth.MultiAccountUser) bool { 87 + if user == nil { 88 + return true 89 + } 90 + pref, err := db.GetNewsletterPref(s.db, user.Did) 91 + if err != nil { 92 + s.logger.Error("failed to read newsletter preference", "did", user.Did, "err", err) 93 + return true 94 + } 95 + return pref == nil 96 + }