mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-11 22:35:22 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9105dad09 | ||
|
|
5765be7892 | ||
|
|
7a99bfc717 | ||
|
|
156ad4ae92 | ||
|
|
a6dc691672 | ||
|
|
9114d1e996 | ||
|
|
55d871aeb2 | ||
|
|
a5318a4312 | ||
|
|
f77d299aa3 | ||
|
|
af66cc3ddc | ||
|
|
a47eef5006 | ||
|
|
0ac7cbd4fe | ||
|
|
72ac95148b | ||
|
|
817a0ece7e | ||
|
|
e617c5f453 | ||
|
|
a04a12eb5f | ||
|
|
46799db753 | ||
|
|
5e32f05302 | ||
|
|
e3889746c4 | ||
|
|
047a1258f1 | ||
|
|
2b1f2d6b9a |
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 |
@@ -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 |
@@ -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": "Презареждане"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user