diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 367091e2..4193d446 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "أنت غير متصل بالإنترنت", + "discarded_changes": "{count, plural, zero {تم تجاهل # تغيير دون اتصال لأن خادم البريد تغيّر} one {تم تجاهل تغيير واحد دون اتصال لأن خادم البريد تغيّر} two {تم تجاهل تغييرين دون اتصال لأن خادم البريد تغيّر} few {تم تجاهل # تغييرات دون اتصال لأن خادم البريد تغيّر} many {تم تجاهل # تغييرًا دون اتصال لأن خادم البريد تغيّر} other {تم تجاهل # تغيير دون اتصال لأن خادم البريد تغيّر}}", "queued_changes": "{count, plural, zero {ستتم مزامنة # تغيير عند إعادة الاتصال} one {ستتم مزامنة تغيير واحد عند إعادة الاتصال} two {ستتم مزامنة تغييرين عند إعادة الاتصال} few {ستتم مزامنة # تغييرات عند إعادة الاتصال} many {ستتم مزامنة # تغييرًا عند إعادة الاتصال} other {ستتم مزامنة # تغيير عند إعادة الاتصال}}", "sync": { "cached_count": "{count, plural, zero {تم تخزين # رسالة جديدة مؤقتًا} one {تم تخزين رسالة جديدة واحدة مؤقتًا} two {تم تخزين رسالتين جديدتين مؤقتًا} few {تم تخزين # رسائل جديدة مؤقتًا} many {تم تخزين # رسالة جديدة مؤقتًا} other {تم تخزين # رسالة جديدة مؤقتًا}} • {size}", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 7837e09e..eabc053c 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "No teniu connexió", + "discarded_changes": "{count, plural, one {S'ha descartat # canvi fet sense connexió} other {S'han descartat # canvis fets sense connexió}} perquè el servidor de correu ha canviat", "queued_changes": "{count, plural, one {# canvi es sincronitzarà} other {# canvis se sincronitzaran}} quan us torneu a connectar", "sync": { "cached_count": "{count, plural, one {# missatge nou a la memòria cau} other {# missatges nous a la memòria cau}} • {size}", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 053324a4..d2c0b669 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Jste offline", + "discarded_changes": "{count, plural, one {Byla zahozena # změna provedená offline} few {Byly zahozeny # změny provedené offline} many {Bylo zahozeno # změny provedené offline} other {Bylo zahozeno # změn provedených offline}}, protože se změnil poštovní server", "queued_changes": "{count, plural, one {# změna se synchronizuje} few {# změny se synchronizují} many {# změny se synchronizuje} other {# změn se synchronizuje}} po obnovení připojení", "sync": { "cached_count": "{count, plural, one {Stažena # nová zpráva} few {Staženy # nové zprávy} many {Staženo # nové zprávy} other {Staženo # nových zpráv}} • {size}", diff --git a/locales/rn/da.json b/locales/rn/da.json index c53463a5..135fa96c 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Du er offline", + "discarded_changes": "{count, plural, one {# ændring foretaget offline blev kasseret} other {# ændringer foretaget offline blev kasseret}}, fordi mailserveren er skiftet", "queued_changes": "{count, plural, one {# ændring} other {# ændringer}} synkroniseres, når du har forbindelse igen", "sync": { "cached_count": "{count, plural, one {# ny besked gemt i cachen} other {# nye beskeder gemt i cachen}} • {size}", diff --git a/locales/rn/de.json b/locales/rn/de.json index e7143d72..85a3c747 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Sie sind offline", + "discarded_changes": "{count, plural, one {# Offline-Änderung wurde} other {# Offline-Änderungen wurden}} verworfen, weil sich der Mailserver geändert hat", "queued_changes": "{count, plural, one {# Änderung wird} other {# Änderungen werden}} synchronisiert, sobald Sie wieder verbunden sind", "sync": { "cached_count": "{count, plural, one {# neue Nachricht zwischengespeichert} other {# neue Nachrichten zwischengespeichert}} • {size}", diff --git a/locales/rn/en.json b/locales/rn/en.json index cdb228c9..ccf6e213 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -545,6 +545,7 @@ }, "offline": { "banner": "You are offline", + "discarded_changes": "{count, plural, one {# offline change was} other {# offline changes were}} discarded because the mail server changed", "queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect", "sync": { "cached_count": "{count, plural, one {# new message cached} other {# new messages cached}} • {size}", diff --git a/locales/rn/es.json b/locales/rn/es.json index 34f376e9..d7d1d6ef 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Estás sin conexión", + "discarded_changes": "{count, plural, one {Se descartó # cambio sin conexión} other {Se descartaron # cambios sin conexión}} porque cambió el servidor de correo", "queued_changes": "{count, plural, one {# cambio se sincronizará} other {# cambios se sincronizarán}} cuando vuelvas a conectarte", "sync": { "cached_count": "{count, plural, one {# mensaje nuevo en caché} other {# mensajes nuevos en caché}} • {size}", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 20d2b905..0a643de6 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "شما آفلاین هستید", + "discarded_changes": "{count, plural, one {# تغییر آفلاین کنار گذاشته شد، چون سرور ایمیل عوض شده است} other {# تغییر آفلاین کنار گذاشته شد، چون سرور ایمیل عوض شده است}}", "queued_changes": "{count, plural, one {# تغییر پس از اتصال دوباره همگام‌سازی می‌شود} other {# تغییر پس از اتصال دوباره همگام‌سازی می‌شود}}", "sync": { "cached_count": "{count, plural, one {# پیام جدید کش شد} other {# پیام جدید کش شد}} • {size}", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index f610fe38..31c3d0ad 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Vous êtes hors ligne", + "discarded_changes": "{count, plural, one {# modification hors ligne a été abandonnée} other {# modifications hors ligne ont été abandonnées}} car le serveur de messagerie a changé", "queued_changes": "{count, plural, one {# modification sera synchronisée} other {# modifications seront synchronisées}} à la reconnexion", "sync": { "cached_count": "{count, plural, one {# nouveau message mis en cache} other {# nouveaux messages mis en cache}} • {size}", diff --git a/locales/rn/he.json b/locales/rn/he.json index 17277f23..fd6024e0 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "אתה במצב לא מקוון", + "discarded_changes": "{count, plural, one {שינוי אחד שנעשה ללא חיבור בוטל כי שרת הדואר השתנה} two {שני שינויים שנעשו ללא חיבור בוטלו כי שרת הדואר השתנה} other {# שינויים שנעשו ללא חיבור בוטלו כי שרת הדואר השתנה}}", "queued_changes": "{count, plural, one {שינוי אחד יסונכרן כשתתחבר מחדש} two {שני שינויים יסונכרנו כשתתחבר מחדש} other {# שינויים יסונכרנו כשתתחבר מחדש}}", "sync": { "cached_count": "{count, plural, one {הודעה חדשה אחת נשמרה במטמון} two {שתי הודעות חדשות נשמרו במטמון} other {# הודעות חדשות נשמרו במטמון}} • {size}", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index daae5f45..106744db 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Offline vagy", + "discarded_changes": "{count, plural, one {# offline módosítás törlődött} other {# offline módosítás törlődött}}, mert megváltozott a levelezőszerver", "queued_changes": "{count, plural, one {# módosítás szinkronizálódik} other {# módosítás szinkronizálódik}}, amint újra csatlakozol", "sync": { "cached_count": "{count, plural, one {# új üzenet letöltve} other {# új üzenet letöltve}} • {size}", diff --git a/locales/rn/it.json b/locales/rn/it.json index e354e3f9..e07ea179 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Sei offline", + "discarded_changes": "{count, plural, one {# modifica offline è stata scartata} other {# modifiche offline sono state scartate}} perché il server di posta è cambiato", "queued_changes": "{count, plural, one {# modifica verrà sincronizzata} other {# modifiche verranno sincronizzate}} quando ti riconnetti", "sync": { "cached_count": "{count, plural, one {# nuovo messaggio in cache} other {# nuovi messaggi in cache}} • {size}", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 77cd0fbb..6b4e5eea 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "オフラインです", + "discarded_changes": "メールサーバーが変わったため、{count, plural, other {オフラインでの変更#件}}を破棄しました", "queued_changes": "{count, plural, other {#件の変更}}は再接続時に同期されます", "sync": { "cached_count": "{count, plural, other {#件の新着メッセージをキャッシュしました}} • {size}", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 327943d8..b250046c 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "오프라인 상태예요", + "discarded_changes": "메일 서버가 바뀌어 {count, plural, other {오프라인 변경 사항 #개가}} 폐기됐어요", "queued_changes": "{count, plural, other {변경 사항 #개}}는 다시 연결되면 동기화돼요", "sync": { "cached_count": "{count, plural, other {새 메시지 #개를 캐시했어요}} • {size}", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 35a013b9..c7f41b04 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Jūs esat bezsaistē", + "discarded_changes": "{count, plural, zero {# bezsaistes izmaiņas tika atmestas} one {# bezsaistes izmaiņa tika atmesta} other {# bezsaistes izmaiņas tika atmestas}}, jo mainījās pasta serveris", "queued_changes": "{count, plural, zero {# izmaiņas tiks sinhronizētas} one {# izmaiņa tiks sinhronizēta} other {# izmaiņas tiks sinhronizētas}}, kad atjaunosies savienojums", "sync": { "cached_count": "{count, plural, zero {Lejupielādēti # jauni ziņojumi} one {Lejupielādēts # jauns ziņojums} other {Lejupielādēti # jauni ziņojumi}} • {size}", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 9212eedd..1460f707 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Та офлайн байна", + "discarded_changes": "{count, plural, one {Шуудангийн сервер өөрчлөгдсөн тул офлайн хийсэн # өөрчлөлтийг хаслаа} other {Шуудангийн сервер өөрчлөгдсөн тул офлайн хийсэн # өөрчлөлтийг хаслаа}}", "queued_changes": "{count, plural, one {# өөрчлөлт дахин холбогдоход синк хийгдэнэ} other {# өөрчлөлт дахин холбогдоход синк хийгдэнэ}}", "sync": { "cached_count": "{count, plural, one {# шинэ имэйл кэшлэгдлээ} other {# шинэ имэйл кэшлэгдлээ}} • {size}", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index e3565c43..e0003efc 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Du er uten nett", + "discarded_changes": "{count, plural, one {# endring gjort uten nett ble forkastet} other {# endringer gjort uten nett ble forkastet}} fordi e-postserveren er byttet", "queued_changes": "{count, plural, one {# endring} other {# endringer}} synkroniseres når du er på nett igjen", "sync": { "cached_count": "{count, plural, one {# ny melding bufret} other {# nye meldinger bufret}} • {size}", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 2e78ff64..7872c742 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "U bent offline", + "discarded_changes": "{count, plural, one {# offline wijziging is} other {# offline wijzigingen zijn}} verwijderd omdat de mailserver is gewijzigd", "queued_changes": "{count, plural, one {# wijziging wordt} other {# wijzigingen worden}} gesynchroniseerd zodra u weer verbinding hebt", "sync": { "cached_count": "{count, plural, one {# nieuw bericht in de cache opgeslagen} other {# nieuwe berichten in de cache opgeslagen}} • {size}", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 44604baf..07e3f53a 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Jesteś offline", + "discarded_changes": "{count, plural, one {Odrzucono # zmianę offline} few {Odrzucono # zmiany offline} many {Odrzucono # zmian offline} other {Odrzucono # zmiany offline}}, ponieważ zmienił się serwer poczty", "queued_changes": "{count, plural, one {# zmiana zostanie zsynchronizowana} few {# zmiany zostaną zsynchronizowane} many {# zmian zostanie zsynchronizowanych} other {# zmiany zostanie zsynchronizowane}} po ponownym połączeniu", "sync": { "cached_count": "{count, plural, one {Pobrano # nową wiadomość} few {Pobrano # nowe wiadomości} many {Pobrano # nowych wiadomości} other {Pobrano # nowej wiadomości}} • {size}", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index f79cf281..fb0e1afb 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Você está offline", + "discarded_changes": "{count, plural, one {# alteração offline foi descartada} other {# alterações offline foram descartadas}} porque o servidor de e-mail mudou", "queued_changes": "{count, plural, one {# alteração será sincronizada} other {# alterações serão sincronizadas}} quando você se reconectar", "sync": { "cached_count": "{count, plural, one {# nova mensagem em cache} other {# novas mensagens em cache}} • {size}", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 5dd489d7..7ba111a9 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Sunteți offline", + "discarded_changes": "{count, plural, one {# modificare offline a fost eliminată} few {# modificări offline au fost eliminate} other {# de modificări offline au fost eliminate}} deoarece serverul de e-mail s-a schimbat", "queued_changes": "{count, plural, one {# modificare va fi sincronizată} few {# modificări vor fi sincronizate} other {# de modificări vor fi sincronizate}} când vă reconectați", "sync": { "cached_count": "{count, plural, one {# mesaj nou salvat în cache} few {# mesaje noi salvate în cache} other {# de mesaje noi salvate în cache}} • {size}", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index aab76cfc..ed664fa1 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Вы не в сети", + "discarded_changes": "{count, plural, one {# офлайн-изменение отменено} few {# офлайн-изменения отменены} many {# офлайн-изменений отменено} other {# офлайн-изменения отменено}}, так как сменился почтовый сервер", "queued_changes": "{count, plural, one {# изменение будет синхронизировано} few {# изменения будут синхронизированы} many {# изменений будет синхронизировано} other {# изменения будет синхронизировано}} после подключения к сети", "sync": { "cached_count": "{count, plural, one {В кэш сохранено # новое письмо} few {В кэш сохранено # новых письма} many {В кэш сохранено # новых писем} other {В кэш сохранено # нового письма}} • {size}", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 79e66e8c..3880fa73 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Ste offline", + "discarded_changes": "{count, plural, one {Zahodila sa # zmena urobená offline} few {Zahodili sa # zmeny urobené offline} many {Zahodilo sa # zmeny urobenej offline} other {Zahodilo sa # zmien urobených offline}}, pretože sa zmenil poštový server", "queued_changes": "{count, plural, one {# zmena sa zosynchronizuje} few {# zmeny sa zosynchronizujú} many {# zmeny sa zosynchronizuje} other {# zmien sa zosynchronizuje}} po obnovení pripojenia", "sync": { "cached_count": "{count, plural, one {Stiahnutá # nová správa} few {Stiahnuté # nové správy} many {Stiahnutých # novej správy} other {Stiahnutých # nových správ}} • {size}", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 132210a1..35847f11 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Çevrimdışısınız", + "discarded_changes": "{count, plural, one {Çevrimdışı yapılan # değişiklik} other {Çevrimdışı yapılan # değişiklik}} posta sunucusu değiştiği için silindi", "queued_changes": "{count, plural, one {# değişiklik} other {# değişiklik}} yeniden bağlandığınızda senkronize edilecek", "sync": { "cached_count": "{count, plural, one {# yeni ileti önbelleğe alındı} other {# yeni ileti önbelleğe alındı}} • {size}", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 7e3ebc52..066f2931 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "Ви не в мережі", + "discarded_changes": "{count, plural, one {# офлайн-зміну скасовано} few {# офлайн-зміни скасовано} many {# офлайн-змін скасовано} other {# офлайн-зміни скасовано}}, бо змінився поштовий сервер", "queued_changes": "{count, plural, one {# зміну буде синхронізовано} few {# зміни буде синхронізовано} many {# змін буде синхронізовано} other {# зміни буде синхронізовано}} після підключення до мережі", "sync": { "cached_count": "{count, plural, one {У кеш збережено # новий лист} few {У кеш збережено # нові листи} many {У кеш збережено # нових листів} other {У кеш збережено # нового листа}} • {size}", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index d85cb824..876dcb19 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "您目前處於離線狀態", + "discarded_changes": "由於郵件伺服器已變更,{count, plural, other {# 項離線變更}}已被捨棄", "queued_changes": "{count, plural, other {# 項變更}}將在重新連線後同步", "sync": { "cached_count": "{count, plural, other {已快取 # 封新郵件}} • {size}", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 3746ce43..90fbe1fb 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -538,6 +538,7 @@ }, "offline": { "banner": "您已离线", + "discarded_changes": "由于邮件服务器已更改,{count, plural, other {# 项离线更改}}已被丢弃", "queued_changes": "{count, plural, other {# 项更改}}将在重新连接后同步", "sync": { "cached_count": "{count, plural, other {已缓存 # 封新邮件}} • {size}", diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 4eda6854..ae1ae414 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1156,6 +1156,20 @@ export async function setDefaultCalendar( methodResult(res); } +/** + * The name of the user's calendar `id`, or null when the account has no + * calendar by that id. + */ +export async function getCalendarName(id: string): Promise { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']], + USING, + ); + const list = methodResult<{ list?: Calendar[] }>(res).list ?? []; + return list.find((cal) => cal.id === id)?.name ?? null; +} + // Destroy a calendar. `onDestroyRemoveEvents` removes its events too — // Stalwart otherwise refuses to delete a non-empty calendar. export async function deleteCalendar(id: string, targetAccountId?: string): Promise { diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx index 4d7955a9..98ca8187 100644 --- a/src/components/calendar/ICalSubscriptionSheet.tsx +++ b/src/components/calendar/ICalSubscriptionSheet.tsx @@ -12,6 +12,7 @@ import { CALENDAR_COLOR_PALETTE, calendarColorName } from '../../lib/calendar-ut import { useCalendarLocale } from '../../lib/calendar-locale'; import { DEFAULT_REFRESH_INTERVAL_MINUTES, + currentLocalAccountId, selectAccountSubscriptions, useCalendarSubscriptionsStore, type CalendarSubscription, @@ -40,7 +41,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { // Only the signed-in account's feeds: a sub created under another account // mirrors into a calendar id that doesn't exist (or collides) here. const subscriptions = React.useMemo( - () => selectAccountSubscriptions(allSubscriptions, jmapClient.isConnected ? jmapClient.accountId : null), + () => selectAccountSubscriptions( + allSubscriptions, + jmapClient.isConnected ? jmapClient.accountId : null, + currentLocalAccountId(), + ), [allSubscriptions], ); const syncing = useCalendarSubscriptionsStore((s) => s.syncing); diff --git a/src/lib/__tests__/push-notifications.test.ts b/src/lib/__tests__/push-notifications.test.ts index b62a325f..5f0c6a36 100644 --- a/src/lib/__tests__/push-notifications.test.ts +++ b/src/lib/__tests__/push-notifications.test.ts @@ -72,6 +72,7 @@ import { setupPushNotifications, deviceClientIdKey, disablePushForAccount, + forgetPushServerState, isValidRelayUrl, mailAccountsWithoutPush, notificationTapJmapAccountId, @@ -937,6 +938,8 @@ describe('resyncPushNotifications', () => { expect(result?.subscriptionId).toBe('existing'); expect(updateMock).toHaveBeenCalledTimes(1); expect(createMock).not.toHaveBeenCalled(); + // The expiry the server reported is what a later resync measures against. + expect(await AsyncStorage.getItem(EXPIRES_KEY)).toBe(expires); }); it('leaves an account alone after the user turned push off for it', async () => { @@ -969,10 +972,7 @@ describe('resyncPushNotifications', () => { expect(createMock).not.toHaveBeenCalled(); }); - it('re-creates a subscription the server lost before it was due to expire (reinstalled at the same address)', async () => { - // A server reinstalled for Stalwart 1.0, or an upgrade that doesn't carry - // the subscriptions over, looks exactly like a revocation from elsewhere. - // The expiry record is what older builds took for proof of revocation. + it('does not re-register a subscription another device revoked', async () => { await AsyncStorage.setItem(SUB_KEY, 'existing'); await AsyncStorage.setItem(EXPIRES_KEY, inDays(5)); await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID])); @@ -980,6 +980,25 @@ describe('resyncPushNotifications', () => { const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + expect(result).toBeNull(); + expect(createMock).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(SUB_KEY)).toBeNull(); + expect(await readPushAccountIds()).toEqual([]); + expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull(); + }); + + it('re-creates the subscription once the server behind the account was replaced (0.16.23 to 1.0)', async () => { + // A server reinstalled for Stalwart 1.0 holds none of the subscriptions, + // which looks exactly like a revocation from elsewhere; the fingerprint + // check tells them apart and forgets the recorded expiry. + await AsyncStorage.setItem(SUB_KEY, 'existing'); + await AsyncStorage.setItem(EXPIRES_KEY, inDays(5)); + await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID])); + listMock.mockResolvedValue([]); + + await forgetPushServerState(ACCOUNT_ID); + const result = await resyncPushNotifications({ relayBaseUrl: RELAY }); + expect(result?.subscriptionId).toBe('new-server-id'); expect(createMock).toHaveBeenCalledTimes(1); expect(await AsyncStorage.getItem(SUB_KEY)).toBe('new-server-id'); @@ -1053,6 +1072,7 @@ describe('resyncPushNotifications', () => { await setupPushNotifications({ relayBaseUrl: RELAY }); expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull(); + expect(await AsyncStorage.getItem(EXPIRES_KEY)).not.toBeNull(); vi.clearAllMocks(); listMock.mockResolvedValue([ { id: 'new-server-id', deviceClientId: OUR_DCID, expires: inDays(7), types: ['EmailDelivery'] }, diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index e6392833..ec3028ff 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -47,12 +47,14 @@ const PROMPT_DISMISSED_PREFIX = 'push:promptDismissed:v1:'; // emailPushFallbacks). Left out of the map from then on, so a refusal isn't // re-provoked on every launch. const EMAIL_PUSH_REFUSED_PREFIX = 'push:emailPushRefused:v1:'; -// The recorded subscription's expiry. Earlier builds took a subscription gone -// before it for revoked; nothing reads it now, and teardown still clears it. +// When the recorded subscription expires, as the server last reported it. Lets +// the launch-time resync tell a subscription that lapsed (re-create it) from +// one that was revoked (leave push off). Forgotten when the server behind the +// account is replaced, which loses every subscription (forgetPushServerState). const SUBSCRIPTION_EXPIRES_PREFIX = 'push:subscriptionExpires:v1:'; // Set when the user turned push off for the account - the settings toggle, or -// revoking this device here. The launch-time resync leaves such an account -// alone until push is enabled for it again. +// revoking this device here or from elsewhere. The launch-time resync leaves +// such an account alone until push is enabled for it again. const OPTED_OUT_PREFIX = 'push:optedOut:v1:'; // Legacy single-account keys (pre-multi-account). Migrated lazily on the next @@ -226,6 +228,10 @@ const SUBSCRIPTION_EXPIRES_DAYS = 90; // When an existing subscription has less than this much lifetime left, push // expires forward on the next app start. const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7; +// A recorded subscription missing from the server was revoked only if it still +// had at least this long to live; closer to its expiry (or with the clock a +// little off) it may simply have lapsed, and is re-created. +const REVOKED_EXPIRY_MARGIN_MS = 24 * 60 * 60 * 1000; function expiresFromNow(days: number): string { return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); @@ -1033,6 +1039,7 @@ async function setupPushNotificationsInner( : undefined; if (storedServerId) { if (match) { + if (match.expires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), match.expires); if (!params.forceRecreate) { let refreshed: Awaited>; try { @@ -1057,9 +1064,10 @@ async function setupPushNotificationsInner( // once it's verified. replacedServerId = storedServerId; } else { - // The server no longer has it: it expired, was revoked, or the server - // lost its subscriptions (a reinstall, an upgrade). Either way it is - // re-created below; push is never left off because of it. + // The server no longer has it: it lapsed, or the server behind the + // account was replaced. A revocation from another device is caught + // before this by the launch-time resync (wasRevokedOnServer). Either + // way it is re-created below. await AsyncStorage.removeItem(subKey); } } @@ -1090,6 +1098,7 @@ async function setupPushNotificationsInner( } let serverAssignedId: string; + let serverExpires: string | null; try { const created = await writeWithEmailPush(emailPush, (filter) => createPushSubscription({ @@ -1101,6 +1110,7 @@ async function setupPushNotificationsInner( }), ); serverAssignedId = created.result.id; + serverExpires = created.result.expires; await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); } catch (err) { throw subscriptionError(err); @@ -1119,6 +1129,7 @@ async function setupPushNotificationsInner( } await AsyncStorage.setItem(subKey, serverAssignedId); + if (serverExpires) await AsyncStorage.setItem(subscriptionExpiresKey(accountId), serverExpires); if (replacedServerId) { await destroyPushSubscription(replacedServerId).catch(() => undefined); } @@ -1209,15 +1220,17 @@ async function clearAccountPushKeys(accountId: string): Promise { /** * Forget what push learned from the account's server: the ring of notified * message ids (a different server hands the same short ids to new mail, which - * would then never notify) and the accounts it refused in the emailPush map. - * The registration is left to the next resync, which re-creates it when the - * server no longer has it (see lib/server-change). + * would then never notify), the accounts it refused in the emailPush map, and + * the recorded subscription's expiry. A replaced server holds none of the old + * subscriptions, which must not count as revoked: without the expiry the next + * resync re-creates it (see lib/server-change). */ export async function forgetPushServerState(accountId: string): Promise { await AsyncStorage.multiRemove([ lastNotifiedKey(accountId), notifiedIdsKey(accountId), emailPushRefusedKey(accountId), + subscriptionExpiresKey(accountId), ]); } @@ -1272,23 +1285,41 @@ export async function teardownPushNotificationsForAccount( /** * Turn push off for one account because the user asked to: the settings - * toggle, or revoking this device here. Tears the registration down and - * remembers the choice, so the launch-time resync doesn't quietly register - * the account again. Enabling push for it again clears the mark. + * toggle, or revoking this device (here, or from another device - see + * resyncPushNotifications). Tears the registration down and remembers the + * choice, so the launch-time resync doesn't quietly register the account + * again. Enabling push for it again clears the mark. */ export async function disablePushForAccount(accountId: string): Promise { await teardownPushNotificationsForAccount(accountId); await AsyncStorage.setItem(optedOutKey(accountId), String(Date.now())); } +/** + * True when the server no longer has this account's recorded subscription + * although it wasn't due to expire - someone revoked it. Without a known + * expiry (a registration made by an older build, or one on a server that was + * replaced since) nothing can be told apart, so it counts as not revoked. + * Throws when the server can't be asked. + */ +async function wasRevokedOnServer(accountId: string): Promise { + const storedServerId = await AsyncStorage.getItem(subscriptionIdKey(accountId)); + const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? ''); + if (!storedServerId || !Number.isFinite(expires)) return false; + if (expires - Date.now() < REVOKED_EXPIRY_MARGIN_MS) return false; + const subs = await listPushSubscriptions(); + return !subs.some((s) => s.id === storedServerId); +} + /** * Bring the loaded account's registration up to date without user action - * on launch and when the push token or endpoint rotates. Skips an account the - * user turned push off for. A subscription the server no longer has counts - * as expired and is re-created: the server can lose every subscription (a - * reinstall at the same address, an upgrade that doesn't carry them over), - * which looks the same as a revocation from another device. Resolves to null - * when it left push off. + * user turned push off for, and honours a revocation: when the server dropped + * the subscription before it was due to expire, push is turned off for the + * account instead of being silently registered again. A server replaced at + * the account's address loses every subscription too; that is told apart by + * its fingerprint, which forgets the expiry (forgetPushServerState), so the + * subscription is re-created there. Resolves to null when it left push off. */ export async function resyncPushNotifications( params: PushSetupParams, @@ -1298,6 +1329,11 @@ export async function resyncPushNotifications( if (!username || !serverUrl) return null; const accountId = generateAccountId(username, serverUrl); if (await AsyncStorage.getItem(optedOutKey(accountId))) return null; + if (await wasRevokedOnServer(accountId)) { + logPhase('revoked', 'subscription gone before it was due to expire; leaving push off'); + await disablePushForAccount(accountId); + return null; + } return setupPushNotifications(params); } diff --git a/src/lib/server-change.ts b/src/lib/server-change.ts index 2cc0d3f1..d9e3bfee 100644 --- a/src/lib/server-change.ts +++ b/src/lib/server-change.ts @@ -1,15 +1,25 @@ import type { JMAPSession } from '../api/types'; import { useCalendarStore } from '../stores/calendar-store'; +import { useCalendarSubscriptionsStore } from '../stores/calendar-subscriptions-store'; import { useContactsStore } from '../stores/contacts-store'; import { useEmailStore } from '../stores/email-store'; +import { t } from '../stores/locale-store'; import { useOfflineCacheStore } from '../stores/offline-cache-store'; import { useOutboxStore } from '../stores/outbox-store'; import { flushPersistedWrites } from '../stores/persist-storage'; +import { useSettingsStore } from '../stores/settings-store'; +import { toast } from '../stores/toast-store'; import { clearBodyHeights } from './body-heights'; import { clearBodyDocuments } from './email-body-document'; import { clearEmailDetailCache } from './email-detail-cache'; import { forgetPushServerState } from './push-notifications'; -import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from './server-fingerprint'; +import { + primaryAccountId, + readServerFingerprint, + readServerPrimaryAccountId, + sessionFingerprint, + writeServerFingerprint, +} from './server-fingerprint'; // The app keeps a lot across launches that only means something on the // server it came from: JMAP states and query states, message ids in the @@ -22,11 +32,26 @@ import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } fro // derived from the old server is dropped before anything reads it. /** - * Drop what the app derived from an account's server. Preferences, templates, - * tags and iCal subscriptions stay: they are the user's, not the server's. + * The JMAP ids the replaced server and the new one gave the account's own + * data (null when not known). Preferences kept per JMAP account are found by + * them. + */ +export interface ServerAccountIds { + previous: string | null; + next: string | null; +} + +/** + * Drop what the app derived from an account's server. Preferences, templates + * and tags stay: they are the user's, not the server's. So do the iCal + * subscriptions, without their mirror calendars. Preferences that name a + * server object go: the preferred sender identity and the hidden calendars. * Every step runs; the first failure is thrown afterwards. */ -export async function forgetServerDerivedState(accountId: string): Promise { +export async function forgetServerDerivedState( + accountId: string, + jmapAccountIds: ServerAccountIds = { previous: null, next: null }, +): Promise { const failures: unknown[] = []; const step = async (run: () => unknown): Promise => { try { @@ -54,11 +79,31 @@ export async function forgetServerDerivedState(accountId: string): Promise // that one. So the whole queue goes, failed ops too. Offline edits still // waiting are lost; a message the user wrote never is: the outbox holds no // sends (sending needs a connection, and drafts are saved to the server). - await step(() => useOutboxStore.getState().clearAccount(accountId)); + let droppedOps = 0; + await step(async () => { + droppedOps = await useOutboxStore.getState().clearAccount(accountId); + }); await step(() => forgetPushServerState(accountId)); + // Calendar ids start over as well: a subscription's mirror id may name + // another calendar now, which its next sync would fill and "Remove" would + // destroy. Each gets a new mirror instead. + await step(() => useCalendarSubscriptionsStore.getState().unlinkAccount(accountId, jmapAccountIds)); + await step(() => useCalendarStore.getState().forgetHiddenCalendars()); + // Identity ids too (live: "b" is usera@ on 0.16.23, team@ on 1.0), and the + // preference would send from another address. + await step(() => useSettingsStore.getState().forgetPreferredIdentities( + [jmapAccountIds.previous, jmapAccountIds.next].filter((id): id is string => !!id), + )); // Get the emptied caches onto disk before the new fingerprint is. await step(() => flushPersistedWrites()); + if (droppedOps > 0) { + toast.warning(t( + 'offline.discarded_changes', + '{count, plural, one {# offline change was} other {# offline changes were}} discarded because the mail server changed', + { count: droppedOps }, + )); + } if (failures.length > 0) throw failures[0]; } @@ -72,11 +117,21 @@ export async function forgetServerDerivedState(accountId: string): Promise export async function reconcileServerFingerprint(accountId: string, session: JMAPSession): Promise { const next = sessionFingerprint(session); const recorded = await readServerFingerprint(accountId); - if (recorded === next) return false; + const primary = primaryAccountId(session); + if (recorded === next) { + // Recorded by a build that kept no primary account id with it. + if (primary && (await readServerPrimaryAccountId(accountId)) === null) { + await writeServerFingerprint(accountId, next, primary); + } + return false; + } if (recorded !== null) { console.warn(`[server-change] ${accountId}: the server changed, dropping what was read from the old one`); - await forgetServerDerivedState(accountId); + await forgetServerDerivedState(accountId, { + previous: await readServerPrimaryAccountId(accountId), + next: primary || null, + }); } - await writeServerFingerprint(accountId, next); + await writeServerFingerprint(accountId, next, primary); return recorded !== null; } diff --git a/src/lib/server-fingerprint.ts b/src/lib/server-fingerprint.ts index e26650a3..c130f710 100644 --- a/src/lib/server-fingerprint.ts +++ b/src/lib/server-fingerprint.ts @@ -15,13 +15,17 @@ import { sha256Hex } from './sha256'; // only costs a re-download. const STORAGE_PREFIX = 'server-fingerprint:v1:'; +// The JMAP id the recorded server gave the account's own data. Preferences +// kept per JMAP account (the preferred sender identity, iCal subscriptions) +// are found by it once that server is gone. +const PRIMARY_PREFIX = 'server-primary-account:v1:'; function storageKey(accountId: string): string { return STORAGE_PREFIX + accountId; } /** The session's primary account, picked the way JMAPClient picks it. */ -function primaryAccountId(session: JMAPSession): string { +export function primaryAccountId(session: JMAPSession): string { return session.primaryAccounts?.[CAPABILITIES.MAIL] || session.primaryAccounts?.[CAPABILITIES.CORE] || Object.keys(session.accounts ?? {})[0] @@ -53,6 +57,20 @@ export async function readServerFingerprint(accountId: string): Promise { +/** The primary JMAP account id recorded with the fingerprint, if any. */ +export async function readServerPrimaryAccountId(accountId: string): Promise { + try { + return await AsyncStorage.getItem(PRIMARY_PREFIX + accountId); + } catch { + return null; + } +} + +export async function writeServerFingerprint( + accountId: string, + fingerprint: string, + primaryAccount?: string, +): Promise { + if (primaryAccount) await AsyncStorage.setItem(PRIMARY_PREFIX + accountId, primaryAccount); await AsyncStorage.setItem(storageKey(accountId), fingerprint); } diff --git a/src/stores/__tests__/calendar-store-server-reads.test.ts b/src/stores/__tests__/calendar-store-server-reads.test.ts index 36351ff3..39e41a96 100644 --- a/src/stores/__tests__/calendar-store-server-reads.test.ts +++ b/src/stores/__tests__/calendar-store-server-reads.test.ts @@ -166,6 +166,8 @@ describe.each(['0.16', '1.0'])('calendar reads through the stor it(`removes what a subscribed feed dropped and adds only what is new on Stalwart ${version}`, async () => { serve({ + // The mirror calendar, recognised by the subscription's name. + calendars: [...CALENDARS, { id: 'feed', name: 'Feed' }], objects: { old: { id: 'old', uid: 'old@feed', title: 'Gone upstream', start: '2026-09-02T09:00:00', calendarIds: { feed: true } }, kept: { id: 'kept', uid: 'kept@feed', title: 'Still there', start: '2026-09-03T09:00:00', calendarIds: { feed: true } }, diff --git a/src/stores/__tests__/calendar-subscriptions-store.test.ts b/src/stores/__tests__/calendar-subscriptions-store.test.ts index 2a5b73af..ba609fbd 100644 --- a/src/stores/__tests__/calendar-subscriptions-store.test.ts +++ b/src/stores/__tests__/calendar-subscriptions-store.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../../api/calendar', () => ({ createCalendar: vi.fn(), deleteCalendar: vi.fn(), + getCalendarName: vi.fn(), parseCalendarBlob: vi.fn(), queryEvents: vi.fn(), getEventIdentities: vi.fn(), @@ -10,12 +11,17 @@ vi.mock('../../api/calendar', () => ({ updateEvent: vi.fn(), updateCalendar: vi.fn(), })); -vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn() })); +vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) })); vi.mock('../../api/jmap-client', () => ({ - jmapClient: { accountId: 'acc-1', isConnected: true }, + jmapClient: { accountId: 'acc-1', isConnected: true, username: 'user', serverUrl: 'https://mail.example.org' }, +})); +const calendarStore = vi.hoisted(() => ({ + importEvents: vi.fn(async () => ({ created: 1, updated: 0 })), + fetchCalendars: vi.fn(async () => undefined), + refresh: vi.fn(async () => undefined), })); vi.mock('../calendar-store', () => ({ - useCalendarStore: { getState: () => ({}) }, + useCalendarStore: { getState: () => calendarStore }, })); vi.mock('@react-native-async-storage/async-storage', () => ({ default: { @@ -58,12 +64,22 @@ describe('selectAccountSubscriptions', () => { expect(selectAccountSubscriptions(subs, 'acc-1').map((s) => s.id)).toEqual(['a', 'legacy']); expect(selectAccountSubscriptions(subs, 'acc-2').map((s) => s.id)).toEqual(['b', 'legacy']); }); + + it('tells apart accounts on two servers that gave them the same JMAP id', () => { + const both: CalendarSubscription[] = [ + { id: 'x', name: 'X', url: 'x', calendarId: 'c1', accountId: 'b', localAccountId: 'ann@one.example', lastSyncAt: null, lastError: null }, + { id: 'y', name: 'Y', url: 'y', calendarId: 'c1', accountId: 'b', localAccountId: 'bob@two.example', lastSyncAt: null, lastError: null }, + ]; + expect(selectAccountSubscriptions(both, 'b', 'ann@one.example').map((s) => s.id)).toEqual(['x']); + expect(selectAccountSubscriptions(both, 'b', 'bob@two.example').map((s) => s.id)).toEqual(['y']); + }); }); describe('updateSubscription', () => { it('renames the mirror calendar without re-sending a colour Stalwart 1.0 refuses', async () => { const update = calendarApi.updateCalendar as ReturnType; update.mockResolvedValue(undefined); + vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Feed'); useCalendarSubscriptionsStore.setState({ subscriptions: [{ id: 's', name: 'Feed', url: 'https://example.com/f.ics', color: '#FF2968FF', calendarId: 'c9', lastSyncAt: null, lastError: null }], }); @@ -74,3 +90,147 @@ describe('updateSubscription', () => { expect(useCalendarSubscriptionsStore.getState().subscriptions[0].name).toBe('Holidays'); }); }); + +describe('subscriptions of a server that was replaced (0.16.23 to 1.0)', () => { + const ME = 'user@mail.example.org'; + const OTHER = 'user@other.example.org'; + const mine = (over: Partial = {}): CalendarSubscription => ({ + id: 'mine', name: 'Holidays', url: 'https://feeds.example/holidays.ics', color: '#ff0000', + calendarId: 'c', accountId: 'acc-1', localAccountId: ME, refreshIntervalMinutes: 360, + lastSyncAt: 5, lastError: null, ...over, + }); + const only = () => useCalendarSubscriptionsStore.getState().subscriptions[0]; + + beforeEach(() => { + vi.clearAllMocks(); + useCalendarSubscriptionsStore.setState({ syncing: {} }); + globalThis.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + text: async () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', + })) as unknown as typeof fetch; + vi.mocked(calendarApi.parseCalendarBlob).mockResolvedValue([{ uid: 'e1', title: 'New year', start: '2027-01-01T00:00:00' }]); + vi.mocked(calendarApi.queryEvents).mockResolvedValue([]); + vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([]); + vi.mocked(calendarApi.createCalendar).mockResolvedValue({ id: 'n1', name: 'Holidays' } as never); + }); + + it('keeps the feed, name, colour and interval of the account\'s subscriptions and drops their mirror calendar', async () => { + useCalendarSubscriptionsStore.setState({ + subscriptions: [ + mine({ lastError: 'Feed moved' }), + // Made before subscriptions recorded their account: found by JMAP id. + { id: 'legacy', name: 'Moon', url: 'https://feeds.example/moon.ics', calendarId: 'd', accountId: 'acc-1', lastSyncAt: 5, lastError: null }, + { id: 'theirs', name: 'Sports', url: 'https://feeds.example/sports.ics', calendarId: 'c', accountId: 'acc-1', localAccountId: OTHER, lastSyncAt: 5, lastError: null }, + ], + }); + + await useCalendarSubscriptionsStore.getState().unlinkAccount(ME, { previous: 'acc-1', next: 'acc-9' }); + + const [first, legacy, theirs] = useCalendarSubscriptionsStore.getState().subscriptions; + expect(first).toEqual({ + id: 'mine', name: 'Holidays', url: 'https://feeds.example/holidays.ics', color: '#ff0000', + calendarId: null, accountId: 'acc-9', localAccountId: ME, refreshIntervalMinutes: 360, + lastSyncAt: null, lastError: null, + }); + expect(legacy).toMatchObject({ calendarId: null, accountId: 'acc-9', localAccountId: ME, url: 'https://feeds.example/moon.ics' }); + expect(theirs).toMatchObject({ calendarId: 'c', accountId: 'acc-1', localAccountId: OTHER, lastSyncAt: 5 }); + }); + + it('makes an unlinked subscription a new mirror calendar on its next sync, as adding one does', async () => { + vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Holidays'); + useCalendarSubscriptionsStore.setState({ subscriptions: [mine({ calendarId: null, lastSyncAt: null })] }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); + + expect(calendarApi.createCalendar).toHaveBeenCalledWith('Holidays', '#ff0000'); + expect(calendarStore.importEvents).toHaveBeenCalledWith([expect.objectContaining({ uid: 'e1' })], 'n1'); + expect(only()).toMatchObject({ calendarId: 'n1', accountId: 'acc-1', localAccountId: ME, lastError: null }); + expect(only().lastSyncAt).not.toBeNull(); + expect(calendarStore.refresh).toHaveBeenCalled(); + }); + + it('keeps the new mirror when the feed fails, so the next sync makes no second one', async () => { + globalThis.fetch = vi.fn(async () => ({ ok: false, status: 503, headers: { get: () => null }, text: async () => '' })) as unknown as typeof fetch; + useCalendarSubscriptionsStore.setState({ subscriptions: [mine({ calendarId: null, lastSyncAt: null })] }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); + await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); + + expect(calendarApi.createCalendar).toHaveBeenCalledTimes(1); + expect(only().calendarId).toBe('n1'); + expect(only().lastError).toContain('503'); + }); + + it('leaves the unlinked subscription of another signed-in account alone', async () => { + useCalendarSubscriptionsStore.setState({ subscriptions: [mine({ calendarId: null, localAccountId: OTHER })] }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); + + expect(calendarApi.createCalendar).not.toHaveBeenCalled(); + expect(only().calendarId).toBeNull(); + }); + + it('touches no calendar when an unlinked subscription is renamed or removed', async () => { + useCalendarSubscriptionsStore.setState({ subscriptions: [mine({ calendarId: null })] }); + + await useCalendarSubscriptionsStore.getState().updateSubscription('mine', { name: 'Public holidays', color: '#00ff00' }); + expect(only()).toMatchObject({ name: 'Public holidays', color: '#00ff00', calendarId: null }); + await useCalendarSubscriptionsStore.getState().removeSubscription('mine'); + + expect(calendarApi.getCalendarName).not.toHaveBeenCalled(); + expect(calendarApi.updateCalendar).not.toHaveBeenCalled(); + expect(calendarApi.deleteCalendar).not.toHaveBeenCalled(); + expect(useCalendarSubscriptionsStore.getState().subscriptions).toEqual([]); + }); + + it('drops no events from a calendar under the recorded id that carries another name', async () => { + // The reinstalled server gave "c" to the user's own calendar. + vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Personal'); + vi.mocked(calendarApi.queryEvents).mockResolvedValue(['p1']); + vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([{ id: 'p1', uid: 'dentist', calendarIds: { c: true } }] as never); + useCalendarSubscriptionsStore.setState({ subscriptions: [mine()] }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); + + expect(calendarApi.getCalendarName).toHaveBeenCalledWith('c'); + expect(calendarApi.queryEvents).not.toHaveBeenCalled(); + expect(calendarApi.deleteEvents).not.toHaveBeenCalled(); + expect(calendarApi.updateEvent).not.toHaveBeenCalled(); + }); + + it('still drops events that left the feed from its own mirror calendar', async () => { + vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Holidays'); + vi.mocked(calendarApi.queryEvents).mockResolvedValue(['x1', 'x2']); + vi.mocked(calendarApi.getEventIdentities).mockResolvedValue([ + { id: 'x1', uid: 'gone', calendarIds: { c: true } }, + { id: 'x2', uid: 'e1', calendarIds: { c: true } }, + ] as never); + useCalendarSubscriptionsStore.setState({ subscriptions: [mine()] }); + + await useCalendarSubscriptionsStore.getState().syncSubscription('mine'); + + expect(calendarApi.deleteEvents).toHaveBeenCalledWith(['x1']); + }); + + it('destroys only a calendar that carries the subscription name on remove', async () => { + vi.mocked(calendarApi.getCalendarName).mockResolvedValueOnce('Personal').mockResolvedValueOnce('Holidays'); + useCalendarSubscriptionsStore.setState({ subscriptions: [mine(), mine({ id: 'mine-2' })] }); + + await useCalendarSubscriptionsStore.getState().removeSubscription('mine'); + expect(calendarApi.deleteCalendar).not.toHaveBeenCalled(); + await useCalendarSubscriptionsStore.getState().removeSubscription('mine-2'); + expect(calendarApi.deleteCalendar).toHaveBeenCalledWith('c'); + }); + + it('renames only a calendar that carries the subscription name', async () => { + vi.mocked(calendarApi.getCalendarName).mockResolvedValue('Personal'); + useCalendarSubscriptionsStore.setState({ subscriptions: [mine()] }); + + await useCalendarSubscriptionsStore.getState().updateSubscription('mine', { name: 'Public holidays' }); + + expect(calendarApi.updateCalendar).not.toHaveBeenCalled(); + expect(only().name).toBe('Public holidays'); + }); +}); diff --git a/src/stores/__tests__/server-change.test.ts b/src/stores/__tests__/server-change.test.ts index cfd7d871..34e2b4c2 100644 --- a/src/stores/__tests__/server-change.test.ts +++ b/src/stores/__tests__/server-change.test.ts @@ -9,12 +9,20 @@ vi.mock('../../api/jmap-client', () => ({ import AsyncStorage from '@react-native-async-storage/async-storage'; import type { Email, JMAPSession } from '../../api/types'; import { reconcileServerFingerprint } from '../../lib/server-change'; -import { readServerFingerprint, sessionFingerprint, writeServerFingerprint } from '../../lib/server-fingerprint'; +import { + readServerFingerprint, + readServerPrimaryAccountId, + sessionFingerprint, + writeServerFingerprint, +} from '../../lib/server-fingerprint'; import { useCalendarStore } from '../calendar-store'; +import { useCalendarSubscriptionsStore, type CalendarSubscription } from '../calendar-subscriptions-store'; import { useContactsStore } from '../contacts-store'; import { useEmailStore, type AccountSnapshot } from '../email-store'; import { useOfflineCacheStore } from '../offline-cache-store'; import { useOutboxStore } from '../outbox-store'; +import { useSettingsStore } from '../settings-store'; +import { useToastStore } from '../toast-store'; // Stalwart 1.0 has no migration from 0.16: the way there is a fresh install // at the same address, where ids start over ("a", "b", ...) and the account's @@ -22,7 +30,9 @@ import { useOutboxStore } from '../outbox-store'; const ACCOUNT = 'userc@mail.example.org'; const OTHER = 'usera@other.example.org'; -function session(version: '0.16.23' | '1.0'): JMAPSession { +// The server gives the account's own data JMAP id `primary` ("f" on both +// test servers for userc; a reinstall can hand out another). +function session(version: '0.16.23' | '1.0', primary = 'f'): JMAPSession { const accountCaps: Record = { 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:emailpush': {}, @@ -35,7 +45,7 @@ function session(version: '0.16.23' | '1.0'): JMAPSession { }; if (version === '1.0') { accountCaps['urn:ietf:params:jmap:metadata'] = {}; - accountCaps['urn:ietf:params:jmap:principals:owner'] = { accountIdForPrincipal: 'f', principalId: 'f' }; + accountCaps['urn:ietf:params:jmap:principals:owner'] = { accountIdForPrincipal: primary, principalId: primary }; capabilities['urn:ietf:params:jmap:metadata'] = {}; } return { @@ -43,8 +53,8 @@ function session(version: '0.16.23' | '1.0'): JMAPSession { downloadUrl: '', uploadUrl: '', eventSourceUrl: '', - primaryAccounts: { 'urn:ietf:params:jmap:mail': 'f' }, - accounts: { f: { name: 'userc@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: accountCaps } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': primary }, + accounts: { [primary]: { name: 'userc@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: accountCaps } }, capabilities, state: 'b3bed0bb', }; @@ -65,6 +75,20 @@ const INBOX = { myRights: {} as never, accountId: 'f', isShared: false, }; +// iCal subscriptions whose mirror calendars live on the 0.16.23 server. +const SUBSCRIPTIONS: CalendarSubscription[] = [ + { + id: 'sub-mine', name: 'Holidays', url: 'https://feeds.example/holidays.ics', color: '#ff0000', + calendarId: 'c', accountId: 'f', localAccountId: ACCOUNT, refreshIntervalMinutes: 360, lastSyncAt: 1, lastError: null, + }, + // Made before subscriptions recorded their account. + { id: 'sub-legacy', name: 'Moon', url: 'https://feeds.example/moon.ics', calendarId: 'd', accountId: 'f', lastSyncAt: 1, lastError: null }, + { + id: 'sub-other', name: 'Sports', url: 'https://feeds.example/sports.ics', calendarId: 'c', accountId: 'b', + localAccountId: OTHER, lastSyncAt: 1, lastError: null, + }, +]; + const otherSnapshot: AccountSnapshot = { mailboxes: [{ ...INBOX, accountId: 'b' }], mailboxState: 'sae', @@ -88,12 +112,12 @@ async function seed(active: string): Promise { for (const account of [OTHER, ACCOUNT]) { await useOfflineCacheStore.getState().setAccount(account); await useOfflineCacheStore.getState().put({ ...email('b'), bodyValues: {} } as Email, 500); - await useOutboxStore.getState().setAccount(account); - useOutboxStore.getState().enqueue({ kind: 'destroy', emailId: 'b' }); - useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'c', patch: { $seen: true } }); await AsyncStorage.setItem(`webmail:outbox:v1:${account}:failed`, JSON.stringify([ { id: 'x', op: { kind: 'mailboxes', emailId: 'd', mailboxIds: { e: true } }, createdAt: 1, attempts: 5 }, ])); + await useOutboxStore.getState().setAccount(account); + useOutboxStore.getState().enqueue({ kind: 'destroy', emailId: 'b' }); + useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'c', patch: { $seen: true } }); await AsyncStorage.setItem(`push:notifiedIds:v1:${account}`, JSON.stringify(['b', 'a'])); await AsyncStorage.setItem(`push:lastNotifiedEmailId:v2:${account}`, 'b'); await AsyncStorage.setItem(`push:emailPushRefused:v1:${account}`, JSON.stringify(['d'])); @@ -111,10 +135,18 @@ async function seed(active: string): Promise { addressBooks: [{ id: 'a', name: 'Personal' } as never], contacts: [{ id: 'b', name: { full: 'Ann' } } as never], }); + useCalendarSubscriptionsStore.setState({ subscriptions: SUBSCRIPTIONS.map((sub) => ({ ...sub })) }); + // Identity "b" was userc@ on 0.16.23; on 1.0 it can be a group's address. + useSettingsStore.setState({ preferredIdentityIds: { f: 'b', b: 'c' } }); + useCalendarStore.setState({ hiddenCalendarIds: ['c'] }); + await AsyncStorage.setItem('webmail:calendar:hidden:v1', JSON.stringify(['c'])); + useToastStore.setState({ toasts: [] }); - await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23'))); + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('0.16.23')), 'f'); } +const subscription = (id: string) => useCalendarSubscriptionsStore.getState().subscriptions.find((s) => s.id === id)!; + const storedKeys = async () => (await AsyncStorage.getAllKeys()) as string[]; beforeEach(() => { @@ -162,6 +194,63 @@ describe('reconcileServerFingerprint', () => { expect(useContactsStore.getState().contacts).toEqual([]); expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); + expect(await readServerPrimaryAccountId(ACCOUNT)).toBe('f'); + }); + + it('keeps the iCal subscriptions of the account without their mirror calendars (0.16.23 to 1.0)', async () => { + await seed(ACCOUNT); + + await reconcileServerFingerprint(ACCOUNT, session('1.0')); + + // "c" and "d" may name other calendars on 1.0: the next sync makes new ones. + expect(subscription('sub-mine')).toEqual({ ...SUBSCRIPTIONS[0], calendarId: null, lastSyncAt: null }); + expect(subscription('sub-legacy')).toMatchObject({ calendarId: null, localAccountId: ACCOUNT, accountId: 'f' }); + expect(subscription('sub-other')).toEqual(SUBSCRIPTIONS[2]); + }); + + it('forgets the preferred identity and the hidden calendars (0.16.23 to 1.0)', async () => { + await seed(ACCOUNT); + + await reconcileServerFingerprint(ACCOUNT, session('1.0')); + + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ b: 'c' }); + expect(useCalendarStore.getState().hiddenCalendarIds).toEqual([]); + expect(await AsyncStorage.getItem('webmail:calendar:hidden:v1')).toBe('[]'); + }); + + it('follows the account to the JMAP id the new server gave it', async () => { + await seed(ACCOUNT); + + await reconcileServerFingerprint(ACCOUNT, session('1.0', 'h')); + + expect(subscription('sub-mine')).toMatchObject({ calendarId: null, accountId: 'h', localAccountId: ACCOUNT }); + expect(subscription('sub-legacy')).toMatchObject({ calendarId: null, accountId: 'h', localAccountId: ACCOUNT }); + expect(subscription('sub-other').calendarId).toBe('c'); + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ b: 'c' }); + expect(await readServerPrimaryAccountId(ACCOUNT)).toBe('h'); + }); + + it('tells the user once how many offline changes it discarded', async () => { + await seed(ACCOUNT); + + await reconcileServerFingerprint(ACCOUNT, session('1.0')); + // A later check of the same server has nothing left to drop. + await reconcileServerFingerprint(ACCOUNT, session('1.0')); + + const toasts = useToastStore.getState().toasts; + expect(toasts).toHaveLength(1); + expect(toasts[0].type).toBe('warning'); + // Two queued and one failed op. + expect(toasts[0].title).toBe('3 offline changes were discarded because the mail server changed'); + }); + + it('says nothing about an empty outbox', async () => { + await seed(ACCOUNT); + await useOutboxStore.getState().clearAccount(ACCOUNT); + + await reconcileServerFingerprint(ACCOUNT, session('1.0')); + + expect(useToastStore.getState().toasts).toEqual([]); }); it('leaves every other account alone', async () => { @@ -207,6 +296,10 @@ describe('reconcileServerFingerprint', () => { expect(useOutboxStore.getState().entries).toHaveLength(2); expect(useCalendarStore.getState().events).toHaveLength(1); expect(await AsyncStorage.getItem(`push:notifiedIds:v1:${ACCOUNT}`)).not.toBeNull(); + expect(useCalendarSubscriptionsStore.getState().subscriptions).toEqual(SUBSCRIPTIONS); + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ f: 'b', b: 'c' }); + expect(useCalendarStore.getState().hiddenCalendarIds).toEqual(['c']); + expect(useToastStore.getState().toasts).toEqual([]); }); it('only records the first server it sees for an account', async () => { diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 920296e2..505e5d26 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -281,6 +281,12 @@ export interface CalendarState { deleteTask: (id: string) => Promise; toggleCalendarVisibility: (id: string) => void; setCalendarHidden: (id: string, hidden: boolean) => void; + /** + * Show every calendar again. The hidden ids are kept for all accounts + * together, and a server that was replaced may have given one of them to + * another calendar (see lib/server-change). + */ + forgetHiddenCalendars: () => Promise; reset: () => void; } @@ -1183,6 +1189,13 @@ export const useCalendarStore = create()( persistHidden(next); }, + forgetHiddenCalendars: async () => { + // A read of the stored list still under way would bring them back. + await get().hydrate(); + set({ hiddenCalendarIds: [] }); + persistHidden([]); + }, + reset: () => { // A new session may grant access the old one lacked, or reach an // upgraded server. diff --git a/src/stores/calendar-subscriptions-store.ts b/src/stores/calendar-subscriptions-store.ts index 64050241..b1db69a7 100644 --- a/src/stores/calendar-subscriptions-store.ts +++ b/src/stores/calendar-subscriptions-store.ts @@ -5,6 +5,7 @@ import type { CalendarEvent } from '../api/types'; import { createCalendar, deleteCalendar, + getCalendarName, parseCalendarBlob, queryEvents, getEventIdentities, @@ -14,6 +15,7 @@ import { } from '../api/calendar'; import { jmapClient } from '../api/jmap-client'; import { uploadBytes } from '../api/blob'; +import { generateAccountId } from '../lib/account-utils'; import { useCalendarStore } from './calendar-store'; import { t } from './locale-store'; @@ -22,10 +24,19 @@ export interface CalendarSubscription { name: string; url: string; color?: string; - /** The local Stalwart calendar that mirrors this remote feed. */ - calendarId: string; + /** + * The local Stalwart calendar that mirrors this remote feed. Null once the + * server behind the account was replaced (lib/server-change), whose ids may + * name other calendars; the next sync makes a new mirror. + */ + calendarId: string | null; /** JMAP account the mirror calendar lives in; subs of other accounts are hidden. */ accountId?: string; + /** + * The signed-in account (username@host) it belongs to: JMAP account ids + * repeat across servers. Unset on subscriptions made before it was kept. + */ + localAccountId?: string; /** Minutes between automatic refreshes (default 60). */ refreshIntervalMinutes?: number; lastSyncAt: number | null; @@ -56,20 +67,55 @@ interface SubscriptionsState { syncAll: () => Promise; /** Refresh every subscription of the active account whose interval elapsed. */ syncDue: () => Promise; + /** + * The server behind a signed-in account was replaced (lib/server-change): + * its calendar ids name other calendars now, or none. The account's + * subscriptions keep their feed, name, colour and interval, lose their + * mirror calendar and get a new one on the next sync. Subscriptions made + * before the account was recorded on them are matched by the JMAP account + * ids the old and the new server gave it. + */ + unlinkAccount: (localAccountId: string, jmapAccountIds: { previous: string | null; next: string | null }) => Promise; } -/** Subscriptions belonging to the signed-in JMAP account (legacy ones without an accountId count as its own). */ +/** + * Subscriptions belonging to the signed-in account: by username@host where + * the subscription records it, else by JMAP account id (legacy ones without + * either count as its own). + */ export function selectAccountSubscriptions( subscriptions: CalendarSubscription[], accountId: string | null, + localAccountId?: string | null, ): CalendarSubscription[] { - return subscriptions.filter((s) => !s.accountId || !accountId || s.accountId === accountId); + return subscriptions.filter((s) => { + if (s.localAccountId && localAccountId) return s.localAccountId === localAccountId; + return !s.accountId || !accountId || s.accountId === accountId; + }); } function currentAccountId(): string | null { return jmapClient.isConnected ? jmapClient.accountId : null; } +/** The signed-in account as username@host, when the client serves one. */ +export function currentLocalAccountId(): string | null { + const { username, serverUrl } = jmapClient; + if (!jmapClient.isConnected || !username || !serverUrl) return null; + return generateAccountId(username, serverUrl); +} + +// The mirror calendar is recognised by the name the subscription gave it. +// A calendar under the recorded id with another name is not the mirror: the +// id may be reused by a replaced server, or the user renamed the calendar. +async function isMirrorCalendar(calendarId: string, name: string): Promise { + try { + return (await getCalendarName(calendarId)) === name; + } catch { + return false; + } +} + // webcal:// and webcals:// are just iCalendar over HTTP(S) — clients map // them to https. Credentials embedded in the URL (#275) are moved into an // Authorization header because RN's WHATWG fetch rejects userinfo in URLs. @@ -121,23 +167,26 @@ async function fetchAndParseFeed(url: string): Promise[]> // Reconcile the local calendar with the remote feed: drop events whose UID // disappeared upstream (unlinking rather than deleting when the event also // lives in another calendar), then import the current set (importEvents -// dedupes by UID, so unchanged events are left in place). -async function syncFeedIntoCalendar(calendarId: string, url: string): Promise { +// dedupes by UID, so unchanged events are left in place). Nothing is dropped +// from a calendar that doesn't carry the subscription's name. +async function syncFeedIntoCalendar(calendarId: string, url: string, name: string): Promise { const parsed = await fetchAndParseFeed(url); const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]); try { - const ids = await queryEvents([calendarId], '', ''); - const existing = await getEventIdentities(ids); - const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid)); - const toDelete: string[] = []; - for (const e of stale) { - const others = { ...(e.calendarIds || {}) }; - delete others[calendarId]; - if (Object.keys(others).length === 0) toDelete.push(e.id); - else await updateEvent(e.id, { calendarIds: others }); + if (await isMirrorCalendar(calendarId, name)) { + const ids = await queryEvents([calendarId], '', ''); + const existing = await getEventIdentities(ids); + const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid)); + const toDelete: string[] = []; + for (const e of stale) { + const others = { ...(e.calendarIds || {}) }; + delete others[calendarId]; + if (Object.keys(others).length === 0) toDelete.push(e.id); + else await updateEvent(e.id, { calendarIds: others }); + } + if (toDelete.length > 0) await deleteEvents(toDelete); } - if (toDelete.length > 0) await deleteEvents(toDelete); } catch { // Non-fatal: if we can't enumerate existing events, still import new ones. } @@ -161,6 +210,7 @@ export const useCalendarSubscriptionsStore = create()( color, calendarId: calendar.id, accountId: currentAccountId() ?? undefined, + localAccountId: currentLocalAccountId() ?? undefined, refreshIntervalMinutes: refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES, lastSyncAt: null, lastError: null, @@ -169,7 +219,7 @@ export const useCalendarSubscriptionsStore = create()( await useCalendarStore.getState().fetchCalendars(); try { set({ syncing: { ...get().syncing, [sub.id]: true } }); - await syncFeedIntoCalendar(sub.calendarId, sub.url); + await syncFeedIntoCalendar(calendar.id, sub.url, name); set({ subscriptions: [...get().subscriptions, { ...sub, lastSyncAt: Date.now() }] }); await useCalendarStore.getState().refresh(); return { ...sub, lastSyncAt: Date.now() }; @@ -199,7 +249,13 @@ export const useCalendarSubscriptionsStore = create()( ? { refreshIntervalMinutes: updates.refreshIntervalMinutes } : {}), }; - if (next.name !== sub.name || next.color !== sub.color) { + // An unlinked subscription has no calendar to rename yet; its next + // sync makes one with the new name and colour. + if ( + sub.calendarId + && (next.name !== sub.name || next.color !== sub.color) + && (await isMirrorCalendar(sub.calendarId, sub.name)) + ) { try { // Only what changed: Stalwart 1.0 refuses a colour that is not // #rgb, #rrggbb or a CSS name, which another client may have set. @@ -219,7 +275,9 @@ export const useCalendarSubscriptionsStore = create()( removeSubscription: async (id) => { const sub = get().subscriptions.find((s) => s.id === id); set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) }); - if (sub) { + // Only a calendar recognisably the mirror is destroyed, with its + // events: under a replaced server the recorded id can name another. + if (sub?.calendarId && (await isMirrorCalendar(sub.calendarId, sub.name))) { try { await deleteCalendar(sub.calendarId); } catch { @@ -234,14 +292,27 @@ export const useCalendarSubscriptionsStore = create()( const sub = get().subscriptions.find((s) => s.id === id); if (!sub) return; if (get().syncing[id]) return; + // An unlinked subscription gets its new mirror in its own account only. + const localAccountId = currentLocalAccountId(); + if (!sub.calendarId && (!localAccountId || (sub.localAccountId && sub.localAccountId !== localAccountId))) return; set({ syncing: { ...get().syncing, [id]: true } }); try { - await syncFeedIntoCalendar(sub.calendarId, sub.url); + let calendarId = sub.calendarId; + if (!calendarId) { + // As addSubscription does. Linked right away, so a feed that + // fails below doesn't get another calendar on the next sync. + calendarId = (await createCalendar(sub.name, sub.color)).id; + const linked = { calendarId, accountId: currentAccountId() ?? undefined, localAccountId: localAccountId ?? undefined }; + set({ subscriptions: get().subscriptions.map((s) => (s.id === id ? { ...s, ...linked } : s)) }); + await useCalendarStore.getState().fetchCalendars(); + } + await syncFeedIntoCalendar(calendarId, sub.url, sub.name); set({ subscriptions: get().subscriptions.map((s) => s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s, ), }); + if (!sub.calendarId) await useCalendarStore.getState().refresh(); } catch (err) { const message = err instanceof Error ? err.message : t('calendar.subscription.refresh_error', 'Failed to refresh subscription'); set({ @@ -255,7 +326,7 @@ export const useCalendarSubscriptionsStore = create()( }, syncAll: async () => { - const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId()); + const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId(), currentLocalAccountId()); for (const sub of subs) { await get().syncSubscription(sub.id); } @@ -264,13 +335,36 @@ export const useCalendarSubscriptionsStore = create()( syncDue: async () => { if (!jmapClient.isConnected) return; const now = Date.now(); - const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId()); + const subs = selectAccountSubscriptions(get().subscriptions, currentAccountId(), currentLocalAccountId()); for (const sub of subs) { const interval = (sub.refreshIntervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES) * 60_000; if (sub.lastSyncAt && now - sub.lastSyncAt < interval) continue; await get().syncSubscription(sub.id); } }, + + unlinkAccount: async (localAccountId, { previous, next }) => { + // Cold start: the subscriptions may not have been read from storage yet. + if (!useCalendarSubscriptionsStore.persist.hasHydrated()) { + await useCalendarSubscriptionsStore.persist.rehydrate(); + } + const owned = (s: CalendarSubscription) => (s.localAccountId + ? s.localAccountId === localAccountId + : !s.accountId || s.accountId === previous || s.accountId === next); + set({ + subscriptions: get().subscriptions.map((s) => (owned(s) + ? { + ...s, + calendarId: null, + localAccountId, + accountId: next ?? s.accountId, + // Due at once: the calendar is gone until the next sync. + lastSyncAt: null, + lastError: null, + } + : s)), + }); + }, }), { name: 'calendar-subscriptions', diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index 8ef98bcc..d8c1521e 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -116,9 +116,9 @@ interface OutboxState { /** * Drop an account's queued and failed ops, loaded or not. Every op names a * message by its server id, which a different server can have given to - * another message (see lib/server-change). + * another message (see lib/server-change). Resolves to how many went. */ - clearAccount: (accountId: string) => Promise; + clearAccount: (accountId: string) => Promise; } function persist(accountId: string, entries: OutboxEntry[]): void { @@ -447,14 +447,22 @@ export const useOutboxStore = create((set, get) => ({ }, clearAccount: async (accountId) => { - if (get().activeAccountId === accountId) { + const state = get(); + const loaded = state.activeAccountId === accountId && state.hydrated; + let dropped = loaded ? state.entries.length + state.failed.length : 0; + if (state.activeAccountId === accountId) { clearRetry(); // A read of the bucket still under way must not bring the ops back. loadEpoch += 1; set({ entries: [], failed: [], hydrated: true }); } + if (!loaded) { + const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]); + dropped = entries.length + failed.length; + } await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined); await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined); + return dropped; }, })); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index d82ec07e..4af1c706 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -461,6 +461,12 @@ export interface SettingsState extends PersistedSettings { */ ensureIdentities: () => Promise; hydrate: () => Promise; + /** + * Forget the preferred sender identity of these JMAP accounts: the id names + * an identity of a server that was replaced (lib/server-change), which the + * new one may have given to another address. + */ + forgetPreferredIdentities: (jmapAccountIds: string[]) => Promise; // Generic setter — preferred for new code. updateSetting: ( @@ -757,6 +763,16 @@ export const useSettingsStore = create((set, get) => ({ persist(snapshot(get())); }, + forgetPreferredIdentities: async (jmapAccountIds) => { + // Written before the settings were read, the defaults would replace them. + await get().hydrate(); + const current = get().preferredIdentityIds; + if (!jmapAccountIds.some((id) => id in current)) return; + const next = { ...current }; + for (const id of jmapAccountIds) delete next[id]; + get().updateSetting('preferredIdentityIds', next); + }, + setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); }, setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); }, setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); },