Compare commits

..
21 Commits
Author SHA1 Message Date
binwiederhier c9105dad09 Release notes 2026-06-26 16:01:18 -04:00
binwiederhier 5765be7892 Root ref 2026-06-26 15:59:34 -04:00
binwiederhier 7a99bfc717 COndense comments, better prefcache use for theme 2026-06-26 07:58:28 -04:00
binwiederhier 156ad4ae92 Merge branch 'main' into transitions 2026-06-26 06:21:21 -04:00
binwiederhier a6dc691672 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-26 06:21:15 -04:00
binwiederhier 9114d1e996 Pref context 2026-06-26 06:20:58 -04:00
Rui Barbosa 55d871aeb2 Translated using Weblate (Portuguese)
Currently translated at 94.3% (433 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2026-06-26 05:16:49 +02:00
Hosted Weblate user 54392 a5318a4312 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-06-26 05:16:49 +02:00
109247019824 f77d299aa3 Translated using Weblate (Bulgarian)
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-06-26 05:16:49 +02:00
binwiederhier af66cc3ddc Undo theme weirdness 2026-06-25 22:40:34 -04:00
binwiederhier a47eef5006 Centralize fade out 2026-06-25 22:33:25 -04:00
binwiederhier 0ac7cbd4fe Merge branch 'main' of github.com:binwiederhier/ntfy into transitions 2026-06-25 21:52:58 -04:00
binwiederhier 72ac95148b Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-25 21:52:31 -04:00
Rui Barbosa 817a0ece7e Translated using Weblate (Portuguese)
Currently translated at 94.3% (433 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2026-06-26 03:52:11 +02:00
Hosted Weblate user 54392 e617c5f453 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-06-26 03:52:09 +02:00
109247019824 a04a12eb5f Translated using Weblate (Bulgarian)
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-06-26 03:52:09 +02:00
binwiederhier 46799db753 German translations 2026-06-25 21:44:34 -04:00
binwiederhier 5e32f05302 Read /account from primary 2026-06-25 20:24:50 -04:00
binwiederhier e3889746c4 Merge branch 'main' of github.com:binwiederhier/ntfy into transitions 2026-06-24 20:17:48 -04:00
binwiederhier 047a1258f1 Transitions 2026-06-24 17:08:32 -04:00
binwiederhier 2b1f2d6b9a WIP: Web app transitions 2026-06-24 15:27:40 -04:00
31 changed files with 696 additions and 128 deletions
+9
View File
@@ -90,6 +90,15 @@ func (d *DB) ReadOnly() *sql.DB {
return d.primary.DB return d.primary.DB
} }
// MarkReplicasHealthyForTest immediately marks all configured replicas as healthy, bypassing the
// async health-check loop's initial delay. It exists so tests can deterministically route
// ReadOnly() to a replica without waiting; it is not used in production code.
func (d *DB) MarkReplicasHealthyForTest() {
for _, r := range d.replicas {
r.healthy.Store(true)
}
}
// Close closes the primary database and all replicas, and stops the health-check goroutine. // Close closes the primary database and all replicas, and stops the health-check goroutine.
func (d *DB) Close() error { func (d *DB) Close() error {
d.cancel() d.cancel()
+7
View File
@@ -1983,6 +1983,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy server v2.26.x (UNRELEASED)
**Bug fixes + maintenance:**
* Web app: Smooth transitions and loading animation, remove flickering
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
### ntfy Android v1.25.x (UNRELEASED) ### ntfy Android v1.25.x (UNRELEASED)
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
+4 -8
View File
@@ -180,10 +180,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
if err != nil { if err != nil {
return err return err
} }
primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
if err != nil {
return err
}
pendingEmails, err := s.userManager.PendingEmails(u.ID) pendingEmails, err := s.userManager.PendingEmails(u.ID)
if err != nil { if err != nil {
return err return err
@@ -191,7 +187,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
// Combine verified (with primary flag) and pending (unverified) into one list // Combine verified (with primary flag) and pending (unverified) into one list
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails)) emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
for _, email := range emails { for _, email := range emails {
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Primary: email == primaryEmail}) emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email.Address, Primary: email.Primary})
} }
for _, email := range pendingEmails { for _, email := range pendingEmails {
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true}) emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
@@ -667,7 +663,7 @@ func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v
emails, err := s.userManager.Emails(u.ID) emails, err := s.userManager.Emails(u.ID)
if err != nil { if err != nil {
return err return err
} else if util.Contains(emails, req.Email) { } else if emails.Contains(req.Email) {
return errHTTPConflictEmailExists return errHTTPConflictEmailExists
} }
// Rate limit (counts against the user's email quota) // Rate limit (counts against the user's email quota)
@@ -888,7 +884,7 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
if err != nil { if err != nil {
return "", errHTTPInternalError return "", errHTTPInternalError
} else if len(emails) > 0 { } else if len(emails) > 0 {
return emails[0], nil return emails[0].Address, nil
} }
return "", errHTTPBadRequestEmailAddressNotVerified return "", errHTTPBadRequestEmailAddressNotVerified
} }
@@ -903,7 +899,7 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
emails, err := s.userManager.Emails(u.ID) emails, err := s.userManager.Emails(u.ID)
if err != nil { if err != nil {
return "", errHTTPInternalError return "", errHTTPInternalError
} else if util.Contains(emails, email) { } else if emails.Contains(email) {
return email, nil return email, nil
} }
return "", errHTTPBadRequestEmailAddressNotVerified return "", errHTTPBadRequestEmailAddressNotVerified
+1 -1
View File
@@ -72,7 +72,7 @@ func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
emails, err := s.userManager.Emails(u.ID) emails, err := s.userManager.Emails(u.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []string{"billing@example.com"}, emails) require.Equal(t, []string{"billing@example.com"}, emails.Strings())
primary, err := s.userManager.PrimaryEmail(u.ID) primary, err := s.userManager.PrimaryEmail(u.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "billing@example.com", primary) require.Equal(t, "billing@example.com", primary)
+26 -16
View File
@@ -941,7 +941,9 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
// Reservations returns all user-owned topics, and the associated everyone-access // Reservations returns all user-owned topics, and the associated everyone-access
func (a *Manager) Reservations(username string) ([]Reservation, error) { func (a *Manager) Reservations(username string) ([]Reservation, error) {
return a.reservationsTx(a.db.ReadOnly(), username) // Read from the primary, not a replica: this backs GET /account, which the web app refetches
// immediately after a sync event. Replication lag would otherwise show stale data.
return a.reservationsTx(a.db, username)
} }
func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) { func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {
@@ -1204,7 +1206,8 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
// Tokens returns all existing tokens for the user with the given user ID // Tokens returns all existing tokens for the user with the given user ID
func (a *Manager) Tokens(userID string) ([]*Token, error) { func (a *Manager) Tokens(userID string) ([]*Token, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID) // Primary read: backs GET /account (read-your-writes after a sync event).
rows, err := a.db.Query(a.queries.selectTokens, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1417,7 +1420,8 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
// PhoneNumbers returns all phone numbers for the user with the given user ID // PhoneNumbers returns all phone numbers for the user with the given user ID
func (a *Manager) PhoneNumbers(userID string) ([]string, error) { func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectPhoneNumbers, userID) // Primary read: backs GET /account (read-your-writes after a sync event).
rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1465,14 +1469,17 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
return phoneNumber, nil return phoneNumber, nil
} }
// Emails returns all verified email addresses for the user with the given user ID // Emails returns all verified email addresses for the user with the given user ID, each carrying
func (a *Manager) Emails(userID string) ([]string, error) { // whether it is the primary (recovery) address. Because the primary flag is included, callers that
rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID) // need it (e.g. the account view) do not need a separate PrimaryEmail call.
func (a *Manager) Emails(userID string) (Emails, error) {
// Primary read: backs GET /account (read-your-writes after a sync event).
rows, err := a.db.Query(a.queries.selectEmails, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
emails := make([]string, 0) emails := make(Emails, 0)
for { for {
email, err := a.readEmail(rows) email, err := a.readEmail(rows)
if errors.Is(err, ErrEmailNotFound) { if errors.Is(err, ErrEmailNotFound) {
@@ -1508,7 +1515,8 @@ func (a *Manager) RemoveEmail(userID, email string) error {
// the user has not designated one. // the user has not designated one.
func (a *Manager) PrimaryEmail(userID string) (string, error) { func (a *Manager) PrimaryEmail(userID string) (string, error) {
var email sql.NullString var email sql.NullString
err := a.db.ReadOnly().QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email) // Primary read: backs GET /account (read-your-writes after a sync event).
err := a.db.QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return "", nil return "", nil
} else if err != nil { } else if err != nil {
@@ -1534,7 +1542,8 @@ func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) {
// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with // PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with
// an outstanding email-verification magic link. // an outstanding email-verification magic link.
func (a *Manager) PendingEmails(userID string) ([]string, error) { func (a *Manager) PendingEmails(userID string) ([]string, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID) // Primary read: backs GET /account (read-your-writes after a sync event).
rows, err := a.db.Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1756,17 +1765,18 @@ func (a *Manager) deleteExpiredMagicLinks() error {
return err return err
} }
func (a *Manager) readEmail(rows *sql.Rows) (string, error) { func (a *Manager) readEmail(rows *sql.Rows) (*Email, error) {
var email string var address string
var primary bool
if !rows.Next() { if !rows.Next() {
return "", ErrEmailNotFound return nil, ErrEmailNotFound
} }
if err := rows.Scan(&email); err != nil { if err := rows.Scan(&address, &primary); err != nil {
return "", err return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return "", err return nil, err
} }
return email, nil return &Email{Address: address, Primary: primary}, nil
} }
// ChangeBilling updates a user's billing fields // ChangeBilling updates a user's billing fields
+1 -1
View File
@@ -217,7 +217,7 @@ const (
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
// Email queries // Email queries
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email` postgresSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = $1 ORDER BY email`
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)` postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING` postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING`
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2` postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
+1 -1
View File
@@ -214,7 +214,7 @@ const (
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
// Email queries // Email queries
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email` sqliteSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = ? ORDER BY email`
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING` sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING`
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
+100 -6
View File
@@ -1157,7 +1157,7 @@ func TestUser_EmailAddListRemove(t *testing.T) {
emails, err := a.Emails(phil.ID) emails, err := a.Emails(phil.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 1, len(emails)) require.Equal(t, 1, len(emails))
require.Equal(t, "phil@example.com", emails[0]) require.Equal(t, "phil@example.com", emails[0].Address)
require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com")) require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com"))
emails, err = a.Emails(phil.ID) emails, err = a.Emails(phil.ID)
@@ -2701,7 +2701,7 @@ func TestStoreEmails(t *testing.T) {
emails, err = manager.Emails(u.ID) emails, err = manager.Emails(u.ID)
require.Nil(t, err) require.Nil(t, err)
require.Len(t, emails, 1) require.Len(t, emails, 1)
require.Equal(t, "phil2@example.com", emails[0]) require.Equal(t, "phil2@example.com", emails[0].Address)
}) })
} }
@@ -2871,7 +2871,7 @@ func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) {
emails, err = a.Emails(phil.ID) emails, err = a.Emails(phil.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []string{"phil@example.com"}, emails) require.Equal(t, []string{"phil@example.com"}, emails.Strings())
primary, err = a.PrimaryEmail(phil.ID) primary, err = a.PrimaryEmail(phil.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "phil@example.com", primary) require.Equal(t, "phil@example.com", primary)
@@ -2904,7 +2904,7 @@ func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) {
// Both verified, but primary is still the first // Both verified, but primary is still the first
emails, err := a.Emails(phil.ID) emails, err := a.Emails(phil.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []string{"first@example.com", "second@example.com"}, emails) require.Equal(t, []string{"first@example.com", "second@example.com"}, emails.Strings())
primary, err := a.PrimaryEmail(phil.ID) primary, err := a.PrimaryEmail(phil.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "first@example.com", primary) require.Equal(t, "first@example.com", primary)
@@ -2933,7 +2933,7 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
emails, err := a.Emails(ben.ID) emails, err := a.Emails(ben.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []string{"shared@example.com"}, emails) require.Equal(t, []string{"shared@example.com"}, emails.Strings())
primary, err = a.PrimaryEmail(ben.ID) primary, err = a.PrimaryEmail(ben.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "", primary) require.Equal(t, "", primary)
@@ -3168,7 +3168,7 @@ func TestUser_MagicLink_VerifyEmail_ProvisionedGetsPrimary(t *testing.T) {
emails, err := a.Emails(prov.ID) emails, err := a.Emails(prov.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, []string{"prov@example.com"}, emails) require.Equal(t, []string{"prov@example.com"}, emails.Strings())
primary, err := a.PrimaryEmail(prov.ID) primary, err := a.PrimaryEmail(prov.ID)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "prov@example.com", primary) require.Equal(t, "prov@example.com", primary)
@@ -3219,3 +3219,97 @@ func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) {
require.ErrorIs(t, err, ErrUserNotFound) require.ErrorIs(t, err, ErrUserNotFound)
}) })
} }
func TestManager_Emails_PrimaryFlagAndHelpers(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
u, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(u.ID, "a@example.com"))
require.Nil(t, a.AddEmail(u.ID, "b@example.com"))
require.Nil(t, a.SetPrimaryEmail(u.ID, "b@example.com"))
// Emails() carries the primary flag, so a separate PrimaryEmail() call is unnecessary.
emails, err := a.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 2)
require.Equal(t, "a@example.com", emails[0].Address) // ORDER BY email
require.False(t, emails[0].Primary)
require.Equal(t, "b@example.com", emails[1].Address)
require.True(t, emails[1].Primary)
// Helper methods for the address-only callers
require.Equal(t, []string{"a@example.com", "b@example.com"}, emails.Strings())
require.True(t, emails.Contains("a@example.com"))
require.False(t, emails.Contains("c@example.com"))
})
}
// openReplicaTestSQLite opens a fresh SQLite database file with the user schema applied.
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
require.Nil(t, err)
require.Nil(t, setupSQLite(d))
return d
}
// TestManager_AccountReadsUsePrimary verifies that the per-user reads backing the GET /account
// endpoint read from the primary, not from a read replica. The sync event ("something changed")
// is published immediately after a write, so a replica that lags would make the account view
// stale right after the user changes it. These reads must therefore be read-your-writes consistent.
//
// The test wires up a primary and a deliberately-empty replica (simulating replication lag),
// forces the replica healthy so ReadOnly() would route to it, writes everything to the primary,
// and asserts the reads still observe the fresh primary data.
func TestManager_AccountReadsUsePrimary(t *testing.T) {
dir := t.TempDir()
primaryDB := openReplicaTestSQLite(t, filepath.Join(dir, "primary.db"))
replicaDB := openReplicaTestSQLite(t, filepath.Join(dir, "replica.db")) // intentionally left empty
pool := db.New(&db.Host{DB: primaryDB}, []*db.Host{{DB: replicaDB}})
pool.MarkReplicasHealthyForTest() // force ReadOnly() to route to the stale replica
a, err := newManager(pool, sqliteQueries, &Config{BcryptCost: bcrypt.MinCost})
require.Nil(t, err)
t.Cleanup(func() { a.Close() })
// All writes below go to the primary; the replica stays empty.
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
u, err := a.User("phil")
require.Nil(t, err)
_, err = a.CreateToken(u.ID, "test token", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err)
require.Nil(t, a.AddReservation("phil", "mytopic", PermissionDenyAll, 10))
require.Nil(t, a.AddPhoneNumber(u.ID, "+12223334444"))
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, a.SetPrimaryEmail(u.ID, "phil@example.com"))
_, err = a.AddMagicLink(MagicLinkKindEmailVerify, u.ID, "pending@example.com", time.Hour)
require.Nil(t, err)
// Each read must observe the just-written primary data, NOT the empty replica.
tokens, err := a.Tokens(u.ID)
require.Nil(t, err)
require.Len(t, tokens, 1)
reservations, err := a.Reservations("phil")
require.Nil(t, err)
require.Len(t, reservations, 1)
phoneNumbers, err := a.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Len(t, phoneNumbers, 1)
emails, err := a.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 1)
require.Equal(t, "phil@example.com", emails[0].Address)
require.True(t, emails[0].Primary)
primaryEmail, err := a.PrimaryEmail(u.ID)
require.Nil(t, err)
require.Equal(t, "phil@example.com", primaryEmail)
pendingEmails, err := a.PendingEmails(u.ID)
require.Nil(t, err)
require.Len(t, pendingEmails, 1)
}
+30
View File
@@ -182,6 +182,36 @@ type Reservation struct {
Everyone Permission Everyone Permission
} }
// Email is a verified email address on a user account, along with whether it is the user's
// designated primary (recovery) address.
type Email struct {
Address string
Primary bool
}
// Emails is a list of verified email addresses for a user.
type Emails []*Email
// Strings returns just the address strings, in the same order. It is a convenience for callers
// that only care about the addresses and not the primary flag.
func (e Emails) Strings() []string {
addresses := make([]string, len(e))
for i, email := range e {
addresses[i] = email.Address
}
return addresses
}
// Contains reports whether the given address is in the list.
func (e Emails) Contains(address string) bool {
for _, email := range e {
if email.Address == address {
return true
}
}
return false
}
// Permission represents a read or write permission to a topic // Permission represents a read or write permission to a topic
type Permission uint8 type Permission uint8
+85 -1
View File
@@ -14,7 +14,7 @@
<meta name="msapplication-navbutton-color" content="#317f6f" /> <meta name="msapplication-navbutton-color" content="#317f6f" />
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" /> <meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" /> <link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
<link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" /> <link rel="mask-icon" href="/static/images/ntfy-mask.svg" color="#317f6f" />
<!-- Favicon, see favicon.io --> <!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="/static/images/favicon.ico" /> <link rel="icon" type="image/png" href="/static/images/favicon.ico" />
@@ -44,6 +44,85 @@
<!-- PWA --> <!-- PWA -->
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<!-- Splash: painted before the JS bundle loads, faded out by the app once ready (see
src/app/splash.js). Background matches MUI's grey[100]/grey[900] for a seamless handoff. -->
<style>
html {
background-color: #f5f5f5;
}
html.dark {
background-color: #212121;
}
#splash {
position: fixed;
inset: 0;
z-index: 200000;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
opacity: 1;
/* Background fade = the app fading in once the logo is gone (see src/app/splash.js). */
transition: opacity 0.1s ease-out;
}
html.dark #splash {
background-color: #212121;
}
#splash.splash-hidden {
opacity: 0;
pointer-events: none;
}
#splash img {
width: 112px;
height: 112px;
/* Gently pulse while loading; src/app/splash.js stops this and fades the logo out. */
animation: splash-pulse 1.4s ease-in-out infinite;
}
@keyframes splash-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
#splash {
transition: none;
}
#splash img {
animation: none;
}
}
</style>
<!-- Decide light/dark before first paint from the "prefcache" blob (written by PrefCache.jsx) --
avoids the async-IndexedDB theme flash. Keep the key in sync with PrefCache.jsx. -->
<script>
(function () {
try {
var cache = JSON.parse(localStorage.getItem("prefcache"));
var theme = cache && cache.theme;
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
var dark = theme === "dark" || ((!theme || theme === "system") && prefersDark);
if (dark) {
document.documentElement.classList.add("dark");
}
} catch (e) {
/* localStorage/matchMedia/JSON unavailable -- fall back to the default light splash */
}
})();
</script>
</head> </head>
<body> <body>
<noscript> <noscript>
@@ -51,6 +130,11 @@
<a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
subscribe. subscribe.
</noscript> </noscript>
<!-- Static splash, removed by src/app/splash.js once ready. Logo is a same-origin SVG (precached by the SW). -->
<div id="splash" aria-hidden="true">
<img src="/static/images/ntfy-splash.svg" alt="" />
</div>
<div id="root"></div> <div id="root"></div>
<script src="/config.js"></script> <script src="/config.js"></script>
<script type="module" src="/src/index.jsx"></script> <script type="module" src="/src/index.jsx"></script>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="#9a9a9a">
<g transform="translate(-51.451771,-87.327048)">
<path d="m 59.291677,93.677052 c -3.579993,0 -6.646873,2.817003 -6.646873,6.398338 v 0.003 l 0.03508,27.86677 -0.899113,6.63475 12.226096,-3.24797 H 94.40052 c 3.579985,0 6.64687,-2.82079 6.64687,-6.40216 v -24.85449 c 0,-3.580312 -3.065184,-6.39668 -6.643822,-6.398338 h -0.0031 z m 0,4.516205 h 35.108844 0.0031 c 1.257851,0.0013 2.12767,0.916373 2.12767,1.882133 v 24.85442 c 0,0.9666 -0.871353,1.88213 -2.13072,1.88213 H 63.344139 l -6.211425,1.87679 0.0633,-0.36604 -0.03431,-28.2473 c 0,-0.966516 0.870609,-1.882133 2.129956,-1.882133 z" />
<g transform="matrix(2.1452134,0,0,2.5503116,-71.247407,-178.388)">
<path d="m 62.57046,116.77004 v -1.31201 l 3.280018,-1.45904 q 0.158346,-0.0679 0.305381,-0.1018 0.158346,-0.0452 0.282761,-0.0679 0.135725,-0.0113 0.271449,-0.0226 v -0.0905 q -0.135724,-0.0113 -0.271449,-0.0452 -0.124415,-0.0226 -0.282761,-0.0566 -0.147035,-0.0452 -0.305381,-0.1131 l -3.280018,-1.45904 v -1.32332 l 5.067063,2.31863 v 1.4138 z" />
<path d="m 62.308594,110.31055 v 1.90234 l 3.4375,1.5293 c 0.0073,0.003 0.0142,0.005 0.02148,0.008 -0.0073,0.003 -0.0142,0.005 -0.02148,0.008 l -3.4375,1.5293 v 1.89258 l 0.371093,-0.16992 5.220704,-2.39063 v -1.75 z m 0.52539,0.8164 4.541016,2.08008 v 1.07617 l -4.541016,2.07813 v -0.73242 l 3.119141,-1.38868 0.0039,-0.002 c 0.09141,-0.0389 0.178343,-0.0676 0.257813,-0.0859 h 0.0059 l 0.0078,-0.002 c 0.09483,-0.0271 0.176055,-0.0474 0.246093,-0.0606 l 0.498047,-0.041 v -0.57422 l -0.240234,-0.0195 c -0.07606,-0.006 -0.153294,-0.0198 -0.230469,-0.0391 l -0.0078,-0.002 -0.0078,-0.002 c -0.07608,-0.0138 -0.16556,-0.0318 -0.263672,-0.0527 -0.08398,-0.0262 -0.172736,-0.058 -0.265625,-0.0977 l -0.0039,-0.002 -3.119141,-1.38868 z" />
</g>
<g transform="matrix(2.1388566,0,0,2.4558588,-69.745456,-170.93962)">
<path d="m 69.17132,117.75404 h 5.428996 v 1.27808 H 69.17132 Z" />
<path d="m 68.908203,117.49219 v 0.26172 1.54101 h 5.955078 v -1.80273 z m 0.525391,0.52344 h 4.904297 v 0.7539 h -4.904297 z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+3 -1
View File
@@ -405,5 +405,7 @@
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy", "web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
"action_bar_unmute_notifications": "Включване звука на известията", "action_bar_unmute_notifications": "Включване звука на известията",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Кодът за защита от външна система не може да бъде променян или премахван", "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Кодът за защита от външна система не може да бъде променян или премахван",
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван" "account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван",
"common_close": "Затваряне",
"common_refresh": "Презареждане"
} }
+55 -1
View File
@@ -405,5 +405,59 @@
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest", "web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)", "prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden", "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden",
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden" "account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden",
"common_close": "Schließen",
"common_refresh": "Aktualisieren",
"email_verify_progress_title": "Deine E-Mail-Adresse wird verifiziert...",
"email_verify_success_title": "E-Mail-Adresse verifiziert",
"email_verify_success_description": "Deine E-Mail-Adresse wurde verifiziert und Deinem Konto hinzugefügt.",
"email_verify_error_title": "Verifizierung fehlgeschlagen",
"email_verify_error_description": "Dieser Verifizierungslink ist ungültig oder abgelaufen. Du kannst in Deinen Konto-Einstellungen einen neuen anfordern.",
"email_verify_button_account": "Zum Konto",
"version_update_available_title": "Neue Version verfügbar",
"version_update_available_description": "Der ntfy-Server wurde aktualisiert. Bitte lade die Seite neu.",
"signup_form_email": "E-Mail (optional, zur Konto-Wiederherstellung)",
"login_link_forgot_password": "Kennwort vergessen",
"reset_password_request_title": "Kennwort zurücksetzen",
"reset_password_request_description": "Gib Deinen Benutzernamen oder Deine E-Mail-Adresse ein. Falls ein Konto existiert, wird ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
"reset_password_request_primary_required": "Dies funktioniert nur, wenn Du bereits eine primäre E-Mail-Adresse hinzugefügt und verifiziert hast.",
"reset_password_request_identifier_label": "Benutzername oder E-Mail",
"reset_password_request_button_submit": "Link zum Zurücksetzen senden",
"reset_password_sent_title": "Überprüfe Deinen Posteingang",
"reset_password_sent_description": "Falls ein Konto existiert, wurde ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
"reset_password_back_to_login": "Zurück zur Anmeldung",
"reset_password_disabled": "Das Zurücksetzen des Kennworts ist deaktiviert",
"reset_password_title": "Neues Kennwort festlegen",
"reset_password_form_password": "Neues Kennwort",
"reset_password_form_confirm": "Neues Kennwort bestätigen",
"reset_password_form_button_submit": "Kennwort festlegen",
"reset_password_form_error_invalid": "Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
"reset_password_success_title": "Kennwort geändert",
"reset_password_success_description": "Dein Kennwort wurde geändert. Du kannst Dich nun mit Deinem neuen Kennwort anmelden.",
"action_bar_reload": "App neu laden",
"account_basics_emails_title": "E-Mail-Adressen",
"account_basics_emails_description": "Für E-Mail-Benachrichtigungen und das Zurücksetzen des Kennworts",
"account_basics_emails_no_emails_yet": "Noch keine E-Mail-Adressen",
"account_basics_emails_copied_to_clipboard": "E-Mail-Adresse in die Zwischenablage kopiert",
"account_basics_emails_chip_actions_primary": "Primäre Adresse, wird als Deine standardmäßige E-Mail-Adresse verwendet. Klicke für Aktionen.",
"account_basics_emails_chip_actions_verified": "Kann für Benachrichtigungen verwendet werden. Klicke für Aktionen.",
"account_basics_emails_chip_actions_unverified": "Nicht verifizierte Adresse, überprüfe Deinen Posteingang, um sie zu verifizieren. Klicke für Aktionen.",
"account_basics_emails_unverified": "nicht verifiziert",
"account_basics_emails_set_primary": "Als primäre E-Mail-Adresse festlegen",
"account_basics_emails_delete": "Adresse entfernen",
"account_basics_emails_resend": "Verifizierungs-E-Mail erneut senden",
"account_basics_emails_resent": "Verifizierungs-E-Mail gesendet, überprüfe Deinen Posteingang",
"account_basics_emails_primary_elsewhere": "Diese E-Mail-Adresse wird als primäre Adresse eines anderen Kontos verwendet",
"account_basics_emails_no_recovery_warning": "Füge mindestens eine E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
"account_basics_emails_no_primary_warning": "Füge eine primäre E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
"account_basics_emails_dialog_title": "E-Mail-Adresse hinzufügen",
"account_basics_emails_dialog_description": "Gib eine E-Mail-Adresse ein, um sie Deinem Konto hinzuzufügen. Es wird ein Verifizierungslink gesendet, um zu bestätigen, dass sie Dir gehört.",
"account_basics_emails_dialog_email_label": "E-Mail-Adresse",
"account_basics_emails_dialog_email_placeholder": "z.B. user@example.com",
"account_basics_emails_dialog_verify_button": "Verifizierungslink senden",
"account_basics_emails_dialog_check_inbox": "Überprüfe Deinen Posteingang und klicke auf den Verifizierungslink, um diese E-Mail-Adresse zu bestätigen. Sie wird als nicht verifiziert angezeigt, bis Du dies tust.",
"account_basics_tier_provisioned": "Bereitgestellt",
"account_usage_emails_none": "Mit diesem Konto können keine E-Mail-Benachrichtigungen gesendet werden",
"prefs_users_dialog_base_url_invalid": "Ungültiges URL-Format. Muss mit http:// oder https:// beginnen",
"prefs_users_dialog_base_url_exists": "Für diese Service-URL existiert bereits ein Benutzer"
} }
+32 -2
View File
@@ -215,7 +215,7 @@
"action_bar_reservation_add": "Reservar tópico", "action_bar_reservation_add": "Reservar tópico",
"action_bar_sign_up": "Registar", "action_bar_sign_up": "Registar",
"nav_button_account": "Conta", "nav_button_account": "Conta",
"common_copy_to_clipboard": "Copiar à área de transferência", "common_copy_to_clipboard": "Copiar para a área de transferência",
"nav_upgrade_banner_label": "Upgrade para ntfy Pro", "nav_upgrade_banner_label": "Upgrade para ntfy Pro",
"alert_not_supported_context_description": "As notificações são apenas suportadas através de HTTPS. Isto é uma limitação da <mdnLink>Notifications API</mdnLink>.", "alert_not_supported_context_description": "As notificações são apenas suportadas através de HTTPS. Isto é uma limitação da <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_title": "Alterar o nome público", "display_name_dialog_title": "Alterar o nome público",
@@ -406,5 +406,35 @@
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor", "web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web", "web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido", "account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido" "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido",
"common_close": "Fechar",
"common_refresh": "Atualizar",
"email_verify_progress_title": "A verificar o email...",
"email_verify_success_title": "Email verificado",
"email_verify_success_description": "O seu email foi verificado e adicionado à sua conta.",
"email_verify_error_title": "Verificação falhada",
"email_verify_error_description": "Este link de verificação é inválido ou expirou. Pode pedir um novo nas definições da sua conta.",
"email_verify_button_account": "Ir para conta",
"version_update_available_title": "Nova sessão disponível",
"version_update_available_description": "O servidor ntfy foi atualizado. Por favor atualiza a página.",
"signup_form_email": "Email (opcional, para recuperação de conta)",
"login_link_forgot_password": "Esqueci a palavra-passe",
"reset_password_request_title": "Redefinir palavra-passe",
"reset_password_request_description": "Introduza o seu nome de utilizador ou email. Se a conta existir, um link de redefinição de palavra-passe será enviado para o email.",
"reset_password_request_primary_required": "Isto apenas funciona se adicionar e verificar um email principal.",
"reset_password_request_identifier_label": "Nome de utilizador ou email",
"reset_password_request_button_submit": "Enviar link de redefinição",
"reset_password_sent_title": "Verifique a caixa de entrada",
"reset_password_sent_description": "Se a conta existir, um link de redefinição de palavra-passe foi enviado.",
"reset_password_back_to_login": "Voltar a início de sessão",
"reset_password_disabled": "Redefinição de palavra-passe desativada",
"reset_password_title": "Definir uma nova palavra-passe",
"reset_password_form_password": "Nova palavra-passe",
"reset_password_form_confirm": "Confirme a nova palavra-passe",
"reset_password_form_button_submit": "Definir palavra-passe",
"reset_password_form_error_invalid": "O link de redefinição é invalido ou expirou. Por favor peça um novo.",
"reset_password_success_title": "Palavra-passe alterada",
"reset_password_success_description": "A sua palavra passe foi alterada. Pode agora iniciar sessão com a nova palavra passe.",
"action_bar_reload": "Reiniciar aplicação",
"account_basics_emails_title": "Endereços de email"
} }
+2 -2
View File
@@ -247,8 +247,8 @@
"account_basics_tier_title": "账户类型", "account_basics_tier_title": "账户类型",
"account_basics_tier_description": "您账户的权限级别", "account_basics_tier_description": "您账户的权限级别",
"account_basics_tier_admin": "管理员", "account_basics_tier_admin": "管理员",
"account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等级)", "account_basics_tier_admin_suffix_with_tier": "等级为 {{tier}}",
"account_basics_tier_admin_suffix_no_tier": "(无等级)", "account_basics_tier_admin_suffix_no_tier": "无等级",
"account_basics_tier_basic": "基础版", "account_basics_tier_basic": "基础版",
"account_basics_tier_free": "免费", "account_basics_tier_free": "免费",
"account_basics_tier_upgrade_button": "升级到专业版", "account_basics_tier_upgrade_button": "升级到专业版",
+14 -5
View File
@@ -6,6 +6,15 @@ export const THEME = {
SYSTEM: "system", SYSTEM: "system",
}; };
// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx).
export const PREF_DEFAULTS = {
sound: "ding",
minPriority: 1,
deleteAfter: 604800, // one week
theme: THEME.SYSTEM,
webPushEnabled: false,
};
class Prefs { class Prefs {
constructor(dbImpl) { constructor(dbImpl) {
this.db = dbImpl; this.db = dbImpl;
@@ -17,7 +26,7 @@ class Prefs {
async sound() { async sound() {
const sound = await this.db.prefs.get("sound"); const sound = await this.db.prefs.get("sound");
return sound ? sound.value : "ding"; return sound ? sound.value : PREF_DEFAULTS.sound;
} }
async setMinPriority(minPriority) { async setMinPriority(minPriority) {
@@ -26,7 +35,7 @@ class Prefs {
async minPriority() { async minPriority() {
const minPriority = await this.db.prefs.get("minPriority"); const minPriority = await this.db.prefs.get("minPriority");
return minPriority ? Number(minPriority.value) : 1; return minPriority ? Number(minPriority.value) : PREF_DEFAULTS.minPriority;
} }
async setDeleteAfter(deleteAfter) { async setDeleteAfter(deleteAfter) {
@@ -35,12 +44,12 @@ class Prefs {
async deleteAfter() { async deleteAfter() {
const deleteAfter = await this.db.prefs.get("deleteAfter"); const deleteAfter = await this.db.prefs.get("deleteAfter");
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week return deleteAfter ? Number(deleteAfter.value) : PREF_DEFAULTS.deleteAfter;
} }
async webPushEnabled() { async webPushEnabled() {
const webPushEnabled = await this.db.prefs.get("webPushEnabled"); const webPushEnabled = await this.db.prefs.get("webPushEnabled");
return webPushEnabled?.value; return webPushEnabled?.value ?? PREF_DEFAULTS.webPushEnabled;
} }
async setWebPushEnabled(enabled) { async setWebPushEnabled(enabled) {
@@ -49,7 +58,7 @@ class Prefs {
async theme() { async theme() {
const theme = await this.db.prefs.get("theme"); const theme = await this.db.prefs.get("theme");
return theme?.value ?? THEME.SYSTEM; return theme?.value ?? PREF_DEFAULTS.theme;
} }
async setTheme(mode) { async setTheme(mode) {
+7 -1
View File
@@ -1,4 +1,5 @@
import Dexie from "dexie"; import Dexie from "dexie";
import { fadeOut } from "./transition";
/** /**
* Manages the logged-in user's session and access token. * Manages the logged-in user's session and access token.
@@ -42,7 +43,12 @@ class Session {
localStorage.setItem("token", token); localStorage.setItem("token", token);
} }
async resetAndRedirect(url) { async resetAndRedirect(url, { fade = false } = {}) {
// User-initiated exits (logout, account deletion) fade out first, while data's intact, so the
// wipe + reload doesn't flash a broken UI. Error redirects pass no options and cut straight through.
if (fade) {
await fadeOut();
}
await this.db.delete(); await this.db.delete();
localStorage.removeItem("user"); localStorage.removeItem("user");
localStorage.removeItem("token"); localStorage.removeItem("token");
+1 -1
View File
@@ -49,7 +49,7 @@ export const isImage = (attachment) => {
}; };
export const icon = "/static/images/ntfy.png"; export const icon = "/static/images/ntfy.png";
export const badge = "/static/images/mask-icon.svg"; export const badge = "/static/images/ntfy-mask.svg";
/** /**
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID. * Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
+58
View File
@@ -0,0 +1,58 @@
// Fades out and removes the static splash (see web/index.html), called once the app has mounted and
// its data is ready. Idempotent.
// Minimum time the splash stays up, so it doesn't flash-and-vanish on warm-cache loads.
const MIN_VISIBLE_MS = 1000;
// Hide in two phases: fade the logo out, then fade the background away to reveal the app.
// APP_FADE_MS must match the #splash opacity transition in index.html.
const LOGO_FADE_MS = 300;
const APP_FADE_MS = 100;
let removed = false;
const fadeOutAndRemove = () => {
const splash = document.getElementById("splash");
if (!splash) {
return;
}
// Phase 1: freeze the pulse at its current opacity (else stopping the animation snaps to full),
// then fade the logo to 0.
const img = splash.querySelector("img");
if (img) {
const current = getComputedStyle(img).opacity;
img.style.opacity = current;
img.style.animation = "none";
img.getBoundingClientRect(); // force reflow so the fade starts from `current`
img.style.transition = `opacity ${LOGO_FADE_MS}ms ease-out`;
img.style.opacity = "0";
}
// Phase 2: lift the background to fade the app in, then remove the node.
setTimeout(() => {
splash.classList.add("splash-hidden");
const remove = () => splash.remove();
// Ignore the logo's bubbling transitionend; only the background's own fade should remove it.
const onEnd = (event) => {
if (event.target === splash) {
splash.removeEventListener("transitionend", onEnd);
remove();
}
};
splash.addEventListener("transitionend", onEnd);
setTimeout(remove, APP_FADE_MS + 100); // fallback if transitionend never fires
}, LOGO_FADE_MS);
};
const hideSplash = () => {
if (removed) {
return;
}
removed = true;
// performance.now() ~= how long the splash has been visible; hold until MIN_VISIBLE_MS elapses.
const remaining = Math.max(0, MIN_VISIBLE_MS - performance.now());
setTimeout(fadeOutAndRemove, remaining);
};
export default hideSplash;
+49
View File
@@ -0,0 +1,49 @@
// Fade transitions between the app and the auth pages (login/signup/reset): fade #root out, then
// navigate client-side (fading back in) or full-reload (the splash fades the next page in).
const FADE_MS = 150;
const rootNode = () => document.getElementById("root");
// Fade #root out, resolving when done. Exported so callers can await it before their own
// teardown + reload (e.g. resetAndRedirect wiping IndexedDB).
export const fadeOut = () =>
new Promise((resolve) => {
const node = rootNode();
if (!node) {
resolve();
return;
}
node.style.transition = `opacity ${FADE_MS}ms ease-out`;
node.style.opacity = "0";
setTimeout(resolve, FADE_MS);
});
// Fade #root back in, then strip the inline styles (a lingering `transition` would animate future
// opacity changes). setTimeout, not rAF, so a backgrounded tab can't strand #root at opacity 0.
const fadeInRoot = () => {
const node = rootNode();
if (!node) {
return;
}
node.style.opacity = "1";
setTimeout(() => {
node.style.transition = "";
node.style.opacity = "";
}, FADE_MS);
};
// Fade out, navigate client-side, fade the new page in (app -> login/signup, no reload).
export const fadeNavigate = (navigate, to) => {
fadeOut().then(() => {
navigate(to);
fadeInRoot();
});
};
// Fade out, then full-reload to `url` (login/signup -> app needs a reload for the per-user DB).
// The splash fades it back in.
export const fadeReload = (url) => {
fadeOut().then(() => {
window.location.href = url;
});
};
+6 -8
View File
@@ -191,7 +191,7 @@ const ChangePasswordDialog = (props) => {
}; };
return ( return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle> <DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
@@ -402,13 +402,12 @@ const Emails = () => {
setSnack(t("account_basics_emails_copied_to_clipboard")); setSnack(t("account_basics_emails_copied_to_clipboard"));
}; };
// runEmailAction wraps an account API call with the shared error handling (redirect on // runEmailAction wraps an email account action with the shared error handling used by the
// unauthorized, surface a message otherwise). On success it refetches the account so the email // delete/set-primary/resend handlers (redirect on unauthorized, surface a message otherwise).
// list reflects the change immediately, rather than waiting for the async sync event. // The account view refreshes via the server's sync event, so there's no explicit refetch here.
const runEmailAction = async (fn, errorMessage) => { const runEmailAction = async (fn, errorMessage) => {
try { try {
await fn(); await fn();
await accountApi.sync();
} catch (e) { } catch (e) {
console.log(`[Account] Email action failed`, e); console.log(`[Account] Email action failed`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@@ -569,7 +568,6 @@ const AddEmailDialog = (props) => {
setSending(true); setSending(true);
setError(""); // Clear any error from a previous attempt setError(""); // Clear any error from a previous attempt
await accountApi.startEmailVerification(email); await accountApi.startEmailVerification(email);
await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately
setSent(true); setSent(true);
} catch (e) { } catch (e) {
console.log(`[Account] Error starting email verification`, e); console.log(`[Account] Error starting email verification`, e);
@@ -774,7 +772,7 @@ const AddPhoneNumberDialog = (props) => {
}; };
return ( return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle> <DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText> <DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
@@ -1398,7 +1396,7 @@ const DeleteAccountDialog = (props) => {
await accountApi.delete(password); await accountApi.delete(password);
await db().delete(); await db().delete();
console.debug(`[Account] Account deleted`); console.debug(`[Account] Account deleted`);
await session.resetAndRedirect(routes.app); await session.resetAndRedirect(routes.app, { fade: true });
} catch (e) { } catch (e) {
console.log(`[Account] Error deleting account`, e); console.log(`[Account] Error deleting account`, e);
if (e instanceof IncorrectPasswordError) { if (e instanceof IncorrectPasswordError) {
+15 -3
View File
@@ -18,6 +18,7 @@ import subscriptionManager from "../app/SubscriptionManager";
import routes from "./routes"; import routes from "./routes";
import db from "../app/db"; import db from "../app/db";
import { topicDisplayName } from "../app/utils"; import { topicDisplayName } from "../app/utils";
import { fadeNavigate } from "../app/transition";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
@@ -172,7 +173,7 @@ const ProfileIcon = () => {
await accountApi.logout(); await accountApi.logout();
await db().delete(); await db().delete();
} finally { } finally {
await session.resetAndRedirect(routes.app); await session.resetAndRedirect(routes.app, { fade: true });
} }
}; };
@@ -184,12 +185,23 @@ const ProfileIcon = () => {
</IconButton> </IconButton>
)} )}
{!session.exists() && config.enable_login && ( {!session.exists() && config.enable_login && (
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t("action_bar_sign_in")}> <Button
color="inherit"
variant="text"
onClick={() => fadeNavigate(navigate, routes.login)}
sx={{ m: 1 }}
aria-label={t("action_bar_sign_in")}
>
{t("action_bar_sign_in")} {t("action_bar_sign_in")}
</Button> </Button>
)} )}
{!session.exists() && config.enable_signup && ( {!session.exists() && config.enable_signup && (
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}> <Button
color="inherit"
variant="outlined"
onClick={() => fadeNavigate(navigate, routes.signup)}
aria-label={t("action_bar_sign_up")}
>
{t("action_bar_sign_up")} {t("action_bar_sign_up")}
</Button> </Button>
)} )}
+64 -28
View File
@@ -28,6 +28,8 @@ import prefs from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider"; import RTLCacheProvider from "./RTLCacheProvider";
import session from "../app/Session"; import session from "../app/Session";
import AccountContext from "./AccountContext"; import AccountContext from "./AccountContext";
import { PrefCacheProvider } from "./PrefCache";
import hideSplash from "../app/splash";
initI18n(); initI18n();
@@ -38,16 +40,29 @@ const App = () => {
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]); const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const themePreference = useLiveQuery(() => prefs.theme()); const themePreference = useLiveQuery(() => prefs.theme());
const theme = React.useMemo( const isDark = darkModeEnabled(prefersDarkMode, themePreference);
() => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }), const theme = React.useMemo(() => createTheme({ ...(isDark ? darkTheme : lightTheme), direction: languageDir }), [isDark, languageDir]);
[prefersDarkMode, themePreference, languageDir]
);
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute("lang", getKebabCaseLangStr(i18n.language)); document.documentElement.setAttribute("lang", getKebabCaseLangStr(i18n.language));
document.dir = languageDir; document.dir = languageDir;
}, [i18n.language, languageDir]); }, [i18n.language, languageDir]);
// Keep the <html> background in sync with the theme once loaded. The splash script sets the
// initial class; don't override it while the preference is still loading.
useEffect(() => {
if (themePreference === undefined) {
return;
}
document.documentElement.classList.toggle("dark", isDark);
}, [isDark, themePreference]);
// Safety net: hide the splash even if no route does (e.g. an unmatched path).
useEffect(() => {
const timer = setTimeout(() => hideSplash(), 3000);
return () => clearTimeout(timer);
}, []);
useEffect(() => { useEffect(() => {
if (!session.exists() && config.require_login && window.location.pathname !== routes.login) { if (!session.exists() && config.require_login && window.location.pathname !== routes.login) {
window.location.href = routes.login; window.location.href = routes.login;
@@ -63,11 +78,13 @@ const App = () => {
<CssBaseline /> <CssBaseline />
<ErrorBoundary> <ErrorBoundary>
<Routes> <Routes>
<Route path={routes.login} element={<Login />} /> <Route element={<AuthLayout />}>
<Route path={routes.signup} element={<Signup />} /> <Route path={routes.login} element={<Login />} />
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} /> <Route path={routes.signup} element={<Signup />} />
<Route path={routes.passwordReset} element={<PasswordReset />} /> <Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
<Route path={routes.emailVerify} element={<EmailVerify />} /> <Route path={routes.passwordReset} element={<PasswordReset />} />
<Route path={routes.emailVerify} element={<EmailVerify />} />
</Route>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path={routes.app} element={<AllSubscriptions />} /> <Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account />} /> <Route path={routes.account} element={<Account />} />
@@ -91,6 +108,12 @@ const updateTitle = (newNotificationsCount) => {
updateFavicon(newNotificationsCount); updateFavicon(newNotificationsCount);
}; };
// Auth pages render synchronously, so the splash can be removed on mount.
const AuthLayout = () => {
useEffect(() => hideSplash(), []);
return <Outlet />;
};
const Layout = () => { const Layout = () => {
const params = useParams(); const params = useParams();
const { account, setAccount } = useContext(AccountContext); const { account, setAccount } = useContext(AccountContext);
@@ -98,6 +121,9 @@ const Layout = () => {
const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all()); const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all());
// Preloaded here so the All view (and single topics, via filter) have data on mount -- no empty
// frame when switching.
const allNotifications = useLiveQuery(() => subscriptionManager.getAllNotifications());
const webPushTopics = useWebPushTopics(); const webPushTopics = useWebPushTopics();
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
@@ -112,27 +138,37 @@ const Layout = () => {
useBackgroundProcesses(); useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
// Hide the splash only once subscriptions have loaded, so the nav/list don't pop in empty-then-filled.
useEffect(() => {
if (subscriptions !== undefined) {
hideSplash();
}
}, [subscriptions]);
return ( return (
<Box sx={{ display: "flex" }}> <PrefCacheProvider>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} /> <Box sx={{ display: "flex" }}>
<Navigation <ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
subscriptions={subscriptionsWithoutInternal} <Navigation
selectedSubscription={selected} subscriptions={subscriptionsWithoutInternal}
mobileDrawerOpen={mobileDrawerOpen} selectedSubscription={selected}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} mobileDrawerOpen={mobileDrawerOpen}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
/> onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
}}
/> />
</Main> <Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} /> <Toolbar />
</Box> <Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
allNotifications,
}}
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
</PrefCacheProvider>
); );
}; };
+18 -2
View File
@@ -1,8 +1,9 @@
import * as React from "react"; import * as React from "react";
import { Avatar, Box, styled } from "@mui/material"; import { Avatar, Box, styled } from "@mui/material";
import { NavLink } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import logo from "../img/ntfy-filled.svg"; import logo from "../img/ntfy-filled.svg";
import routes from "./routes"; import routes from "./routes";
import { fadeNavigate } from "../app/transition";
const AvatarBoxContainer = styled(Box)` const AvatarBoxContainer = styled(Box)`
display: flex; display: flex;
@@ -16,11 +17,26 @@ const AvatarBoxContainer = styled(Box)`
margin: auto; margin: auto;
`; `;
const AvatarBox = (props) => { const AvatarBox = (props) => {
const navigate = useNavigate();
const avatar = <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />; const avatar = <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />;
// Fade back to the app instead of a hard cut. Let modifier-clicks (open in new tab, etc.) through.
const handleLogoClick = (ev) => {
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) {
return;
}
ev.preventDefault();
fadeNavigate(navigate, routes.app);
};
return ( return (
<AvatarBoxContainer> <AvatarBoxContainer>
{/* The logo links back to the app, unless login is forced (no app to go back to without signing in) */} {/* The logo links back to the app, unless login is forced (no app to go back to without signing in) */}
{config.require_login ? avatar : <NavLink to={routes.app}>{avatar}</NavLink>} {config.require_login ? (
avatar
) : (
<Box component="a" href={routes.app} onClick={handleLogoClick} sx={{ cursor: "pointer", lineHeight: 0 }}>
{avatar}
</Box>
)}
{props.children} {props.children}
</AvatarBoxContainer> </AvatarBoxContainer>
); );
+2 -1
View File
@@ -11,6 +11,7 @@ import AvatarBox from "./AvatarBox";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import { fadeReload } from "../app/transition";
const Login = () => { const Login = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -26,7 +27,7 @@ const Login = () => {
const token = await accountApi.login(user); const token = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
await session.store(user.username, token); await session.store(user.username, token);
window.location.href = routes.app; fadeReload(routes.app);
} catch (e) { } catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e); console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
+29 -20
View File
@@ -17,10 +17,9 @@ import {
Button, Button,
} from "@mui/material"; } from "@mui/material";
import * as React from "react"; import * as React from "react";
import { Suspense, lazy, useEffect, useState } from "react"; import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import CheckIcon from "@mui/icons-material/Check"; import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { useLiveQuery } from "dexie-react-hooks";
import InfiniteScroll from "react-infinite-scroll-component"; import InfiniteScroll from "react-infinite-scroll-component";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom"; import { useOutletContext } from "react-router-dom";
@@ -55,28 +54,25 @@ const priorityFiles = {
}; };
export const AllSubscriptions = () => { export const AllSubscriptions = () => {
const { subscriptions } = useOutletContext(); // allNotifications is preloaded in Layout, so this view has its data on mount (no empty frame on switch).
if (!subscriptions) { const { subscriptions, allNotifications } = useOutletContext();
return <Loading />; if (!subscriptions || allNotifications === null || allNotifications === undefined) {
return <DeferredLoading />;
} }
return <AllSubscriptionsList subscriptions={subscriptions} />; return <AllSubscriptionsList subscriptions={subscriptions} notifications={allNotifications} />;
}; };
export const SingleSubscription = () => { export const SingleSubscription = () => {
const { subscriptions, selected } = useOutletContext(); const { subscriptions, selected, allNotifications } = useOutletContext();
useAutoSubscribe(subscriptions, selected); useAutoSubscribe(subscriptions, selected);
if (!selected) { if (!selected || allNotifications === null || allNotifications === undefined) {
return <Loading />; return <DeferredLoading />;
} }
return <SingleSubscriptionList subscription={selected} />; return <SingleSubscriptionList subscription={selected} allNotifications={allNotifications} />;
}; };
const AllSubscriptionsList = (props) => { const AllSubscriptionsList = (props) => {
const { subscriptions } = props; const { subscriptions, notifications } = props;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
if (notifications === null || notifications === undefined) {
return <Loading />;
}
if (subscriptions.length === 0) { if (subscriptions.length === 0) {
return <NoSubscriptions />; return <NoSubscriptions />;
} }
@@ -87,11 +83,13 @@ const AllSubscriptionsList = (props) => {
}; };
const SingleSubscriptionList = (props) => { const SingleSubscriptionList = (props) => {
const { subscription } = props; const { subscription, allNotifications } = props;
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); // Filter the preloaded allNotifications instead of a per-topic query (getNotifications(id) ==
if (notifications === null || notifications === undefined) { // getAllNotifications() filtered by id), so topic switches are instant.
return <Loading />; const notifications = useMemo(
} () => allNotifications.filter((notification) => notification.subscriptionId === subscription.id),
[allNotifications, subscription.id]
);
if (notifications.length === 0) { if (notifications.length === 0) {
return <NoNotifications subscription={subscription} />; return <NoNotifications subscription={subscription} />;
} }
@@ -669,3 +667,14 @@ const Loading = () => {
</VerticallyCenteredContainer> </VerticallyCenteredContainer>
); );
}; };
// Render nothing until a load takes at least `delayMs`, so the centered spinner only shows on
// genuinely slow loads -- normal sub-frame IndexedDB reads don't flash it on every remount.
const DeferredLoading = ({ delayMs = 250 }) => {
const [show, setShow] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(timer);
}, [delayMs]);
return show ? <Loading /> : null;
};
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { createContext, useContext, useEffect } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import prefs, { PREF_DEFAULTS } from "../app/Prefs";
// A CACHE of the user's prefs -- not the source of truth (that's the `prefs` IndexedDB table, via
// Prefs.js). Preloaded once in Layout so Settings renders instantly, and written through to
// localStorage on every change so (a) Settings is instant even on a cold load and (b) the inline
// splash script in index.html can read the theme synchronously before the bundle loads. The
// "prefcache" key is duplicated in that script -- keep them in sync.
const PREFCACHE_LOCALSTORAGE_KEY = "prefcache";
const PrefCacheContext = createContext(undefined);
// Synchronous fallback before the live query resolves. Merged over PREF_DEFAULTS so a newly-added
// pref still has a value.
const readPersistedCache = () => {
try {
const raw = localStorage.getItem(PREFCACHE_LOCALSTORAGE_KEY);
if (raw) {
return { ...PREF_DEFAULTS, ...JSON.parse(raw) };
}
} catch (e) {
// malformed or unavailable storage -- fall back to defaults
}
return PREF_DEFAULTS;
};
export const PrefCacheProvider = ({ children }) => {
const cache = useLiveQuery(async () => ({
sound: await prefs.sound(),
minPriority: await prefs.minPriority(),
deleteAfter: await prefs.deleteAfter(),
theme: await prefs.theme(),
webPushEnabled: await prefs.webPushEnabled(),
}));
// Write through to localStorage on change (prefs change rarely).
useEffect(() => {
if (cache !== undefined) {
try {
localStorage.setItem(PREFCACHE_LOCALSTORAGE_KEY, JSON.stringify(cache));
} catch (e) {
// localStorage may be unavailable (private mode) -- the cache just isn't persisted
}
}
}, [cache]);
return <PrefCacheContext.Provider value={cache}>{children}</PrefCacheContext.Provider>;
};
// Live context once resolved; else the synchronous localStorage snapshot (instant on cold load);
// else defaults.
export const usePrefCache = () => useContext(PrefCacheContext) ?? readPersistedCache();
+7 -17
View File
@@ -50,6 +50,7 @@ import { UnauthorizedError } from "../app/errors";
import { subscribeTopic } from "./SubscribeDialog"; import { subscribeTopic } from "./SubscribeDialog";
import notifier from "../app/Notifier"; import notifier from "../app/Notifier";
import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
import { usePrefCache } from "./PrefCache";
const maybeUpdateAccountSettings = async (payload) => { const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) { if (!session.exists()) {
@@ -99,7 +100,7 @@ const Notifications = () => {
const Sound = () => { const Sound = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = "prefSound"; const labelId = "prefSound";
const sound = useLiveQuery(async () => prefs.sound()); const { sound } = usePrefCache();
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setSound(ev.target.value); await prefs.setSound(ev.target.value);
await maybeUpdateAccountSettings({ await maybeUpdateAccountSettings({
@@ -108,9 +109,6 @@ const Sound = () => {
}, },
}); });
}; };
if (!sound) {
return null; // While loading
}
let description; let description;
if (sound === "none") { if (sound === "none") {
description = t("prefs_notifications_sound_description_none"); description = t("prefs_notifications_sound_description_none");
@@ -143,7 +141,7 @@ const Sound = () => {
const MinPriority = () => { const MinPriority = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = "prefMinPriority"; const labelId = "prefMinPriority";
const minPriority = useLiveQuery(async () => prefs.minPriority()); const { minPriority } = usePrefCache();
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value); await prefs.setMinPriority(ev.target.value);
await maybeUpdateAccountSettings({ await maybeUpdateAccountSettings({
@@ -152,9 +150,6 @@ const MinPriority = () => {
}, },
}); });
}; };
if (!minPriority) {
return null; // While loading
}
const priorities = { const priorities = {
1: t("priority_min"), 1: t("priority_min"),
2: t("priority_low"), 2: t("priority_low"),
@@ -191,7 +186,7 @@ const MinPriority = () => {
const DeleteAfter = () => { const DeleteAfter = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = "prefDeleteAfter"; const labelId = "prefDeleteAfter";
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); const { deleteAfter } = usePrefCache();
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value); await prefs.setDeleteAfter(ev.target.value);
await maybeUpdateAccountSettings({ await maybeUpdateAccountSettings({
@@ -201,11 +196,6 @@ const DeleteAfter = () => {
}); });
}; };
if (deleteAfter === null || deleteAfter === undefined) {
// !deleteAfter will not work with "0"
return null; // While loading
}
const description = (() => { const description = (() => {
switch (deleteAfter) { switch (deleteAfter) {
case 0: case 0:
@@ -241,7 +231,7 @@ const DeleteAfter = () => {
const Theme = () => { const Theme = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = "prefTheme"; const labelId = "prefTheme";
const theme = useLiveQuery(async () => prefs.theme()); const { theme } = usePrefCache();
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setTheme(ev.target.value); await prefs.setTheme(ev.target.value);
}; };
@@ -249,7 +239,7 @@ const Theme = () => {
return ( return (
<Pref labelId={labelId} title={t("prefs_appearance_theme_title")}> <Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}> <FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={theme ?? THEME.SYSTEM} onChange={handleChange} aria-labelledby={labelId}> <Select value={theme} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem> <MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
<MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem> <MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
<MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem> <MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
@@ -262,7 +252,7 @@ const Theme = () => {
const WebPushEnabled = () => { const WebPushEnabled = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = "prefWebPushEnabled"; const labelId = "prefWebPushEnabled";
const enabled = useLiveQuery(async () => prefs.webPushEnabled()); const { webPushEnabled: enabled } = usePrefCache();
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setWebPushEnabled(ev.target.value); await prefs.setWebPushEnabled(ev.target.value);
}; };
+1 -1
View File
@@ -292,7 +292,7 @@ const PublishDialog = (props) => {
return ( return (
<> <>
{dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />} {dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}> <Dialog maxWidth="md" open={open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle> <DialogTitle>
{baseUrl && topic {baseUrl && topic
? t("publish_dialog_title_topic", { ? t("publish_dialog_title_topic", {
+2 -1
View File
@@ -11,6 +11,7 @@ import AvatarBox from "./AvatarBox";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
import { AccountActionLimitReachedError, UserExistsError } from "../app/errors"; import { AccountActionLimitReachedError, UserExistsError } from "../app/errors";
import { fadeReload } from "../app/transition";
const Signup = () => { const Signup = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,7 +31,7 @@ const Signup = () => {
const token = await accountApi.login(user); const token = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
await session.store(user.username, token); await session.store(user.username, token);
window.location.href = routes.app; fadeReload(routes.app);
} catch (e) { } catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e); console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) { if (e instanceof UserExistsError) {