diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 4193d446..b638bc58 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "تذكيرات التقويم", + "participant_limit": "يسمح الخادم بحد أقصى {max, plural, zero {# مشارك} one {مشارك واحد} two {مشاركَين} few {# مشاركين} many {# مشاركًا} other {# مشارك}} لكل حدث، بمن فيهم المنظّم، لذا رفض هذا التغيير. يمكن للمسؤول رفع هذا الحد.", "starts_in_days": "{count, plural, zero {يبدأ بعد # يوم} one {يبدأ بعد يوم واحد} two {يبدأ بعد يومين} few {يبدأ بعد # أيام} many {يبدأ بعد # يومًا} other {يبدأ بعد # يوم}}", "starts_in_hours": "{count, plural, zero {يبدأ بعد # ساعة} one {يبدأ بعد ساعة واحدة} two {يبدأ بعد ساعتين} few {يبدأ بعد # ساعات} many {يبدأ بعد # ساعة} other {يبدأ بعد # ساعة}}", "starts_in_minutes": "{count, plural, zero {يبدأ بعد # دقيقة} one {يبدأ بعد دقيقة واحدة} two {يبدأ بعد دقيقتين} few {يبدأ بعد # دقائق} many {يبدأ بعد # دقيقة} other {يبدأ بعد # دقيقة}}" }, + "participants": { + "limit_warning": "أكثر من {max, plural, zero {# مشارك} one {مشارك واحد} two {مشاركَين} few {# مشاركين} many {# مشاركًا} other {# مشارك}}، بمن فيهم المنظّم: قد يرفض الخادم حفظ هذا الحدث. يمكن للمسؤول رفع هذا الحد." + }, "recurrence": { "editor_occurrences_count": "{count, plural, zero {مرة} one {مرة} two {مرات} few {مرات} many {مرة} other {مرة}}", "every_count_days": "{count, plural, zero {كل # يوم} one {كل يوم} two {كل يومين} few {كل # أيام} many {كل # يومًا} other {كل # يوم}}", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index eabc053c..2089ec27 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Recordatoris del calendari", + "participant_limit": "El servidor permet com a màxim {max, plural, one {# participant} other {# participants}} per esdeveniment, inclòs l'organitzador, per això ha rebutjat aquest canvi. Un administrador pot augmentar el límit.", "starts_in_days": "{count, plural, one {Comença d'aquí a # dia} other {Comença d'aquí a # dies}}", "starts_in_hours": "{count, plural, one {Comença d'aquí a # hora} other {Comença d'aquí a # hores}}", "starts_in_minutes": "{count, plural, one {Comença d'aquí a # minut} other {Comença d'aquí a # minuts}}" }, + "participants": { + "limit_warning": "Més de {max, plural, one {# participant} other {# participants}}, inclòs l'organitzador: és possible que el servidor es negui a desar aquest esdeveniment. Un administrador pot augmentar el límit." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {repetició} other {repeticions}}", "every_count_days": "{count, plural, one {Cada dia} other {Cada # dies}}", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index d2c0b669..f1303b24 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Připomenutí kalendáře", + "participant_limit": "Server povoluje nejvýše {max, plural, one {# účastníka} few {# účastníky} many {# účastníka} other {# účastníků}} na událost včetně organizátora, proto tuto změnu odmítl. Správce může limit zvýšit.", "starts_in_days": "{count, plural, one {Začíná za # den} few {Začíná za # dny} many {Začíná za # dne} other {Začíná za # dní}}", "starts_in_hours": "{count, plural, one {Začíná za # hodinu} few {Začíná za # hodiny} many {Začíná za # hodiny} other {Začíná za # hodin}}", "starts_in_minutes": "{count, plural, one {Začíná za # minutu} few {Začíná za # minuty} many {Začíná za # minuty} other {Začíná za # minut}}" }, + "participants": { + "limit_warning": "Více než {max, plural, one {# účastník} few {# účastníci} many {# účastníka} other {# účastníků}} včetně organizátora: server může uložení této události odmítnout. Správce může limit zvýšit." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {opakování} few {opakováních} many {opakování} other {opakováních}}", "every_count_days": "{count, plural, one {Každý den} few {Každé # dny} many {Každých # dne} other {Každých # dní}}", diff --git a/locales/rn/da.json b/locales/rn/da.json index 135fa96c..c93d7647 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Kalenderpåmindelser", + "participant_limit": "Serveren tillader højst {max, plural, one {# deltager} other {# deltagere}} pr. begivenhed, arrangøren medregnet, så ændringen blev afvist. En administrator kan hæve grænsen.", "starts_in_days": "{count, plural, one {Starter om # dag} other {Starter om # dage}}", "starts_in_hours": "{count, plural, one {Starter om # time} other {Starter om # timer}}", "starts_in_minutes": "{count, plural, one {Starter om # minut} other {Starter om # minutter}}" }, + "participants": { + "limit_warning": "Mere end {max, plural, one {# deltager} other {# deltagere}}, arrangøren medregnet: serveren kan afvise at gemme denne begivenhed. En administrator kan hæve grænsen." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {gentagelse} other {gentagelser}}", "every_count_days": "{count, plural, one {Hver dag} other {Hver #. dag}}", diff --git a/locales/rn/de.json b/locales/rn/de.json index 85a3c747..7e6e3247 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Kalendererinnerungen", + "participant_limit": "Der Server erlaubt höchstens {max, plural, one {# Teilnehmer} other {# Teilnehmer}} pro Termin, den Organisator mitgezählt, und hat die Änderung deshalb abgelehnt. Ihr Administrator kann das Limit erhöhen.", "starts_in_days": "{count, plural, one {Beginnt in # Tag} other {Beginnt in # Tagen}}", "starts_in_hours": "{count, plural, one {Beginnt in # Stunde} other {Beginnt in # Stunden}}", "starts_in_minutes": "{count, plural, one {Beginnt in # Minute} other {Beginnt in # Minuten}}" }, + "participants": { + "limit_warning": "Mehr als {max, plural, one {# Teilnehmer} other {# Teilnehmer}}, den Organisator mitgezählt: Der Server lehnt das Speichern dieses Termins möglicherweise ab. Ihr Administrator kann das Limit erhöhen." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {Termin} other {Terminen}}", "every_count_days": "{count, plural, one {Jeden Tag} other {Alle # Tage}}", diff --git a/locales/rn/en.json b/locales/rn/en.json index ccf6e213..c8764408 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Calendar reminders", + "participant_limit": "The server allows at most {max, plural, one {# participant} other {# participants}} per event, including the organizer, so it refused this change. An administrator can raise the limit.", "starts_in_days": "{count, plural, one {Starts in # day} other {Starts in # days}}", "starts_in_hours": "{count, plural, one {Starts in # hour} other {Starts in # hours}}", "starts_in_minutes": "{count, plural, one {Starts in # minute} other {Starts in # minutes}}" }, + "participants": { + "limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}", "every_count_days": "{count, plural, one {Every # day} other {Every # days}}", diff --git a/locales/rn/es.json b/locales/rn/es.json index d7d1d6ef..100eba1d 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Recordatorios del calendario", + "participant_limit": "El servidor permite como máximo {max, plural, one {# participante} other {# participantes}} por evento, incluido el organizador, por lo que rechazó este cambio. Un administrador puede aumentar el límite.", "starts_in_days": "{count, plural, one {Comienza en # día} other {Comienza en # días}}", "starts_in_hours": "{count, plural, one {Comienza en # hora} other {Comienza en # horas}}", "starts_in_minutes": "{count, plural, one {Comienza en # minuto} other {Comienza en # minutos}}" }, + "participants": { + "limit_warning": "Más de {max, plural, one {# participante} other {# participantes}}, incluido el organizador: es posible que el servidor se niegue a guardar este evento. Un administrador puede aumentar el límite." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {repetición} other {repeticiones}}", "every_count_days": "{count, plural, one {Cada día} other {Cada # días}}", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 0a643de6..444549f6 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "یادآورهای تقویم", + "participant_limit": "سرور برای هر رویداد، با احتساب برگزارکننده، حداکثر {max, plural, one {# شرکت‌کننده} other {# شرکت‌کننده}} را می‌پذیرد، به همین دلیل این تغییر را رد کرد. مدیر سیستم می‌تواند این محدودیت را افزایش دهد.", "starts_in_days": "{count, plural, one {# روز دیگر شروع می‌شود} other {# روز دیگر شروع می‌شود}}", "starts_in_hours": "{count, plural, one {# ساعت دیگر شروع می‌شود} other {# ساعت دیگر شروع می‌شود}}", "starts_in_minutes": "{count, plural, one {# دقیقه دیگر شروع می‌شود} other {# دقیقه دیگر شروع می‌شود}}" }, + "participants": { + "limit_warning": "بیش از {max, plural, one {# شرکت‌کننده} other {# شرکت‌کننده}}، با احتساب برگزارکننده: ممکن است سرور ذخیره این رویداد را رد کند. مدیر سیستم می‌تواند این محدودیت را افزایش دهد." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {بار} other {بار}}", "every_count_days": "{count, plural, one {هر # روز} other {هر # روز}}", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 31c3d0ad..84fb4118 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Rappels du calendrier", + "participant_limit": "Le serveur autorise au plus {max, plural, one {# participant} other {# participants}} par événement, organisateur compris, il a donc refusé cette modification. Un administrateur peut relever la limite.", "starts_in_days": "{count, plural, one {Commence dans # jour} other {Commence dans # jours}}", "starts_in_hours": "{count, plural, one {Commence dans # heure} other {Commence dans # heures}}", "starts_in_minutes": "{count, plural, one {Commence dans # minute} other {Commence dans # minutes}}" }, + "participants": { + "limit_warning": "Plus de {max, plural, one {# participant} other {# participants}}, organisateur compris : le serveur risque de refuser d'enregistrer cet événement. Un administrateur peut relever la limite." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}", "every_count_days": "{count, plural, one {Tous les jours} other {Tous les # jours}}", diff --git a/locales/rn/he.json b/locales/rn/he.json index fd6024e0..ece532f6 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "תזכורות לוח שנה", + "participant_limit": "השרת מאפשר עד {max, plural, one {# משתתף} other {# משתתפים}} לאירוע, כולל המארגן, ולכן דחה את השינוי. מנהל המערכת יכול להגדיל את המגבלה.", "starts_in_days": "{count, plural, one {מתחיל בעוד יום} two {מתחיל בעוד יומיים} other {מתחיל בעוד # ימים}}", "starts_in_hours": "{count, plural, one {מתחיל בעוד שעה} two {מתחיל בעוד שעתיים} other {מתחיל בעוד # שעות}}", "starts_in_minutes": "{count, plural, one {מתחיל בעוד דקה} two {מתחיל בעוד שתי דקות} other {מתחיל בעוד # דקות}}" }, + "participants": { + "limit_warning": "יותר מ-{max, plural, one {# משתתף} other {# משתתפים}}, כולל המארגן: ייתכן שהשרת יסרב לשמור את האירוע. מנהל המערכת יכול להגדיל את המגבלה." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {מופע} two {מופעים} other {מופעים}}", "every_count_days": "{count, plural, one {כל יום} two {כל יומיים} other {כל # ימים}}", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 106744db..5a3dda35 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Naptáremlékeztetők", + "participant_limit": "A szerver eseményenként legfeljebb {max, plural, one {# résztvevőt} other {# résztvevőt}} enged, a szervezőt is beleértve, ezért elutasította a módosítást. A rendszergazda megemelheti a korlátot.", "starts_in_days": "{count, plural, one {# nap múlva kezdődik} other {# nap múlva kezdődik}}", "starts_in_hours": "{count, plural, one {# óra múlva kezdődik} other {# óra múlva kezdődik}}", "starts_in_minutes": "{count, plural, one {# perc múlva kezdődik} other {# perc múlva kezdődik}}" }, + "participants": { + "limit_warning": "Több mint {max, plural, one {# résztvevő} other {# résztvevő}}, a szervezőt is beleértve: a szerver elutasíthatja az esemény mentését. A rendszergazda megemelheti a korlátot." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {alkalom} other {alkalom}}", "every_count_days": "{count, plural, one {Naponta} other {# naponta}}", diff --git a/locales/rn/it.json b/locales/rn/it.json index e07ea179..26831397 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Promemoria del calendario", + "participant_limit": "Il server consente al massimo {max, plural, one {# partecipante} other {# partecipanti}} per evento, organizzatore incluso, quindi ha rifiutato la modifica. Un amministratore può aumentare il limite.", "starts_in_days": "{count, plural, one {Inizia tra # giorno} other {Inizia tra # giorni}}", "starts_in_hours": "{count, plural, one {Inizia tra # ora} other {Inizia tra # ore}}", "starts_in_minutes": "{count, plural, one {Inizia tra # minuto} other {Inizia tra # minuti}}" }, + "participants": { + "limit_warning": "Più di {max, plural, one {# partecipante} other {# partecipanti}}, organizzatore incluso: il server potrebbe rifiutarsi di salvare questo evento. Un amministratore può aumentare il limite." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {occorrenza} other {occorrenze}}", "every_count_days": "{count, plural, one {Ogni giorno} other {Ogni # giorni}}", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 6b4e5eea..08b096cf 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "カレンダーのリマインダー", + "participant_limit": "サーバーでは1件の予定につき参加者は主催者を含めて{max, plural, other {#人}}までのため、この変更は拒否されました。管理者が上限を引き上げることができます。", "starts_in_days": "{count, plural, other {#日後に開始}}", "starts_in_hours": "{count, plural, other {#時間後に開始}}", "starts_in_minutes": "{count, plural, other {#分後に開始}}" }, + "participants": { + "limit_warning": "参加者が主催者を含めて{max, plural, other {#人}}を超えています。サーバーがこの予定の保存を拒否する可能性があります。管理者が上限を引き上げることができます。" + }, "recurrence": { "editor_occurrences_count": "{count, plural, other {回}}", "every_count_days": "{count, plural, other {#日ごと}}", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index b250046c..1d7eeabe 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "캘린더 알림", + "participant_limit": "서버에서 일정당 참석자를 주최자를 포함해 최대 {max, plural, other {#명}}까지만 허용해서 이 변경을 거부했어요. 관리자가 한도를 늘릴 수 있어요.", "starts_in_days": "{count, plural, other {#일 후 시작}}", "starts_in_hours": "{count, plural, other {#시간 후 시작}}", "starts_in_minutes": "{count, plural, other {#분 후 시작}}" }, + "participants": { + "limit_warning": "참석자가 주최자를 포함해 {max, plural, other {#명}}을 넘어요. 서버가 이 일정의 저장을 거부할 수 있어요. 관리자가 한도를 늘릴 수 있어요." + }, "recurrence": { "editor_occurrences_count": "{count, plural, other {회}}", "every_count_days": "{count, plural, other {#일마다}}", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index c7f41b04..fe1cbd18 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Kalendāra atgādinājumi", + "participant_limit": "Serveris atļauj ne vairāk kā {max, plural, one {# dalībnieku} other {# dalībniekus}} vienam pasākumam, ieskaitot organizatoru, tāpēc šīs izmaiņas tika noraidītas. Administrators var palielināt ierobežojumu.", "starts_in_days": "{count, plural, zero {Sākas pēc # dienām} one {Sākas pēc # dienas} other {Sākas pēc # dienām}}", "starts_in_hours": "{count, plural, zero {Sākas pēc # stundām} one {Sākas pēc # stundas} other {Sākas pēc # stundām}}", "starts_in_minutes": "{count, plural, zero {Sākas pēc # minūtēm} one {Sākas pēc # minūtes} other {Sākas pēc # minūtēm}}" }, + "participants": { + "limit_warning": "Vairāk nekā {max, plural, one {# dalībnieks} other {# dalībnieki}}, ieskaitot organizatoru: serveris var atteikties saglabāt šo pasākumu. Administrators var palielināt ierobežojumu." + }, "recurrence": { "editor_occurrences_count": "{count, plural, zero {reizēm} one {reizes} other {reizēm}}", "every_count_days": "{count, plural, zero {Ik pēc # dienām} one {Ik pēc # dienas} other {Ik pēc # dienām}}", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 1460f707..f848aa9c 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Хуанлийн сануулга", + "participant_limit": "Сервер нэг үйл явдалд зохион байгуулагчийг оролцуулаад хамгийн ихдээ {max, plural, other {#}} оролцогч зөвшөөрдөг тул энэ өөрчлөлтийг татгалзлаа. Администратор хязгаарыг нэмэгдүүлэх боломжтой.", "starts_in_days": "{count, plural, one {# өдрийн дараа эхэлнэ} other {# өдрийн дараа эхэлнэ}}", "starts_in_hours": "{count, plural, one {# цагийн дараа эхэлнэ} other {# цагийн дараа эхэлнэ}}", "starts_in_minutes": "{count, plural, one {# минутын дараа эхэлнэ} other {# минутын дараа эхэлнэ}}" }, + "participants": { + "limit_warning": "Зохион байгуулагчийг оролцуулаад {max, plural, other {#}}-аас олон оролцогч: сервер энэ үйл явдлыг хадгалахаас татгалзаж магадгүй. Администратор хязгаарыг нэмэгдүүлэх боломжтой." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {удаа} other {удаа}}", "every_count_days": "{count, plural, one {# өдөр бүр} other {# өдөр бүр}}", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index e0003efc..854adaf8 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Kalenderpåminnelser", + "participant_limit": "Serveren tillater høyst {max, plural, one {# deltaker} other {# deltakere}} per hendelse, arrangøren medregnet, så endringen ble avvist. En administrator kan øke grensen.", "starts_in_days": "{count, plural, one {Starter om # dag} other {Starter om # dager}}", "starts_in_hours": "{count, plural, one {Starter om # time} other {Starter om # timer}}", "starts_in_minutes": "{count, plural, one {Starter om # minutt} other {Starter om # minutter}}" }, + "participants": { + "limit_warning": "Mer enn {max, plural, one {# deltaker} other {# deltakere}}, arrangøren medregnet: serveren kan nekte å lagre denne hendelsen. En administrator kan øke grensen." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {forekomst} other {forekomster}}", "every_count_days": "{count, plural, one {Hver dag} other {Hver #. dag}}", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 7872c742..bc0352f4 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Agendaherinneringen", + "participant_limit": "De server staat maximaal {max, plural, one {# deelnemer} other {# deelnemers}} per evenement toe, de organisator meegeteld, en heeft deze wijziging daarom geweigerd. Een beheerder kan de limiet verhogen.", "starts_in_days": "{count, plural, one {Begint over # dag} other {Begint over # dagen}}", "starts_in_hours": "{count, plural, one {Begint over # uur} other {Begint over # uur}}", "starts_in_minutes": "{count, plural, one {Begint over # minuut} other {Begint over # minuten}}" }, + "participants": { + "limit_warning": "Meer dan {max, plural, one {# deelnemer} other {# deelnemers}}, de organisator meegeteld: de server kan weigeren dit evenement op te slaan. Een beheerder kan de limiet verhogen." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {herhaling} other {herhalingen}}", "every_count_days": "{count, plural, one {Elke dag} other {Elke # dagen}}", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 07e3f53a..4c333592 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Przypomnienia kalendarza", + "participant_limit": "Serwer pozwala na maksymalnie {max, plural, one {# uczestnika} few {# uczestników} many {# uczestników} other {# uczestnika}} wydarzenia łącznie z organizatorem, więc odrzucił tę zmianę. Administrator może zwiększyć limit.", "starts_in_days": "{count, plural, one {Zaczyna się za # dzień} few {Zaczyna się za # dni} many {Zaczyna się za # dni} other {Zaczyna się za # dnia}}", "starts_in_hours": "{count, plural, one {Zaczyna się za # godzinę} few {Zaczyna się za # godziny} many {Zaczyna się za # godzin} other {Zaczyna się za # godziny}}", "starts_in_minutes": "{count, plural, one {Zaczyna się za # minutę} few {Zaczyna się za # minuty} many {Zaczyna się za # minut} other {Zaczyna się za # minuty}}" }, + "participants": { + "limit_warning": "Ponad {max, plural, one {# uczestnika} few {# uczestników} many {# uczestników} other {# uczestnika}} łącznie z organizatorem: serwer może odmówić zapisania tego wydarzenia. Administrator może zwiększyć limit." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {wystąpieniu} few {wystąpieniach} many {wystąpieniach} other {wystąpienia}}", "every_count_days": "{count, plural, one {Codziennie} few {Co # dni} many {Co # dni} other {Co # dnia}}", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index fb0e1afb..b0e44991 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Lembretes do calendário", + "participant_limit": "O servidor permite no máximo {max, plural, one {# participante} other {# participantes}} por evento, incluindo o organizador, por isso recusou esta alteração. Um administrador pode aumentar o limite.", "starts_in_days": "{count, plural, one {Começa em # dia} other {Começa em # dias}}", "starts_in_hours": "{count, plural, one {Começa em # hora} other {Começa em # horas}}", "starts_in_minutes": "{count, plural, one {Começa em # minuto} other {Começa em # minutos}}" }, + "participants": { + "limit_warning": "Mais de {max, plural, one {# participante} other {# participantes}}, incluindo o organizador: o servidor pode se recusar a salvar este evento. Um administrador pode aumentar o limite." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {repetição} other {repetições}}", "every_count_days": "{count, plural, one {Todos os dias} other {A cada # dias}}", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 7ba111a9..b742cec3 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Mementouri din calendar", + "participant_limit": "Serverul permite cel mult {max, plural, one {# participant} few {# participanți} other {# de participanți}} per eveniment, inclusiv organizatorul, așa că a respins această modificare. Un administrator poate mări limita.", "starts_in_days": "{count, plural, one {Începe peste # zi} few {Începe peste # zile} other {Începe peste # de zile}}", "starts_in_hours": "{count, plural, one {Începe peste # oră} few {Începe peste # ore} other {Începe peste # de ore}}", "starts_in_minutes": "{count, plural, one {Începe peste # minut} few {Începe peste # minute} other {Începe peste # de minute}}" }, + "participants": { + "limit_warning": "Mai mult de {max, plural, one {# participant} few {# participanți} other {# de participanți}}, inclusiv organizatorul: serverul poate refuza salvarea acestui eveniment. Un administrator poate mări limita." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {ocurență} few {ocurențe} other {de ocurențe}}", "every_count_days": "{count, plural, one {În fiecare zi} few {La fiecare # zile} other {La fiecare # de zile}}", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index ed664fa1..8ea0da68 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Напоминания календаря", + "participant_limit": "Сервер допускает не более {max, plural, one {# участника} few {# участников} many {# участников} other {# участника}} в событии, включая организатора, поэтому отклонил это изменение. Администратор может увеличить лимит.", "starts_in_days": "{count, plural, one {Начнётся через # день} few {Начнётся через # дня} many {Начнётся через # дней} other {Начнётся через # дня}}", "starts_in_hours": "{count, plural, one {Начнётся через # час} few {Начнётся через # часа} many {Начнётся через # часов} other {Начнётся через # часа}}", "starts_in_minutes": "{count, plural, one {Начнётся через # минуту} few {Начнётся через # минуты} many {Начнётся через # минут} other {Начнётся через # минуты}}" }, + "participants": { + "limit_warning": "Больше {max, plural, one {# участника} few {# участников} many {# участников} other {# участника}}, включая организатора: сервер может отказаться сохранить это событие. Администратор может увеличить лимит." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {повторение} few {повторения} many {повторений} other {повторения}}", "every_count_days": "{count, plural, one {Каждый # день} few {Каждые # дня} many {Каждые # дней} other {Каждые # дня}}", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 3880fa73..eaf40c2d 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Pripomenutia kalendára", + "participant_limit": "Server povoľuje najviac {max, plural, one {# účastníka} few {# účastníkov} many {# účastníka} other {# účastníkov}} na udalosť vrátane organizátora, preto túto zmenu odmietol. Správca môže limit zvýšiť.", "starts_in_days": "{count, plural, one {Začína o # deň} few {Začína o # dni} many {Začína o # dňa} other {Začína o # dní}}", "starts_in_hours": "{count, plural, one {Začína o # hodinu} few {Začína o # hodiny} many {Začína o # hodiny} other {Začína o # hodín}}", "starts_in_minutes": "{count, plural, one {Začína o # minútu} few {Začína o # minúty} many {Začína o # minúty} other {Začína o # minút}}" }, + "participants": { + "limit_warning": "Viac ako {max, plural, one {# účastník} few {# účastníci} many {# účastníka} other {# účastníkov}} vrátane organizátora: server môže odmietnuť uloženie tejto udalosti. Správca môže limit zvýšiť." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {opakovaní} few {opakovaniach} many {opakovania} other {opakovaniach}}", "every_count_days": "{count, plural, one {Každý deň} few {Každé # dni} many {Každých # dňa} other {Každých # dní}}", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 35847f11..52f889d7 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Takvim hatırlatıcıları", + "participant_limit": "Sunucu etkinlik başına organizatör dahil en fazla {max, plural, one {# katılımcıya} other {# katılımcıya}} izin verdiği için bu değişikliği reddetti. Bir yönetici bu sınırı yükseltebilir.", "starts_in_days": "{count, plural, one {# gün sonra başlıyor} other {# gün sonra başlıyor}}", "starts_in_hours": "{count, plural, one {# saat sonra başlıyor} other {# saat sonra başlıyor}}", "starts_in_minutes": "{count, plural, one {# dakika sonra başlıyor} other {# dakika sonra başlıyor}}" }, + "participants": { + "limit_warning": "Organizatör dahil {max, plural, one {# katılımcıdan} other {# katılımcıdan}} fazla: sunucu bu etkinliği kaydetmeyi reddedebilir. Bir yönetici bu sınırı yükseltebilir." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {tekrar} other {tekrar}}", "every_count_days": "{count, plural, one {Her gün} other {Her # günde bir}}", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 066f2931..1a45cb4f 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "Нагадування календаря", + "participant_limit": "Сервер дозволяє щонайбільше {max, plural, one {# учасника} few {# учасників} many {# учасників} other {# учасника}} у події, включно з організатором, тому відхилив цю зміну. Адміністратор може збільшити ліміт.", "starts_in_days": "{count, plural, one {Почнеться через # день} few {Почнеться через # дні} many {Почнеться через # днів} other {Почнеться через # дня}}", "starts_in_hours": "{count, plural, one {Почнеться через # годину} few {Почнеться через # години} many {Почнеться через # годин} other {Почнеться через # години}}", "starts_in_minutes": "{count, plural, one {Почнеться через # хвилину} few {Почнеться через # хвилини} many {Почнеться через # хвилин} other {Почнеться через # хвилини}}" }, + "participants": { + "limit_warning": "Понад {max, plural, one {# учасника} few {# учасників} many {# учасників} other {# учасника}}, включно з організатором: сервер може відмовитися зберегти цю подію. Адміністратор може збільшити ліміт." + }, "recurrence": { "editor_occurrences_count": "{count, plural, one {повторення} few {повторення} many {повторень} other {повторення}}", "every_count_days": "{count, plural, one {Кожен # день} few {Кожні # дні} many {Кожні # днів} other {Кожні # дня}}", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 876dcb19..9e38869b 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "行事曆提醒", + "participant_limit": "伺服器每個活動最多允許 {max, plural, other {# 位}}參與者(含主辦人),因此拒絕了這項變更。管理員可以提高此限制。", "starts_in_days": "{count, plural, other {# 天後開始}}", "starts_in_hours": "{count, plural, other {# 小時後開始}}", "starts_in_minutes": "{count, plural, other {# 分鐘後開始}}" }, + "participants": { + "limit_warning": "參與者(含主辦人)超過 {max, plural, other {# 位}}:伺服器可能會拒絕儲存此活動。管理員可以提高此限制。" + }, "recurrence": { "editor_occurrences_count": "{count, plural, other {次後}}", "every_count_days": "{count, plural, other {每 # 天}}", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 90fbe1fb..1515a5b0 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -79,10 +79,14 @@ }, "notifications": { "channel_name": "日历提醒", + "participant_limit": "服务器每个活动最多允许 {max, plural, other {# 位}}参与者(含组织者),因此拒绝了此更改。管理员可以提高此限制。", "starts_in_days": "{count, plural, other {# 天后开始}}", "starts_in_hours": "{count, plural, other {# 小时后开始}}", "starts_in_minutes": "{count, plural, other {# 分钟后开始}}" }, + "participants": { + "limit_warning": "参与者(含组织者)超过 {max, plural, other {# 位}}:服务器可能会拒绝保存此活动。管理员可以提高此限制。" + }, "recurrence": { "editor_occurrences_count": "{count, plural, other {次}}", "every_count_days": "{count, plural, other {每 # 天}}", diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index df691d97..58498cfd 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -26,7 +26,10 @@ import { resetTimeZoneChecks, queryExpandedEvents, hydrateExpandedOccurrences, + maxParticipantsPerEvent, + rsvpEvent, } from '../calendar'; +import { JMAPSetError, jmapErrorType } from '../jmap-result'; import { useSettingsStore } from '../../stores/settings-store'; describe('toLocalDateTime', () => { @@ -75,7 +78,14 @@ describe('calendar operations', () => { expect(result).toEqual(calendars); expect(mockRequest).toHaveBeenCalledWith( [ - ['Calendar/get', { accountId: 'acc-1' }, '0'], + // The properties by name: Stalwart 1.0's unfinished Metadata ones stay out. + ['Calendar/get', { + accountId: 'acc-1', + properties: [ + 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault', + 'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', + ], + }, '0'], // The synthetic-id probe rides along with the first Calendar/get. ['CalendarEvent/set', { accountId: 'acc-1', update: { h333333: {} } }, 'synthetic-id-probe'], ], @@ -454,6 +464,64 @@ describe('calendar operations', () => { }); }); + describe('refusals for the participant limit (Stalwart 1.0)', () => { + // 1.0 enforces maxParticipantsPerEvent on every create and update, an + // answer included; 0.16 advertises the limit without enforcing it. + const refusal = { + type: 'invalidProperties', + description: 'An event cannot have more than 20 participants.', + properties: ['participants'], + }; + + it('keeps the SetError type of a refused create', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { notCreated: { 'new-event': refusal } }, '0']], + }); + + const error = await createEvent({ title: 'All hands' }, 'cal-1').catch((e) => e); + + expect(error).toBeInstanceOf(JMAPSetError); + expect(jmapErrorType(error)).toBe('invalidProperties'); + expect(error.message).toBe('An event cannot have more than 20 participants.'); + expect(error.properties).toEqual(['participants']); + }); + + it('keeps the SetError type of a refused update and answer', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { notUpdated: { ev1: refusal } }, '0']], + }); + + await expect(updateEvent('ev1', { title: 'All hands' })).rejects.toBeInstanceOf(JMAPSetError); + const error = await rsvpEvent('ev1', 'me', 'accepted').catch((e) => e); + expect(error).toBeInstanceOf(JMAPSetError); + expect(error.message).toBe('An event cannot have more than 20 participants.'); + }); + }); + + describe('maxParticipantsPerEvent', () => { + const session = (calendars: Record) => ({ + accounts: { + 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': calendars } }, + shared: { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } }, + }, + }); + const client = jmapClient as unknown as { isConnected?: boolean; currentSession?: unknown }; + + it.each(['0.16.23', '1.0'])('reads the calendars capability of the account, else the primary one (Stalwart %s)', () => { + client.isConnected = true; + client.currentSession = session({ maxParticipantsPerEvent: 20 }); + try { + expect(maxParticipantsPerEvent()).toBe(20); + expect(maxParticipantsPerEvent('shared')).toBe(20); + client.currentSession = session({}); + expect(maxParticipantsPerEvent()).toBeNull(); + } finally { + delete client.isConnected; + delete client.currentSession; + } + }); + }); + describe('updateEvent', () => { it('should update event fields', async () => { mockRequest.mockResolvedValue({ diff --git a/src/api/calendar.ts b/src/api/calendar.ts index ae1ae414..d332f090 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1,7 +1,7 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types'; -import { JMAPMethodError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; +import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; import { getEffectiveTimeZone } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { @@ -417,11 +417,23 @@ export function resetSyntheticIdSupport(): void { syntheticIdSupport.clear(); } +// The Calendar properties the app reads, which the default list on 0.16 and +// 1.0 covers. Stalwart 1.0 advertises JMAP Metadata but its handlers are +// still `todo!()`: a get that selects `metadata` or `privateMetadata` panics +// on the server. An explicit list keeps them out even if a later release +// adds them to the default. 0.16.6 knows every one of these. +const CALENDAR_PROPERTIES = [ + 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault', + 'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', +]; + export async function getCalendars(): Promise { const accountId = jmapClient.accountId; const probeKey = syntheticIdProbeKey(); const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey); - const methodCalls: [string, Record, string][] = [['Calendar/get', { accountId }, '0']]; + const methodCalls: [string, Record, string][] = [ + ['Calendar/get', { accountId, properties: CALENDAR_PROPERTIES }, '0'], + ]; if (carriesProbe) methodCalls.push(syntheticIdProbeCall(accountId)); // The user's time zone is checked on the way too (calendarTimeZone). const timeZone = getUserTimeZone(); @@ -443,7 +455,7 @@ export async function getCalendars(): Promise { if (calendarAccessDenied.has(sharedAccountId)) return []; try { const sharedRes = await jmapClient.request( - [['Calendar/get', { accountId: sharedAccountId }, '0']], + [['Calendar/get', { accountId: sharedAccountId, properties: CALENDAR_PROPERTIES }, '0']], USING, ); const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? []; @@ -621,6 +633,28 @@ function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | nu ]; } +/** + * The most participants an event may have, from the account's calendars + * capability (`maxParticipantsPerEvent`; the primary account's when the + * account names none), or null when it is not advertised. Stalwart 1.0 + * refuses to create or update an event with more, an answer to an + * invitation included, and counts the organizer; 0.16 advertises the same + * value without enforcing it. + */ +export function maxParticipantsPerEvent(accountId?: string): number | null { + if (!jmapClient.isConnected) return null; + const accounts = jmapClient.currentSession?.accounts; + for (const id of [accountId, jmapClient.accountId]) { + if (!id) continue; + const capability = accounts?.[id]?.accountCapabilities?.[CAPABILITIES.CALENDARS] as + | { maxParticipantsPerEvent?: unknown } + | undefined; + const max = capability?.maxParticipantsPerEvent; + if (typeof max === 'number' && max > 0) return max; + } + return null; +} + /** * The longest range an expanded query may span, in seconds: * `maxExpandedQueryDuration` of the account's calendars capability (the @@ -939,12 +973,13 @@ export async function createEvent( ); const result = methodResult<{ created?: Record; - notCreated?: Record; + notCreated?: Record; }>(res); const created = result.created?.['new-event']; if (!created) { const err = result.notCreated?.['new-event']; - throw new Error(err?.description || err?.type || 'Failed to create event'); + // With its type: the caller names Stalwart 1.0's participant limit. + throw new JMAPSetError(err?.type ?? 'setError', err?.description || err?.type || 'Failed to create event', err?.properties); } // The /set response echoes only server-set properties; merge them over the // submitted payload so the store's optimistic insert keeps recurrence data. @@ -1013,11 +1048,11 @@ export async function updateEvent( [['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']], USING, ); - const result = methodResult<{ notUpdated?: Record }>(res); + const result = methodResult<{ notUpdated?: Record }>(res); const err = result.notUpdated?.[id]; // Keep the SetError type: the store retries some refusals differently // (isOverrideSeriesKeyRejection). - if (err) throw new JMAPMethodError(err.type ?? 'setError', err.description || err.type || 'Failed to update event'); + if (err) throw new JMAPSetError(err.type ?? 'setError', err.description || err.type || 'Failed to update event', err.properties); } export async function deleteEvents( @@ -1057,9 +1092,9 @@ export async function rsvpEvent( [['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']], USING, ); - const result = methodResult<{ notUpdated?: Record }>(res); + const result = methodResult<{ notUpdated?: Record }>(res); const err = result.notUpdated?.[eventId]; - if (err) throw new Error(err.description || err.type || 'Failed to send RSVP'); + if (err) throw new JMAPSetError(err.type ?? 'setError', err.description || err.type || 'Failed to send RSVP', err.properties); } // Parse an uploaded .ics blob into one or more JSCalendar events (server-side). diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 0249ec72..cd8e471f 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + Alert as AlertDialog, Modal, View, Text, @@ -24,6 +25,7 @@ import { Users, Trash2, AlignLeft, + AlertTriangle, } from 'lucide-react-native'; import { addHours, addMinutes, format } from 'date-fns'; import type { @@ -42,7 +44,7 @@ import { timePattern, } from '../../lib/calendar-utils'; import { displayNow } from '../../lib/calendar-timezone'; -import { editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times'; +import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times'; import { useSettingsStore } from '../../stores/settings-store'; import { canCreateEventsIn } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; @@ -56,12 +58,15 @@ import { type Reminder, type ReminderUnit, } from '../../lib/calendar-alerts'; -import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence'; +import { buildRecurrenceSummary, isSimpleRecurrenceRule, recurrenceRulesEqual } from '../../lib/recurrence'; import { participantFieldsToSave, + participantLimitMessage, seedAttendees, + userOrganizes, type Attendee, } from '../../lib/calendar-participants'; +import type { SeriesEdit } from '../../lib/recurrence-overrides'; import { Button } from '..'; import { ParticipantInput } from './ParticipantInput'; import { useContactNameResolver } from '../../lib/contact-name-resolver'; @@ -89,10 +94,14 @@ interface EventModalProps { currentUserEmails?: string[]; // Client-side iCal subscriptions are read-only targets (#762). isSubscriptionCalendar?: (calendarId: string) => boolean; + /** The server's maxParticipantsPerEvent, for a warning; null when not advertised. */ + maxParticipants?: number | null; onSave: ( data: Partial, calendarId: string, - options?: { sendSchedulingMessages?: boolean }, + // `edit`: what the save knows beyond its patch, for the scope a series + // edit goes to (the attendee rows, what changed about its dates). + options?: { sendSchedulingMessages?: boolean; edit?: SeriesEdit }, ) => void | Promise; onDelete?: (event: CalendarEvent) => void; onClose: () => void; @@ -159,6 +168,7 @@ export function EventModal({ defaultCalendarId, currentUserEmails = [], isSubscriptionCalendar, + maxParticipants = null, onSave, onDelete, onClose, @@ -295,6 +305,10 @@ export function EventModal({ } }; + // An event without an organizer, or organized under one of the user's + // addresses; not an invitation someone else sent. + const organizes = userOrganizes(event, currentUserEmails); + const handleSave = async () => { if (!title.trim() || !calendarId) return; setSaving(true); @@ -332,11 +346,19 @@ export function EventModal({ locations: clearedOr(buildLocations(location), 'locations'), virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'), }; + // What changes about when the event happens: moved by the user as its + // organizer, the attendees are asked again. The dates are those of + // the event the editor was opened on - one occurrence of a series - + // whose own rule is not this save's to change. + const timesMoved = !!event && editorTimesChanged({ allDay, start, end }, event); + const ruleChanged = !!event && !recurrenceRulesEqual(event.recurrenceRules, recurrenceRules); // An invitation the user received keeps its organizer; only an event // without one is organized by the user. Object.assign(data, participantFieldsToSave(event, attendees, { userEmail: currentUserEmails[0], + userEmails: currentUserEmails, resolveName: resolveContactName, + rescheduled: timesMoved || (!event?.recurrenceId && ruleChanged), })); if (data.recurrenceRules === null) { // Dropping the rule leaves per-occurrence data dangling. @@ -347,9 +369,17 @@ export function EventModal({ if (data[key] === undefined) delete data[key]; } await onSave(data, calendarId, { - sendSchedulingMessages: attendees.length > 0 && sendInvitations, + // Only the organizer sends: on an invitation someone else sent, a + // save would reach the organizer as a reply. + sendSchedulingMessages: organizes && attendees.length > 0 && sendInvitations, + edit: { attendees, timesMoved, ruleChanged }, }); onClose(); + } catch (err) { + AlertDialog.alert( + t('calendar.notifications.event_error', 'Something went wrong'), + participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined), + ); } finally { setSaving(false); } @@ -729,7 +759,22 @@ export function EventModal({ setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase())) } /> - {attendees.length > 0 && ( + {/* Only a warning: 0.16 advertises the limit without enforcing + it, Stalwart 1.0 refuses to save more participants - the + organizer entry a save adds counts too. */} + {maxParticipants != null && attendees.length > 0 && attendees.length + 1 > maxParticipants && ( + + + + {t( + 'calendar.participants.limit_warning', + 'More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit.', + { max: maxParticipants }, + )} + + + )} + {organizes && attendees.length > 0 && ( {t('calendar.participants.send_invitations', 'Send invitations to participants')} @@ -895,6 +940,8 @@ function makeStyles(c: ThemePalette) { justifyContent: 'center', }, fieldText: { ...typography.body, color: c.text }, + limitRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.xs }, + limitText: { ...typography.caption, color: c.warning, flex: 1 }, calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, calendarSwatch: { width: 12, height: 12, borderRadius: 6 }, diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 3534354c..63de76ec 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -25,6 +25,7 @@ import { type InvitationTrustAssessment, } from '../../lib/calendar-invitation'; import { useUserCalendarAddresses } from '../../lib/calendar-user-addresses'; +import { participantLimitMessage } from '../../lib/calendar-participants'; import { canCreateEventsIn } from '../../lib/calendar-editability'; import { getCalendarColor, timePattern } from '../../lib/calendar-utils'; import { getDateFnsLocale } from '../../lib/calendar-locale'; @@ -211,8 +212,10 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { setRsvpStatus(status); setNotice(t('calendar.invitation.response_sent', 'Response sent')); setState('done'); - } catch { - setNotice(t('calendar.invitation.response_error', 'Could not send your response')); + } catch (err) { + // Stalwart 1.0 refuses an answer, too, for a meeting over its + // participant limit: say so rather than "could not". + setNotice(participantLimitMessage(err, t) ?? t('calendar.invitation.response_error', 'Could not send your response')); } finally { setBusy(false); } @@ -230,8 +233,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { : t('calendar.invitation.already_in_calendar', 'Already in your calendar'), ); setState('done'); - } catch { - setNotice(t('calendar.invitation.add_error', 'Could not add the event')); + } catch (err) { + setNotice(participantLimitMessage(err, t) ?? t('calendar.invitation.add_error', 'Could not add the event')); } finally { setBusy(false); } diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts index 2b6a6b86..aeaf2c8c 100644 --- a/src/lib/__tests__/calendar-participants.test.ts +++ b/src/lib/__tests__/calendar-participants.test.ts @@ -1,16 +1,23 @@ import { describe, it, expect } from 'vitest'; import type { CalendarEvent } from '../../api/types'; import { + askAttendeesAgain, buildParticipantMap, collectUserCalendarAddresses, getParticipantCount, getParticipantList, getStatusCounts, getUserParticipantId, + hasAnsweredAttendees, isOrganizer, participantFieldsToSave, + participantLimitMessage, + participantLimitOf, seedAttendees, + seriesParticipantFields, + userOrganizes, } from '../calendar-participants'; +import { JMAPSetError } from '../../api/jmap-result'; const event: Partial = { organizerCalendarAddress: 'mailto:Alice@example.com', @@ -201,22 +208,47 @@ describe('participantFieldsToSave', () => { me: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, }, }; - const rows = seedAttendees(invitation, ['me@example.com']); + const rows = [...seedAttendees(invitation, ['me@example.com']), { name: 'Dan', email: 'dan@example.com' }]; const fields = participantFieldsToSave(invitation, rows, { userEmail: 'me@example.com' }); expect(owner(fields)).toMatchObject({ email: 'boss@example.com', name: 'Boss', calendarAddress: 'mailto:boss@example.com' }); - expect(Object.values(fields.participants ?? {}).map((p) => p.email).sort()).toEqual(['boss@example.com', 'me@example.com']); + expect(Object.values(fields.participants ?? {}).map((p) => p.email).sort()) + .toEqual(['boss@example.com', 'dan@example.com', 'me@example.com']); expect(fields).not.toHaveProperty('organizerCalendarAddress'); }); + it('writes no participants for an invitation whose list the user left alone', () => { + // An alert or a note on the user's copy: a written list would reach the + // organizer as a reply. + const invitation: Partial = { + organizerCalendarAddress: 'mailto:boss@example.com', + participants: { + boss: { calendarAddress: 'mailto:boss@example.com', roles: { owner: true } }, + me: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + bob: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'declined' }, + }, + }; + + const fields = participantFieldsToSave(invitation, seedAttendees(invitation, ['me@example.com']), { + userEmail: 'me@example.com', + rescheduled: true, + }); + + expect(fields).toEqual({}); + }); + it('keeps the exact address a series is organized under', () => { const series: Partial = { organizerCalendarAddress: 'mailto:Team@Example.com', participants: { org: { calendarAddress: 'mailto:Team@Example.com' }, bob: { email: 'bob@example.com' } }, }; - const fields = participantFieldsToSave(series, attendees, { userEmail: 'me@example.com' }); + // The user organizes it under a group address in another letter case. + const fields = participantFieldsToSave(series, [...attendees, { name: 'Dan', email: 'dan@example.com' }], { + userEmail: 'me@example.com', + userEmails: ['me@example.com', 'team@example.com'], + }); expect(owner(fields)?.email).toBe('Team@Example.com'); expect(fields).not.toHaveProperty('organizerCalendarAddress'); @@ -253,3 +285,153 @@ describe('participantFieldsToSave', () => { expect(participantFieldsToSave({ title: 'Lunch' }, [], { userEmail: 'me@example.com' })).toEqual({}); }); }); + +describe('answers on save (Stalwart 0.16 and 1.0)', () => { + // A meeting the user organizes: Bob accepted, Carol declined, Dan never answered. + const meeting: Partial = { + organizerCalendarAddress: 'mailto:me@example.com', + participants: { + p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + p1: { calendarAddress: 'mailto:Bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + p2: { calendarAddress: 'mailto:carol@example.com', roles: { attendee: true }, participationStatus: 'declined' }, + p3: { calendarAddress: 'mailto:dan@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + }; + const me = { userEmail: 'me@example.com', userEmails: ['me@example.com'] }; + const rows = () => seedAttendees(meeting, ['me@example.com']); + const byAddress = (fields: ReturnType) => Object.fromEntries( + Object.entries(fields.participants ?? {}).map(([id, p]) => [p.email!.toLowerCase(), { id, status: p.participationStatus }]), + ); + + it('keeps every attendee\'s id and answer, and asks a new one', () => { + const fields = participantFieldsToSave(meeting, [...rows(), { name: 'Eve', email: 'eve@example.com' }], me); + + const after = byAddress(fields); + expect(after['me@example.com']).toEqual({ id: 'p0', status: 'accepted' }); + expect(after['bob@example.com']).toEqual({ id: 'p1', status: 'accepted' }); + expect(after['carol@example.com']).toEqual({ id: 'p2', status: 'declined' }); + expect(after['dan@example.com']).toEqual({ id: 'p3', status: 'needs-action' }); + expect(after['eve@example.com'].status).toBe('needs-action'); + expect(after['eve@example.com'].id).not.toMatch(/^p\d$/); + }); + + it('asks the attendees again when the user as the organizer moves the meeting', () => { + const after = byAddress(participantFieldsToSave(meeting, rows(), { ...me, rescheduled: true })); + + expect(after['me@example.com'].status).toBe('accepted'); + expect(after['bob@example.com']).toEqual({ id: 'p1', status: 'needs-action' }); + expect(after['carol@example.com']).toEqual({ id: 'p2', status: 'needs-action' }); + }); + + it('counts any of the user\'s addresses as the organizer', () => { + const aliasMeeting = { ...meeting, organizerCalendarAddress: 'mailto:Alias@example.com' } as Partial; + aliasMeeting.participants = { ...meeting.participants, p0: { ...meeting.participants!.p0, calendarAddress: 'mailto:Alias@example.com' } }; + + const after = byAddress(participantFieldsToSave(aliasMeeting, seedAttendees(aliasMeeting, ['me@example.com', 'alias@example.com']), { + userEmail: 'me@example.com', userEmails: ['me@example.com', 'alias@example.com'], rescheduled: true, + })); + + expect(after['bob@example.com'].status).toBe('needs-action'); + expect(userOrganizes(aliasMeeting, ['me@example.com', 'ALIAS@example.com'])).toBe(true); + expect(userOrganizes(aliasMeeting, ['me@example.com'])).toBe(false); + }); + + it('keeps an answer listed under a second entry of the same address', () => { + const map = buildParticipantMap({ name: '', email: 'me@example.com' }, [{ name: '', email: 'bob@example.com' }], { + a: { calendarAddress: 'mailto:bob@example.com', participationStatus: 'needs-action' }, + b: { email: 'BOB@example.com', participationStatus: 'tentative' }, + }); + + expect(Object.entries(map).find(([, p]) => p.email === 'bob@example.com')).toEqual([ + 'a', expect.objectContaining({ participationStatus: 'tentative' }), + ]); + }); + + it('tells whether anyone but the organizer answered', () => { + expect(hasAnsweredAttendees(meeting.participants)).toBe(true); + expect(hasAnsweredAttendees(askAttendeesAgain(meeting.participants!))).toBe(false); + expect(hasAnsweredAttendees(null)).toBe(false); + }); + + it('asks everyone but the organizer again for a new series', () => { + const asked = askAttendeesAgain(meeting.participants!, ['me@example.com']); + + expect(asked.p0.participationStatus).toBe('accepted'); + expect([asked.p1, asked.p2, asked.p3].map((p) => p.participationStatus)).toEqual(['needs-action', 'needs-action', 'needs-action']); + }); +}); + +describe('seriesParticipantFields (all occurrences)', () => { + const master: Partial = { + organizerCalendarAddress: 'mailto:me@example.com', + participants: { + p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + p2: { calendarAddress: 'mailto:carol@example.com', roles: { attendee: true }, participationStatus: 'tentative' }, + }, + }; + // This occurrence: Bob declined it, and Zoe was added to just this day. + const occurrence: Partial = { + ...master, + participants: { + ...master.participants, + p1: { ...master.participants!.p1, participationStatus: 'declined' }, + pz: { calendarAddress: 'mailto:zoe@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + }, + }; + const me = { userEmail: 'me@example.com', userEmails: ['me@example.com'] }; + const rows = seedAttendees(occurrence, ['me@example.com']); + const statuses = (fields: ReturnType) => Object.fromEntries( + Object.values(fields.participants ?? {}).map((p) => [p.email, p.participationStatus]), + ); + + it('writes nothing when the user changed no attendee and did not move the series', () => { + expect(seriesParticipantFields(master, occurrence, rows, me)).toEqual({}); + }); + + it('starts from the series\' own list and applies only the attendees added or removed', () => { + const edited = [...rows.filter((a) => a.email !== 'carol@example.com'), { name: '', email: 'eve@example.com' }]; + + const fields = seriesParticipantFields(master, occurrence, edited, me); + + // Bob keeps the series' answer, not this day's; Zoe stays on her day only. + expect(statuses(fields)).toEqual({ + 'me@example.com': 'accepted', + 'bob@example.com': 'accepted', + 'eve@example.com': 'needs-action', + }); + }); + + it('asks the series\' attendees again when the user moves it', () => { + const fields = seriesParticipantFields(master, occurrence, rows, { ...me, rescheduled: true }); + + expect(statuses(fields)).toEqual({ + 'me@example.com': 'accepted', + 'bob@example.com': 'needs-action', + 'carol@example.com': 'needs-action', + }); + }); +}); + +describe('participantLimitOf (Stalwart 1.0 maxParticipantsPerEvent)', () => { + it('reads the limit from a 1.0 refusal and ignores other failures', () => { + const refusal = new JMAPSetError('invalidProperties', 'An event cannot have more than 20 participants.', ['participants']); + + expect(participantLimitOf(refusal)).toBe(20); + expect(participantLimitOf(new Error('Failed to create event'))).toBeNull(); + expect(participantLimitOf(null)).toBeNull(); + // An import that got nothing in carries the refusals as reasons. + expect(participantLimitOf(Object.assign(new Error('nothing imported'), { + refused: [{ reason: 'An event cannot have more than 5 participants.' }], + }))).toBe(5); + }); + + it('says the limit, counting the organizer, in the user\'s language', () => { + const t = (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace('{max, plural, one {# participant} other {# participants}}', `${params?.max} participants`); + + expect(participantLimitMessage(new Error('An event cannot have more than 20 participants.'), t)) + .toBe('The server allows at most 20 participants per event, including the organizer, so it refused this change. An administrator can raise the limit.'); + expect(participantLimitMessage(new Error('nope'), t)).toBeNull(); + }); +}); diff --git a/src/lib/__tests__/event-editor-times.test.ts b/src/lib/__tests__/event-editor-times.test.ts index c519485e..4c300dd1 100644 --- a/src/lib/__tests__/event-editor-times.test.ts +++ b/src/lib/__tests__/event-editor-times.test.ts @@ -17,6 +17,7 @@ import { import { getEventStartDate, getTaskDueDate, getTaskDueDisplayDate } from '../calendar-utils'; import { durationBetween, + editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave, type EditorTimes, @@ -248,3 +249,48 @@ describe('task editor round trip', () => { }); }); }); + +describe('editorTimesChanged (whether a save moves the event)', () => { + // 08:00-09:00 in New York, shown in the Berlin calendar as 14:00-15:00. + const meeting = ev({ + start: '2026-10-12T08:00:00', duration: 'PT1H', timeZone: 'America/New_York', + utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T13:00:00Z', + }); + + it('is no move while the form shows the event\'s own times, whatever zone it shows them in', () => { + expect(editorTimesChanged(editorTimesFromEvent(meeting), meeting)).toBe(false); + }); + + it('ignores the seconds the form cannot show', () => { + const withSeconds = ev({ ...meeting, utcStart: '2026-10-12T12:00:30Z', utcEnd: '2026-10-12T13:00:30Z' }); + const times = editorTimesFromEvent(meeting); + expect(editorTimesChanged(times, withSeconds)).toBe(false); + }); + + it('is a move when the start or the end changes', () => { + const times = editorTimesFromEvent(meeting); + expect(editorTimesChanged(shift(times, 'both', 60), meeting)).toBe(true); + expect(editorTimesChanged(shift(times, 'end', 30), meeting)).toBe(true); + }); + + it('is a move when the event turns all-day or timed', () => { + expect(editorTimesChanged({ ...editorTimesFromEvent(meeting), allDay: true }, meeting)).toBe(true); + }); + + it('compares the first and last day of an all-day event', () => { + const holiday = ev({ start: '2026-10-12T00:00:00', duration: 'P2D', timeZone: null, showWithoutTime: true }); + const times = editorTimesFromEvent(holiday); + expect(editorTimesChanged(times, holiday)).toBe(false); + expect(editorTimesChanged({ ...times, end: new Date(times.end.getTime() + 86_400_000) }, holiday)).toBe(true); + }); + + it('does not take a zero-length event for moved (the form keeps its end)', () => { + const reminder = ev({ + start: '2026-10-12T08:00:00', duration: 'PT0S', timeZone: 'America/New_York', + utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T12:00:00Z', + }); + const times = editorTimesFromEvent(reminder); + expect(times.end.getTime()).toBe(times.start.getTime()); + expect(editorTimesChanged(times, reminder)).toBe(false); + }); +}); diff --git a/src/lib/__tests__/recurrence-expansion.test.ts b/src/lib/__tests__/recurrence-expansion.test.ts index f2552624..2d360b63 100644 --- a/src/lib/__tests__/recurrence-expansion.test.ts +++ b/src/lib/__tests__/recurrence-expansion.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { parseISO } from 'date-fns'; import { expandRecurringEvents } from '../recurrence-expansion'; +import { computeFireTime } from '../calendar-alert-scheduler'; import type { CalendarEvent, RecurrenceRule } from '../../api/types'; function ev(partial: Partial): CalendarEvent { @@ -400,3 +402,93 @@ describe('expandRecurringEvents - override patches', () => { expect(result[0].locations?.l1.name).toBe('Room A'); }); }); + +// The master's utcStart/utcEnd place its first occurrence. An occurrence the +// device expands carries the instants worked out for it, or none, never the +// master's: kept, they put every later occurrence's reminders on the first +// one's date, where they are in the past and never scheduled. +describe('expandRecurringEvents - the master\'s instants', () => { + const fifteenHoursBefore = { '@type': 'OffsetTrigger', offset: '-PT15H', relativeTo: 'start' }; + + it('gives all-day occurrences no instants, so their reminders go off before each day (0.16.23 and 1.0)', () => { + // A yearly birthday, as both servers return the master in Berlin. + const birthday = ev({ + id: 'bday', start: '2024-11-02T00:00:00', showWithoutTime: true, duration: 'P1D', timeZone: null, + utcStart: '2024-11-01T23:00:00Z', utcEnd: '2024-11-02T23:00:00Z', + recurrenceRules: [{ frequency: 'yearly' }], + }); + + const [occurrence] = expandRecurringEvents([birthday], '2026-10-01T00:00:00Z', '2026-12-01T00:00:00Z'); + + expect(occurrence.start).toBe('2026-11-02T00:00:00'); + expect(occurrence).not.toHaveProperty('utcStart'); + expect(occurrence).not.toHaveProperty('utcEnd'); + expect(computeFireTime(occurrence, fifteenHoursBefore)).toBe(parseISO('2026-11-02T00:00:00').getTime() - 15 * 3600_000); + }); + + it('gives floating occurrences no instants when the master\'s are 0.16.23\'s year 32548', () => { + const standup = ev({ + id: 'fl', start: '2026-10-05T09:00:00', duration: 'PT1H', timeZone: null, + utcStart: '32548-12-04T15:30:07Z', utcEnd: '32548-12-04T15:30:07Z', + recurrenceRules: [{ frequency: 'daily', count: 3 }], + }); + + const result = expandRecurringEvents([standup], '2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z'); + + expect(result.map((e) => e.start)).toEqual(['2026-10-05T09:00:00', '2026-10-06T09:00:00', '2026-10-07T09:00:00']); + for (const occurrence of result) { + expect(occurrence).not.toHaveProperty('utcStart'); + expect(occurrence).not.toHaveProperty('utcEnd'); + } + }); + + it('gives floating occurrences no instants when the master\'s are the next occurrence\'s (0.16.23)', () => { + // Once the first occurrence is overridden, 0.16.23 answers the master's + // utcStart/utcEnd with the next plain occurrence's. + const standup = ev({ + id: 'fl', start: '2026-10-05T09:00:00', duration: 'PT1H', timeZone: null, + utcStart: '2026-10-06T07:00:00Z', utcEnd: '2026-10-06T08:00:00Z', + recurrenceRules: [{ frequency: 'daily', count: 3 }], + recurrenceOverrides: { '2026-10-05T09:00:00': { title: 'Kick-off' } }, + }); + + const result = expandRecurringEvents([standup], '2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z'); + + expect(result.map((e) => [e.start, e.utcStart])).toEqual([ + ['2026-10-05T09:00:00', undefined], + ['2026-10-06T09:00:00', undefined], + ['2026-10-07T09:00:00', undefined], + ]); + }); + + it('carries the master\'s offset to floating occurrences when its instants are its own (1.0)', () => { + // A floating 09:00 read in Berlin summer time: 07:00Z. + const standup = ev({ + id: 'fl', start: '2026-10-05T09:00:00', duration: 'PT1H', timeZone: null, + utcStart: '2026-10-05T07:00:00Z', utcEnd: '2026-10-05T08:00:00Z', + recurrenceRules: [{ frequency: 'daily', count: 2 }], + }); + + const result = expandRecurringEvents([standup], '2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z'); + + expect(result.map((e) => [e.utcStart, e.utcEnd])).toEqual([ + ['2026-10-05T07:00:00.000Z', '2026-10-05T08:00:00.000Z'], + ['2026-10-06T07:00:00.000Z', '2026-10-06T08:00:00.000Z'], + ]); + }); + + it('times zoned occurrences from the duration when the master\'s instants are another occurrence\'s (0.16.23)', () => { + const standup = ev({ + id: 'tz', start: '2026-10-05T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', + utcStart: '2026-10-06T07:00:00Z', utcEnd: '2026-10-06T08:30:00Z', + recurrenceRules: [{ frequency: 'daily', count: 2 }], + }); + + const result = expandRecurringEvents([standup], '2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z'); + + expect(result.map((e) => [e.utcStart, e.utcEnd])).toEqual([ + ['2026-10-05T07:00:00.000Z', '2026-10-05T08:00:00.000Z'], + ['2026-10-06T07:00:00.000Z', '2026-10-06T08:00:00.000Z'], + ]); + }); +}); diff --git a/src/lib/__tests__/recurrence-overrides.test.ts b/src/lib/__tests__/recurrence-overrides.test.ts index b0a44ed7..b6400a68 100644 --- a/src/lib/__tests__/recurrence-overrides.test.ts +++ b/src/lib/__tests__/recurrence-overrides.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect } from 'vitest'; import type { CalendarEvent } from '../../api/types'; import { + buildAllScopeEdit, buildAllScopeUpdates, buildFutureSeriesData, + buildFutureSeriesEdit, isRecurringSeriesMember, truncateRecurrenceRules, } from '../recurrence-overrides'; +import { seedAttendees } from '../calendar-participants'; const master: CalendarEvent = { id: 'ev-1', @@ -109,3 +112,72 @@ describe('buildFutureSeriesData', () => { expect('recurrenceRules' in data).toBe(false); }); }); + +describe('series edits from an occurrence the user organizes', () => { + const participants = { + p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + } as CalendarEvent['participants']; + const series: CalendarEvent = { ...master, organizerCalendarAddress: 'mailto:me@example.com', participants }; + // The fourth occurrence, stretched to an hour, which Bob declined. + const fourth: CalendarEvent = { + ...series, + id: 'ev-1:2026-03-05T09:00:00', + start: '2026-03-05T09:00:00', + duration: 'PT1H', + recurrenceId: '2026-03-05T09:00:00', + participants: { ...participants, p1: { ...participants!.p1, participationStatus: 'declined' } }, + }; + const me = { userEmail: 'me@example.com', userEmails: ['me@example.com'] }; + // What the editor saves for an unchanged form: the occurrence's own times, + // the rule written back and the occurrence's participants. + const formSave = { + title: 'Standup (moved room)', + start: fourth.start, + duration: fourth.duration, + timeZone: fourth.timeZone, + showWithoutTime: false, + recurrenceRules: [{ frequency: 'daily', interval: 1, count: 10 }], + participants: fourth.participants, + } as Partial; + const attendees = seedAttendees(fourth, ['me@example.com']); + + it('changes only what the user changed when nothing moved (all occurrences)', () => { + const update = buildAllScopeEdit(formSave, fourth, series, { attendees, timesMoved: false, ruleChanged: false }, me); + + // Not the fourth's length, rule rewrite or participants (Bob's decline). + expect(update).toEqual({ title: 'Standup (moved room)' }); + }); + + it('moves the series by the occurrence\'s delta and asks its attendees again (all occurrences)', () => { + const moved = { ...formSave, start: '2026-03-05T10:00:00' }; + + const update = buildAllScopeEdit(moved, fourth, series, { attendees, timesMoved: true, ruleChanged: false }, me); + + expect(update.start).toBe('2026-03-02T10:00:00'); + const statuses = Object.values(update.participants ?? {}).map((p) => [p.calendarAddress, p.participationStatus]); + expect(statuses).toEqual([['mailto:me@example.com', 'accepted'], ['mailto:bob@example.com', 'needs-action']]); + }); + + it('asks every attendee of the new series again (this and following)', () => { + const data = buildFutureSeriesEdit(series, series.recurrenceRules!, fourth, formSave, ['me@example.com']); + + const statuses = Object.values(data.participants ?? {}).map((p) => p.participationStatus); + expect(statuses).toEqual(['accepted', 'needs-action']); + }); + + it('leaves the answers of a series someone else organizes alone (this and following)', () => { + const invitation = { ...series, organizerCalendarAddress: 'mailto:boss@example.com', participants: { + ...participants, p0: { ...participants!.p0, calendarAddress: 'mailto:boss@example.com' }, + } } as CalendarEvent; + + const data = buildFutureSeriesEdit(invitation, invitation.recurrenceRules!, fourth, {}, ['me@example.com']); + + expect(Object.values(data.participants ?? {}).map((p) => p.participationStatus)).toEqual(['accepted', 'accepted']); + }); + + it('keeps writing the times when the flags are not given', () => { + expect(buildAllScopeUpdates({ start: '2026-03-05T09:00:00', duration: 'PT1H' }, fourth, series)) + .toEqual({ duration: 'PT1H' }); + }); +}); diff --git a/src/lib/__tests__/recurrence-summary.test.ts b/src/lib/__tests__/recurrence-summary.test.ts index 43913f8d..1a2ce213 100644 --- a/src/lib/__tests__/recurrence-summary.test.ts +++ b/src/lib/__tests__/recurrence-summary.test.ts @@ -30,3 +30,23 @@ describe('recurrence summary', () => { expect(buildRecurrenceSummary(rule({ frequency: 'hourly' }), t)).toBeNull(); }); }); + +describe('recurrenceRulesEqual', () => { + it('takes the editor\'s rewrite of an unchanged rule for the server\'s normalised copy', async () => { + const { recurrenceRulesEqual } = await import('../recurrence'); + const stored = [{ + '@type': 'RecurrenceRule' as const, frequency: 'weekly', interval: 1, rscale: 'gregorian', skip: 'omit', + firstDayOfWeek: 'mo', byDay: [], + }]; + expect(recurrenceRulesEqual(stored, [{ frequency: 'weekly', interval: 1 }])).toBe(true); + expect(recurrenceRulesEqual(undefined, null)).toBe(true); + expect(recurrenceRulesEqual(stored, [{ frequency: 'daily', interval: 1 }])).toBe(false); + expect(recurrenceRulesEqual(stored, [{ frequency: 'weekly', interval: 2 }])).toBe(false); + expect(recurrenceRulesEqual(stored, [{ frequency: 'weekly', count: 5 }])).toBe(false); + expect(recurrenceRulesEqual(stored, undefined)).toBe(false); + expect(recurrenceRulesEqual( + [{ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] }], + [{ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'fr' }] }], + )).toBe(false); + }); +}); diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts index 826fbeaf..977dc3b3 100644 --- a/src/lib/calendar-participants.ts +++ b/src/lib/calendar-participants.ts @@ -1,4 +1,5 @@ import type { CalendarEvent, Participant } from '../api/types'; +import type { MessageParams } from '../i18n/format'; import { generateUUID } from './uuid'; // Port of the webmail's lib/calendar-participants.ts. @@ -244,18 +245,93 @@ export interface Attendee { email: string; } +type ParticipationStatus = Participant['participationStatus']; + +/** + * The limit a refusal names when the server turned an event down for having + * too many participants, or null for any other failure. Stalwart 1.0 enforces + * maxParticipantsPerEvent on every create and update, answers to invitations + * included, and refuses with invalidProperties "An event cannot have more + * than 20 participants."; 0.16 advertises the limit without enforcing it. + * An import that got nothing in carries the refusals as `refused` reasons. + */ +export function participantLimitOf(error: unknown): number | null { + const texts: string[] = []; + if (error instanceof Error) texts.push(error.message); + const refused = (error as { refused?: { reason?: unknown }[] } | null)?.refused; + if (Array.isArray(refused)) { + for (const entry of refused) if (typeof entry?.reason === 'string') texts.push(entry.reason); + } + for (const text of texts) { + const match = /more than (\d+) participants/i.exec(text); + if (match) return Number(match[1]); + } + return null; +} + +type Translate = (key: string, fallback?: string, params?: MessageParams) => string; + +/** + * What to tell the user about a failed calendar change the server refused + * for the participant limit, in the language of `t`; null for any other + * failure. + */ +export function participantLimitMessage(error: unknown, t: Translate): string | null { + const max = participantLimitOf(error); + if (max === null) return null; + return t( + 'calendar.notifications.participant_limit', + 'The server allows at most {max, plural, one {# participant} other {# participants}} per event, including the organizer, so it refused this change. An administrator can raise the limit.', + { max }, + ); +} + +/** + * An event's participants by address (lowercased): the id each was first + * listed under, and its answer - a real one over a missing or needs-action + * one when an address is listed twice (see getParticipantList). + */ +function participantsByAddress( + participants: CalendarEvent['participants'] | null | undefined, +): Map { + const known = new Map(); + for (const [id, p] of Object.entries(participants ?? {})) { + const key = getParticipantEmail(p).trim().toLowerCase(); + if (!key) continue; + const seen = known.get(key); + if (!seen) known.set(key, { id, status: p.participationStatus }); + else if ((!seen.status || seen.status === 'needs-action') && p.participationStatus) seen.status = p.participationStatus; + } + return known; +} + +/** + * The participants an editor save writes: the organizer, then each attendee + * once. `existing` is the event's current map. An address already in it + * keeps its participant id and its answer, so a save neither resets the + * attendees' answers - the user's own on an invitation included - nor leaves + * a recurrence override pointing at a participant id that is gone. With + * `askAgain` every attendee but the organizer answers anew (needs-action). + */ export function buildParticipantMap( organizer: { name: string; email: string }, attendees: Attendee[], + existing?: CalendarEvent['participants'] | null, + options: { askAgain?: boolean } = {}, ): Record { const participants: Record = {}; - - const generateId = () => generateUUID(); + const known = participantsByAddress(existing); + const idFor = (key: string) => { + const id = known.get(key)?.id; + return id && !(id in participants) ? id : generateUUID(); + }; // calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis // (implemented by Stalwart); the RFC 8984 sendTo property is retired there and // stored as an inert JSPROP, so it is intentionally not sent. - participants[generateId()] = { + const organizerKey = organizer.email.trim().toLowerCase(); + const organizerStatus = known.get(organizerKey)?.status; + participants[idFor(organizerKey)] = { '@type': 'Participant', name: organizer.name, email: organizer.email, @@ -264,7 +340,7 @@ export function buildParticipantMap( // scheduling emits the organizer as an ATTENDEE line in addition to the // ORGANIZER line, so the recipient sees the organizer listed twice. roles: { owner: true }, - participationStatus: 'accepted', + participationStatus: organizerStatus && organizerStatus !== 'needs-action' ? organizerStatus : 'accepted', scheduleAgent: 'server', expectReply: false, kind: 'individual', @@ -272,7 +348,7 @@ export function buildParticipantMap( // The organizer already has an entry above, and an address must not appear // twice in the invite list, so drop both cases case-insensitively (#731). - const seen = new Set([organizer.email.trim().toLowerCase()]); + const seen = new Set([organizerKey]); attendees.forEach((a) => { const email = a.email.trim(); @@ -280,13 +356,13 @@ export function buildParticipantMap( if (!key || seen.has(key)) return; seen.add(key); - participants[generateId()] = { + participants[idFor(key)] = { '@type': 'Participant', name: a.name, email, calendarAddress: `mailto:${email}`, roles: { attendee: true }, - participationStatus: 'needs-action', + participationStatus: (options.askAgain ? undefined : known.get(key)?.status) ?? 'needs-action', scheduleAgent: 'server', expectReply: true, kind: 'individual', @@ -309,11 +385,39 @@ function eventOrganizer(event: Partial): { name: string; email: s return email ? { name: '', email } : null; } +/** + * Whether a save of `event` is the user's as its organizer: an event without + * an organizer yet becomes the user's, and so is one organized under any of + * the user's addresses. An invitation someone else sent is not: its + * organizer asks, the user only answers. + */ +export function userOrganizes(event: Partial | null | undefined, userEmails: string[]): boolean { + const organizer = event ? eventOrganizer(event) : null; + if (!organizer) return true; + const key = organizer.email.toLowerCase(); + return userEmails.some((address) => address.trim().toLowerCase() === key); +} + +/** Whether two attendee lists name the same addresses, whatever the order or letter case. */ +function sameAddresses(a: Attendee[], b: Attendee[]): boolean { + const keys = (list: Attendee[]) => new Set(list.map((x) => x.email.trim().toLowerCase()).filter(Boolean)); + const left = keys(a); + const right = keys(b); + return left.size === right.size && [...left].every((key) => right.has(key)); +} + export interface ParticipantSaveContext { - /** The user's first calendar address: the organizer of invitations sent from the editor. */ + /** The user's first calendar address: the organizer of an event that has none yet. */ userEmail: string | undefined; + /** All of the user's calendar addresses (identities, aliases): whether the user organizes the event. */ + userEmails?: string[]; /** A name for an organizer the event data carries none for (the contact card, the account). */ resolveName?: (email: string) => string | undefined; + /** + * The save changes when the event happens: its start or end, whether it is + * all-day, or its recurrence rule. + */ + rescheduled?: boolean; } /** @@ -325,6 +429,15 @@ export interface ParticipantSaveContext { * occurrences). Only an event without an organizer gets the user, and only * then `organizerCalendarAddress` is written: Stalwart derives the iCalendar * ORGANIZER solely from it, so without one no invitations go out. + * Participants that stay keep their id and answer (buildParticipantMap), + * unless the user as the organizer moves the event: then the other + * attendees are asked again, as RFC 5546 has an organizer do when an event + * is rescheduled - Stalwart's update to them carries each stored answer, + * which would read as an answer to the new time. + * + * A save of an invitation someone else organizes writes no participants + * while the user left them as they were: the organizer's list is not the + * user's to send, and a written list would reach the organizer as a reply. * * Without attendees, an event that had participants loses them, its * organizer and the retired `replyTo` older releases wrote. @@ -335,6 +448,9 @@ export function participantFieldsToSave( context: ParticipantSaveContext, ): Partial> { const { userEmail, resolveName } = context; + const userEmails = [userEmail, ...(context.userEmails ?? [])].filter((address): address is string => !!address); + const organizes = userOrganizes(event, userEmails); + if (event && !organizes && sameAddresses(attendees, seedAttendees(event, userEmails))) return {}; if (attendees.length > 0 && userEmail) { const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail }; const fields: Partial> = { @@ -343,6 +459,8 @@ export function participantFieldsToSave( // organizer name. { name: organizer.name || resolveName?.(organizer.email) || '', email: organizer.email }, attendees, + event?.participants, + { askAgain: !!context.rescheduled && organizes }, ), }; if (!event?.organizerCalendarAddress) fields.organizerCalendarAddress = `mailto:${organizer.email}`; @@ -358,6 +476,68 @@ export function participantFieldsToSave( return {}; } +/** + * The participant fields a save to a whole series writes when the editor was + * opened on one of its occurrences ("All events"). The occurrence's list may + * differ from the series' - one occurrence declined, one attendee added to + * just that day - so the series starts from its own participants and takes + * only the attendees the user added or removed in the editor. Nothing is + * written when the save changes none of them and does not move the series. + */ +export function seriesParticipantFields( + master: Partial, + occurrence: Partial, + attendees: Attendee[], + context: ParticipantSaveContext, +): Partial> { + const userEmails = [context.userEmail, ...(context.userEmails ?? [])].filter((a): a is string => !!a); + const key = (a: Attendee) => a.email.trim().toLowerCase(); + const before = seedAttendees(occurrence, userEmails); + const kept = new Set(attendees.map(key)); + const removed = new Set(before.map(key).filter((k) => !kept.has(k))); + const had = new Set(before.map(key)); + const added = attendees.filter((a) => !had.has(key(a))); + const askAgain = !!context.rescheduled && userOrganizes(master, userEmails); + if (added.length === 0 && removed.size === 0 && !askAgain) return {}; + const series = seedAttendees(master, userEmails).filter((a) => !removed.has(key(a))); + const listed = new Set(series.map(key)); + for (const a of added) if (!listed.has(key(a))) series.push(a); + return participantFieldsToSave(master, series, context); +} + +/** + * `participants` with every attendee but the organizer asked anew + * (needs-action). For a new series cut off an existing one ("This and + * following events"): Stalwart sends a new event only to attendees who have + * not answered, so an answer carried over would keep it from them. + */ +export function askAttendeesAgain( + participants: Record, + organizerEmails: string[] = [], +): Record { + const organizers = new Set(organizerEmails.map((address) => address.trim().toLowerCase())); + const result: Record = {}; + for (const [id, p] of Object.entries(participants)) { + const address = getParticipantEmail(p).trim().toLowerCase(); + const organizer = !!p.roles?.owner || (!!address && organizers.has(address)); + result[id] = organizer ? p : { ...p, participationStatus: 'needs-action' }; + } + return result; +} + +/** + * Whether an attendee (anyone but the organizer) of `participants` has + * answered: Stalwart delivers a new occurrence override, and a new event, + * only to attendees who have not. + */ +export function hasAnsweredAttendees(participants: CalendarEvent['participants'] | null | undefined): boolean { + return Object.values(participants ?? {}).some((p) => { + if (p.roles?.owner) return false; + const status = p.participationStatus; + return !!status && status !== 'needs-action'; + }); +} + /** * The attendee rows an editor should start from: every participant except the * organizer (who is re-added by buildParticipantMap on save), without diff --git a/src/lib/event-editor-times.ts b/src/lib/event-editor-times.ts index d8807b83..99e2123a 100644 --- a/src/lib/event-editor-times.ts +++ b/src/lib/event-editor-times.ts @@ -1,4 +1,4 @@ -import { format, parseISO } from 'date-fns'; +import { format, isSameDay, parseISO } from 'date-fns'; import type { CalendarEvent } from '../api/types'; import { buildAllDayDuration, @@ -42,6 +42,21 @@ export function editorTimesFromEvent(event: CalendarEvent): EditorTimes { }; } +/** + * Whether the editor's times change when `event` happens: it turns all-day or + * timed, or its start or end moves - for an all-day event its first or last + * day, for a timed one the minute the editor shows. The editor keeps a time + * the user did not touch as it came, a zero-length event included, so an + * unchanged form is never a move. + */ +export function editorTimesChanged(times: EditorTimes, event: CalendarEvent): boolean { + const seed = editorTimesFromEvent(event); + if (seed.allDay !== times.allDay) return true; + if (times.allDay) return !isSameDay(seed.start, times.start) || !isSameDay(seed.end, times.end); + const minute = (date: Date) => Math.floor(date.getTime() / 60_000); + return minute(seed.start) !== minute(times.start) || minute(seed.end) !== minute(times.end); +} + export type EventTimeFields = Pick; function pad(n: number, len = 2): string { diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index 1e112ae5..0c0cbefc 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -157,11 +157,10 @@ function isInRange( ): boolean { const moved = !!override && Object.keys(override).some((key) => TIMING_KEYS.has(key.split('/', 1)[0])); if (!moved) return date >= rangeStart && date < rangeEnd; - const timed = !occurrence.showWithoutTime; - const start = (timed ? validDate(occurrence.utcStart) : null) ?? validDate(occurrence.start); + const own = occurrence.showWithoutTime ? null : plausibleInstants(occurrence); + const start = own?.start ?? validDate(occurrence.start); if (!start) return false; - const end = (timed ? validDate(occurrence.utcEnd) : null) - ?? new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000); + const end = own?.end ?? new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000); if (end <= start) return start >= rangeStart && start < rangeEnd; return start < rangeEnd && end > rangeStart; } @@ -173,6 +172,43 @@ function validDate(value: string | null | undefined): Date | null { return isNaN(date.getTime()) ? null : date; } +// UTC-12 to UTC+14: the furthest an instant is from the wall clock it names. +const MAX_ZONE_OFFSET_MS = 14 * 60 * 60 * 1000; + +/** The wall-clock fields of a parsed LocalDateTime, read as UTC. */ +function wallAsUtc(wall: Date): number { + return Date.UTC( + wall.getFullYear(), wall.getMonth(), wall.getDate(), + wall.getHours(), wall.getMinutes(), wall.getSeconds(), + ); +} + +/** `value` parsed, when it is within a time zone's offset of the wall clock `wallMs`. */ +function instantNear(value: string | null | undefined, wallMs: number): Date | null { + const instant = validDate(value); + return instant && Math.abs(instant.getTime() - wallMs) <= MAX_ZONE_OFFSET_MS ? instant : null; +} + +/** + * An event's utcStart and utcEnd, where they can be its own: each within a + * time zone's offset of the wall-clock start and end, the end not before the + * start. `end` is null when only the start passes. For a series' master + * 0.16.23 has answered both with "32548-12-04T15:30:07Z" once every + * occurrence was overridden or excluded, and with the next plain + * occurrence's times once the first one was. + */ +function plausibleInstants( + event: Pick, +): { start: Date; end: Date | null } | null { + const wall = validDate(event.start); + if (!wall) return null; + const wallStart = wallAsUtc(wall); + const start = instantNear(event.utcStart, wallStart); + if (!start) return null; + const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000); + return { start, end: end && end >= start ? end : null }; +} + // Cache Intl formatters per IANA timezone id — constructing them is expensive // and expansion runs over hundreds of occurrences. `null` marks ids the // runtime rejected so we don't retry them. @@ -221,13 +257,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; - const wallAsUtc = Date.UTC( - wall.getFullYear(), wall.getMonth(), wall.getDate(), - wall.getHours(), wall.getMinutes(), wall.getSeconds(), - ); - let guess = wallAsUtc; + const wallMs = wallAsUtc(wall); + let guess = wallMs; for (let i = 0; i < 2; i++) { - guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess); + guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess); } return new Date(guess); } @@ -258,16 +291,15 @@ function createOccurrence( const wall = parseISO(start); if (!occurrence.showWithoutTime && !isNaN(wall.getTime())) { // Unless the override sets its own duration, the master's length: from - // its instants, else from its duration. 0.16.23 has been seen to return - // instants in the year 32548 for a series' master, which don't parse. + // its instants when they are its own (plausibleInstants), else from its + // duration. let durationMs: number | null = null; const ownDuration = override && 'duration' in override ? parseDurationSeconds(occurrence.duration) : null; - const masterStart = validDate(master.utcStart); - const masterEnd = validDate(master.utcEnd); + const masterInstants = plausibleInstants(master); if (ownDuration !== null) { durationMs = ownDuration * 1000; - } else if (masterStart && masterEnd) { - durationMs = masterEnd.getTime() - masterStart.getTime(); + } else if (masterInstants?.end) { + durationMs = masterInstants.end.getTime() - masterInstants.start.getTime(); } else { const seconds = parseDurationSeconds(master.duration); if (seconds !== null) durationMs = seconds * 1000; @@ -283,21 +315,24 @@ function createOccurrence( if (durationMs !== null) { utcEnd = new Date(zoned.getTime() + durationMs).toISOString(); } - } else if (masterStart && master.start) { + } else if (masterInstants) { // Floating events (or an unrecognized timezone id): keep the master's // offset, which by definition doesn't vary. - const offsetMs = masterStart.getTime() - parseISO(master.start).getTime(); - if (!isNaN(offsetMs)) { - utcStart = new Date(wall.getTime() + offsetMs).toISOString(); - if (durationMs !== null) { - utcEnd = new Date(wall.getTime() + offsetMs + durationMs).toISOString(); - } + const offsetMs = masterInstants.start.getTime() - parseISO(master.start).getTime(); + utcStart = new Date(wall.getTime() + offsetMs).toISOString(); + if (durationMs !== null) { + utcEnd = new Date(wall.getTime() + offsetMs + durationMs).toISOString(); } } } + // The master's utcStart/utcEnd place its first occurrence, not this one: + // an occurrence carries the instants worked out for it, or none - an + // all-day one, or one whose master's instants are not its own. Kept, they + // put the reminders of every later occurrence on the first one's date. + const { utcStart: _masterStart, utcEnd: _masterEnd, ...series } = occurrence; const result: CalendarEvent = { - ...occurrence, + ...series, id: `${master.id}:${recurrenceId}`, originalId: master.originalId || master.id, uid: master.uid, @@ -308,11 +343,9 @@ function createOccurrence( recurrenceOverrides: master.recurrenceOverrides, excludedRecurrenceRules: master.excludedRecurrenceRules, }; - // The master's own instants place the first occurrence, not this one. if (utcStart) { result.utcStart = utcStart; if (utcEnd) result.utcEnd = utcEnd; - else delete result.utcEnd; } return result; } diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index 1aeafe31..2ff3e82a 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -553,7 +553,7 @@ function knownOverrides(occurrence: Partial): OverrideMap | null } /** True when a change to `occurrence` creates its override, false when one exists or it is unknown. */ -function createsOverride(occurrence: Partial): boolean { +export function createsOverride(occurrence: Partial): boolean { const overrides = knownOverrides(occurrence); if (overrides === undefined || !occurrence.recurrenceId) return false; const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides); @@ -709,3 +709,28 @@ export function buildOccurrenceRsvpPatch( }; return withNewOverrideDetails(occurrence, patch as Partial, options); } + +/** + * The two writes, on the base event, of a first change to an occurrence the + * device expanded that keeps its attendees' answers: `quiet` creates the + * override as the occurrence stands (to be sent without scheduling), then + * `change` rewrites that override with the change. Stalwart sends a new + * override only to attendees who have not answered; an update of one goes to + * everyone. Null when the occurrence cannot be keyed. + */ +export function buildOverrideInTwoSteps( + instance: OccurrenceOverrideContext & Pick, + patch: Partial, +): { quiet: Record; change: Record } | null { + const key = resolveOverrideKey(instance, instance.recurrenceOverrides); + if (!key) return null; + const entry = mergeOverrideEntry({}, { + start: instance.start, + duration: instance.duration, + ...overrideEntryFields(withNewOverrideDetails(instance, {}, { seriesKeys: true })), + }); + return { + quiet: overrideEntryPatch(instance, key, entry), + change: { [`recurrenceOverrides/${key}`]: mergeOverrideEntry(entry, overrideEntryFields(patch)) }, + }; +} diff --git a/src/lib/recurrence-overrides.ts b/src/lib/recurrence-overrides.ts index 9b37b75d..0cff9c4f 100644 --- a/src/lib/recurrence-overrides.ts +++ b/src/lib/recurrence-overrides.ts @@ -1,5 +1,13 @@ import { format, parseISO } from 'date-fns'; import type { CalendarEvent, RecurrenceRule } from '../api/types'; +import { + askAttendeesAgain, + getEventOrganizerEmails, + seriesParticipantFields, + userOrganizes, + type Attendee, + type ParticipantSaveContext, +} from './calendar-participants'; /** Identity / whole-series keys that must never go into a single-occurrence override. */ export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [ @@ -53,20 +61,40 @@ export function truncateRecurrenceRules( }); } +/** What an editor save changed about when the event happens. */ +export interface ScheduleChanges { + /** Its start, end or all-day. */ + timesMoved?: boolean; + /** Its recurrence rule. */ + ruleChanged?: boolean; +} + /** * Updates for the "All events" scope. The editor seeds its date fields from * the tapped occurrence, so an unchanged `start` must not be written to the * master (that would move the whole series onto the occurrence's date and * make the earlier occurrences vanish). When the user did move the occurrence, - * the same delta is applied to the master's start instead. + * the same delta is applied to the master's start instead. With + * `changes.timesMoved` false none of the occurrence's times reach the master + * - a moved or resized occurrence would move or resize every one - and with + * `changes.ruleChanged` false neither does the rule the editor wrote back. */ export function buildAllScopeUpdates( updates: Partial, occurrence: Pick, master: Pick, + changes: ScheduleChanges = {}, ): Partial { const out: Partial = { ...updates }; delete (out as Record).recurrenceId; + if (changes.ruleChanged === false) delete out.recurrenceRules; + if (changes.timesMoved === false) { + delete out.start; + delete out.duration; + delete out.timeZone; + delete out.showWithoutTime; + return out; + } if (!out.start) return out; if (!occurrence.recurrenceId) return out; // editing the master itself const nextStart = parseISO(out.start); @@ -140,3 +168,58 @@ export function buildFutureSeriesData( } return data; } + +/** An editor save of one occurrence, for the scope it is applied to. */ +export interface SeriesEdit extends ScheduleChanges { + /** The attendee rows the user left. */ + attendees: Attendee[]; +} + +/** + * The update of a whole series ("All events") from an editor opened on one of + * its occurrences: its times only when the user moved the occurrence (and + * then by the same delta), its rule only when the user changed it, and its + * participants from the series' own list with the attendees the user added + * or removed (seriesParticipantFields) - never the occurrence's list, whose + * answers are that day's. A series the user organizes and moves asks its + * attendees again. + */ +export function buildAllScopeEdit( + updates: Partial, + occurrence: CalendarEvent, + master: CalendarEvent, + edit: SeriesEdit, + context: ParticipantSaveContext, +): Partial { + const out = buildAllScopeUpdates(updates, occurrence, master, edit); + delete out.participants; + delete out.organizerCalendarAddress; + delete out.replyTo; + return { + ...out, + ...seriesParticipantFields(master, occurrence, edit.attendees, { + ...context, + rescheduled: !!edit.timesMoved || !!edit.ruleChanged, + }), + }; +} + +/** + * The new series of "This and following events", as buildFutureSeriesData + * makes it. It is a new event (a new UID) to its attendees, and Stalwart + * sends a new event only to attendees who have not answered: when the user + * organizes it, everyone but the organizer is asked anew. + */ +export function buildFutureSeriesEdit( + master: CalendarEvent, + originalRules: RecurrenceRule[] | null, + occurrence: Pick, + updates: Partial, + userEmails: string[], +): Partial { + const data = buildFutureSeriesData(master, originalRules, occurrence, updates); + if (data.participants && userOrganizes(data, userEmails)) { + data.participants = askAttendeesAgain(data.participants, getEventOrganizerEmails(data)); + } + return data; +} diff --git a/src/lib/recurrence.ts b/src/lib/recurrence.ts index b7fd465b..f69e4436 100644 --- a/src/lib/recurrence.ts +++ b/src/lib/recurrence.ts @@ -127,6 +127,41 @@ export function isSimpleRecurrenceRule(rule: RecurrenceRule): boolean { ); } +// A rule as the occurrences it makes: JSCalendar defaults filled in, empty +// lists left out, so an editor's rewrite of an unchanged rule compares equal +// to the server's normalised copy. +function ruleKey(rule: RecurrenceRule): string { + const list = (values: T[] | undefined) => (values?.length ? values : undefined); + return JSON.stringify({ + frequency: rule.frequency.toLowerCase(), + interval: rule.interval || 1, + until: rule.until || undefined, + count: rule.count || undefined, + byDay: list(rule.byDay?.map((d) => `${d.nthOfPeriod ?? ''}${d.day.toLowerCase()}`)), + byMonth: list(rule.byMonth), + byMonthDay: list(rule.byMonthDay), + byYearDay: list(rule.byYearDay), + byWeekNo: list(rule.byWeekNo), + byHour: list(rule.byHour), + byMinute: list(rule.byMinute), + bySecond: list(rule.bySecond), + bySetPosition: list(rule.bySetPosition), + firstDayOfWeek: (rule.firstDayOfWeek || 'mo').toLowerCase(), + rscale: (rule.rscale || 'gregorian').toLowerCase(), + skip: (rule.skip || 'omit').toLowerCase(), + }); +} + +/** Whether two recurrence rule lists make the same occurrences (none equals none). */ +export function recurrenceRulesEqual( + a: RecurrenceRule[] | null | undefined, + b: RecurrenceRule[] | null | undefined, +): boolean { + const left = (a ?? []).map(ruleKey); + const right = (b ?? []).map(ruleKey); + return left.length === right.length && left.every((key, i) => key === right[i]); +} + /** * Human-readable summary of a recurrence rule, e.g. * "Every 2 months on the third Thursday · 12 occurrences", in the app diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 32c77c9e..9fda8733 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -92,11 +92,15 @@ import { } from '../lib/calendar-utils'; import { buildReplyTo } from '../lib/calendar-invitation'; import { - buildAllScopeUpdates, - buildFutureSeriesData, + buildAllScopeEdit, + buildFutureSeriesEdit, isRecurringSeriesMember, truncateRecurrenceRules, + type SeriesEdit, } from '../lib/recurrence-overrides'; +import { participantLimitMessage } from '../lib/calendar-participants'; +import { maxParticipantsPerEvent } from '../api/calendar'; +import { useContactNameResolver } from '../lib/contact-name-resolver'; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; @@ -116,6 +120,8 @@ type PendingAction = updates: Partial; calendarId: string; sendScheduling?: boolean; + // The attendee rows and date changes the scope builds its write from. + edit?: SeriesEdit; } | { kind: 'delete'; event: CalendarEvent } | { @@ -159,6 +165,7 @@ export default function CalendarScreen() { const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const currentUserEmails = useUserCalendarAddresses(); + const resolveContactName = useContactNameResolver(); const { locale } = useCalendarLocale(); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); @@ -651,7 +658,7 @@ export default function CalendarScreen() { } catch (err) { Alert.alert( t('calendar.notifications.rsvp_error', 'Failed to update response'), - err instanceof Error ? err.message : undefined, + participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined), ); } return; @@ -660,6 +667,11 @@ export default function CalendarScreen() { if (action.kind === 'edit') { const { updates } = action; const opts = { sendSchedulingMessages: action.sendScheduling }; + const participantContext = { + userEmail: currentUserEmails[0], + userEmails: currentUserEmails, + resolveName: resolveContactName, + }; switch (scope) { case 'this': { // The store keeps the change on this occurrence: through its @@ -672,7 +684,7 @@ export default function CalendarScreen() { const result = await truncateRecurrenceAtEvent(event); if (!result) throw new Error('Master event not found'); const { master, originalRules } = result; - const newEventData = buildFutureSeriesData(master, originalRules, event, updates); + const newEventData = buildFutureSeriesEdit(master, originalRules, event, updates, currentUserEmails); delete newEventData.calendarIds; const targetCalendarId = action.calendarId || getPrimaryCalendarId(master) || ''; @@ -692,7 +704,10 @@ export default function CalendarScreen() { case 'all': { const master = await getMasterEvent(event); if (!master) throw new Error('Master event not found'); - await updateEvent(master.id, buildAllScopeUpdates(updates, event, master), opts); + const changes = action.edit + ? buildAllScopeEdit(updates, event, master, action.edit, participantContext) + : updates; + await updateEvent(master.id, changes, opts); break; } } @@ -720,7 +735,7 @@ export default function CalendarScreen() { } catch (err) { Alert.alert( t('calendar.notifications.event_error', 'Something went wrong'), - err instanceof Error ? err.message : undefined, + participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined), ); } try { @@ -729,14 +744,17 @@ export default function CalendarScreen() { // Best effort — the next navigation refetches anyway. } }, - [pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh, submitRsvp, t], + [ + pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh, + submitRsvp, t, currentUserEmails, resolveContactName, + ], ); const handleSave = React.useCallback( async ( data: Partial, calendarId: string, - options?: { sendSchedulingMessages?: boolean }, + options?: { sendSchedulingMessages?: boolean; edit?: SeriesEdit }, ) => { const sendScheduling = options?.sendSchedulingMessages; if (modalEvent) { @@ -749,7 +767,7 @@ export default function CalendarScreen() { if (isRecurringSeriesMember(modalEvent)) { // Ask which occurrences the edit applies to; the actual write // happens in handleScopeSelect. - setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId, sendScheduling }); + setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId, sendScheduling, edit: options?.edit }); return; } await updateEvent(modalEvent.id, updates, { sendSchedulingMessages: sendScheduling }); @@ -1164,7 +1182,14 @@ export default function CalendarScreen() { setPendingAction({ kind: 'rsvp', event: ev, participantId, status }); return; } - await submitRsvp(ev, participantId, status, 'series'); + try { + await submitRsvp(ev, participantId, status, 'series'); + } catch (err) { + Alert.alert( + t('calendar.notifications.rsvp_error', 'Failed to update response'), + participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined), + ); + } }} /> @@ -1175,6 +1200,7 @@ export default function CalendarScreen() { defaultDate={modalDate} currentUserEmails={currentUserEmails} isSubscriptionCalendar={isSubscriptionCalendar} + maxParticipants={maxParticipantsPerEvent(modalEvent?.accountId)} onSave={handleSave} onDelete={handleDeleteFromModal} onClose={() => setModalVisible(false)} diff --git a/src/stores/__tests__/calendar-store-occurrence-answers.test.ts b/src/stores/__tests__/calendar-store-occurrence-answers.test.ts new file mode 100644 index 00000000..a5f0c40a --- /dev/null +++ b/src/stores/__tests__/calendar-store-occurrence-answers.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Changing one occurrence of a meeting the user organizes. Stalwart 0.16.6 +// to 1.0 send a new occurrence override - the first change to an occurrence +// - only to attendees who have not answered (no PARTSTAT or NEEDS-ACTION, +// crates/groupware scheduling/organizer.rs), so a change that keeps the +// answers goes out in two writes: the override as the occurrence stands, +// quietly, then the change as an update of it, which reaches everyone. +// And while a series uses its calendar's default alerts, 1.0 shows the +// defaults in place of an occurrence's own and refuses useDefaultAlerts on +// one occurrence: the series stops using them first. + +vi.mock('../../api/calendar', () => ({ + getCalendars: vi.fn(), + queryEvents: vi.fn(async () => []), + getEvents: vi.fn(), + getEventIdentities: vi.fn(async () => []), + scanCalendarObjects: vi.fn(async () => []), + isCalendarAccessDenied: vi.fn(() => false), + noteCalendarAccessError: vi.fn(() => false), + resetCalendarAccessDenied: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(async () => undefined), + deleteEvents: vi.fn(), + batchCreateEvents: vi.fn(), + setDefaultCalendar: vi.fn(), + rsvpEvent: vi.fn(), + supportsSyntheticCalendarIds: vi.fn(async () => false), + queryExpandedEvents: vi.fn(), + hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events), + resetSyntheticIdSupport: vi.fn(), + resetTimeZoneChecks: vi.fn(), +})); + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + removeItem: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { accountId: 'acc-1', isConnected: true }, +})); + +import * as calendarApi from '../../api/calendar'; +import type { CalendarEvent } from '../../api/types'; +import { useCalendarStore } from '../calendar-store'; + +const mockUpdate = vi.mocked(calendarApi.updateEvent); +const mockGetEvents = vi.mocked(calendarApi.getEvents); +const mockQueryExpanded = vi.mocked(calendarApi.queryExpandedEvents); + +const RID = '2026-03-02T09:00:00'; +const alert15 = { '@type': 'Alert' as const, trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' }; +const alert30 = { '@type': 'Alert' as const, trigger: { '@type': 'OffsetTrigger', offset: '-PT30M', relativeTo: 'start' }, action: 'display' }; + +// Organized by the user; Bob accepted the series. +const participants = { + p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, +}; +const askedAgain = { ...participants, p1: { ...participants.p1, participationStatus: 'needs-action' } }; + +// Expanded by the server (a synthetic id), its series' overrides hydrated: none yet. +const serverOccurrence = (partial: Partial = {}): CalendarEvent => ({ + id: 's1', baseEventId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H', + title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null, + calendarIds: { 'cal-1': true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1, + utcStart: '2026-03-02T08:00:00Z', + ...partial, +} as unknown as CalendarEvent); + +// Expanded on the device (0.16 without synthetic ids): changes become +// overrides on the base event. +const deviceOccurrence = (partial: Partial = {}): CalendarEvent => ({ + id: `base:${RID}`, originalId: 'base', uid: 'series@example.com', recurrenceId: RID, start: RID, duration: 'PT1H', + title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }], recurrenceOverrides: null, + calendarIds: { 'cal-1': true }, organizerCalendarAddress: 'mailto:me@example.com', participants, sequence: 1, + ...partial, +} as unknown as CalendarEvent); + +beforeEach(() => { + vi.clearAllMocks(); + useCalendarStore.getState().reset(); + useCalendarStore.setState({ calendars: [{ id: 'cal-1', name: 'Work' }] as never, loadedRange: null }); + mockGetEvents.mockResolvedValue([]); + mockQueryExpanded.mockResolvedValue([]); +}); + +describe.each(['0.16.23', '1.0'])('a first change to one server occurrence on Stalwart %s', () => { + it('writes the override as it stands, then sends the change to its re-read id', async () => { + useCalendarStore.setState({ events: [serverOccurrence()] }); + // The new override gives the occurrence another synthetic id. + mockQueryExpanded.mockResolvedValue(['s9', 'other']); + mockGetEvents.mockImplementation(async (ids: string[]) => (ids.includes('s9') + ? [ + { id: 'other', baseEventId: 'else', recurrenceId: RID, start: RID } as CalendarEvent, + { id: 's9', baseEventId: 'base', recurrenceId: RID, start: RID, duration: 'PT1H' } as CalendarEvent, + ] + : [])); + + await useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial, { + sendSchedulingMessages: true, + }); + + expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([ + ['s1', { title: 'Standup', participants, sequence: 1 }, false], + ['s9', { title: 'Standup, room 2', participants }, true], + ]); + // Looked up around the occurrence, expanded. + expect(mockGetEvents).toHaveBeenCalledWith(['s9', 'other'], undefined, { expanded: true }); + }); + + it('sends the change once when it asks the attendees again (a move)', async () => { + useCalendarStore.setState({ events: [serverOccurrence()] }); + + await useCalendarStore.getState().updateEvent('s1', { + start: '2026-03-02T10:00:00', participants: askedAgain, + } as Partial, { sendSchedulingMessages: true }); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate.mock.calls[0]).toEqual(['s1', expect.objectContaining({ participants: askedAgain }), true, undefined]); + }); + + it('sends the change once to an occurrence that has its override already', async () => { + useCalendarStore.setState({ events: [serverOccurrence({ recurrenceOverrides: { [RID]: { title: 'Standup' } } })] }); + + await useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial, { + sendSchedulingMessages: true, + }); + + expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', true]]); + }); + + it('writes once without scheduling', async () => { + useCalendarStore.setState({ events: [serverOccurrence()] }); + + await useCalendarStore.getState().updateEvent('s1', { title: 'My note' }, { sendSchedulingMessages: false }); + + expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', false]]); + }); + + it('sends the change to the old id when the occurrence cannot be found again', async () => { + useCalendarStore.setState({ events: [serverOccurrence()] }); + mockQueryExpanded.mockResolvedValue(null); + + await useCalendarStore.getState().updateEvent('s1', { title: 'Standup, room 2', participants } as Partial, { + sendSchedulingMessages: true, + }); + + expect(mockUpdate.mock.calls.map(([id, , schedule]) => [id, schedule])).toEqual([['s1', false], ['s1', true]]); + }); +}); + +describe('a first change to one occurrence expanded on the device (Stalwart 0.16.23)', () => { + it('writes the override on the base event as it stands, then rewrites it with the change', async () => { + useCalendarStore.setState({ events: [deviceOccurrence()] }); + + await useCalendarStore.getState().updateEvent(`base:${RID}`, { + title: 'Standup, room 2', participants, + } as Partial, { sendSchedulingMessages: true }); + + const entry = { + start: RID, duration: 'PT1H', title: 'Standup', participants, sequence: 1, + organizerCalendarAddress: 'mailto:me@example.com', + }; + expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([ + ['base', { recurrenceOverrides: { [RID]: entry } }, false], + ['base', { [`recurrenceOverrides/${RID}`]: { ...entry, title: 'Standup, room 2' } }, true], + ]); + }); + + it('writes one override when the attendees are asked again', async () => { + useCalendarStore.setState({ events: [deviceOccurrence()] }); + + await useCalendarStore.getState().updateEvent(`base:${RID}`, { + start: '2026-03-02T10:00:00', participants: askedAgain, + } as Partial, { sendSchedulingMessages: true }); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + }); +}); + +describe('an occurrence\'s alerts on a series that uses the calendar\'s default alerts', () => { + const edit = { alerts: { 'reminder-1': alert30 }, useDefaultAlerts: false } as Partial; + + it('turns the defaults off for the series first, keeping what it shows under ids of its own (Stalwart 1.0)', async () => { + useCalendarStore.setState({ events: [serverOccurrence({ participants: undefined })] }); + // 1.0 fills the defaults into `alerts` under the default alerts' ids. + mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'base' + ? [{ id: 'base', useDefaultAlerts: true, alerts: { 'default-1': alert15 } } as unknown as CalendarEvent] + : [])); + + await useCalendarStore.getState().updateEvent('s1', edit); + + expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([ + ['base', { useDefaultAlerts: false, alerts: { 'reminder-1': alert15 } }, false], + ['s1', expect.objectContaining({ alerts: { 'reminder-1': alert30 } }), undefined], + ]); + expect(mockUpdate.mock.calls[1][1]).not.toHaveProperty('useDefaultAlerts'); + }); + + it('turns the defaults off on the base event of an occurrence expanded on the device (Stalwart 0.16.23)', async () => { + useCalendarStore.setState({ events: [deviceOccurrence({ participants: undefined, organizerCalendarAddress: undefined })] }); + mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'base' + ? [{ id: 'base', useDefaultAlerts: true, alerts: {} } as unknown as CalendarEvent] + : [])); + + await useCalendarStore.getState().updateEvent(`base:${RID}`, edit); + + expect(mockUpdate.mock.calls[0]).toEqual(['base', { useDefaultAlerts: false, alerts: null }, false, undefined]); + expect(mockUpdate.mock.calls[1][0]).toBe('base'); + expect(mockUpdate.mock.calls[1][1]).toHaveProperty('recurrenceOverrides'); + }); + + it('leaves a series without default alerts alone', async () => { + useCalendarStore.setState({ events: [serverOccurrence({ participants: undefined })] }); + mockGetEvents.mockResolvedValue([{ id: 'base', useDefaultAlerts: false, alerts: {} } as unknown as CalendarEvent]); + + await useCalendarStore.getState().updateEvent('s1', edit); + + expect(mockUpdate.mock.calls.map(([id]) => id)).toEqual(['s1']); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 505e5d26..c1c88ad0 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -4,7 +4,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { createPersistStorage } from './persist-storage'; import { t } from './locale-store'; import type { Calendar, CalendarEvent, CalendarRights, Participant, StateChange } from '../api/types'; -import { normalizeAllDayDuration } from '../lib/calendar-utils'; +import { getEventStartDate, normalizeAllDayDuration } from '../lib/calendar-utils'; +import { withOwnAlertIds } from '../lib/calendar-alerts'; +import { hasAnsweredAttendees } from '../lib/calendar-participants'; import { type CalendarUpdates, type EventIdentityFields, @@ -42,6 +44,8 @@ import { buildFallbackOverridePatch, buildOccurrencePatch, buildOccurrenceRsvpPatch, + buildOverrideInTwoSteps, + createsOverride, isBrowserExpandedOccurrence, isOverrideSeriesKeyRejection, isServerRecurrenceInstance, @@ -513,6 +517,65 @@ async function updateOccurrence( await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId); } +/** + * The server occurrence of `instance`'s series at the same recurrence id, read + * again: a new override can give an occurrence another synthetic id. + */ +async function findServerOccurrence( + instance: CalendarEvent, + accountId: string | undefined, +): Promise { + if (!instance.baseEventId || !instance.recurrenceId) return null; + const at = getEventStartDate(instance).getTime(); + if (isNaN(at)) return null; + const ids = await queryExpandedEvents(new Date(at - DAY_MS).toISOString(), new Date(at + DAY_MS).toISOString(), accountId); + if (!ids?.length) return null; + const found = await fetchEvents(ids, accountId, { expanded: true }); + return found.find((e) => e.baseEventId === instance.baseEventId && e.recurrenceId === instance.recurrenceId) ?? null; +} + +/** + * A first change to one server occurrence that keeps its attendees' answers. + * Stalwart delivers a new override only to attendees who have not answered + * (organizer.rs, 0.16 and 1.0 alike), and one who has would never hear of the + * change; an update of an existing override reaches everyone. So the override + * is written first as the occurrence stands, without scheduling, and the + * change then goes out as an update of it. The occurrence is looked up again + * in between: the new override can give it another synthetic id. + */ +async function updateOccurrenceInTwoSteps( + instance: CalendarEvent, + syntheticId: string, + patch: Partial, + sendSchedulingMessages: boolean | undefined, + accountId: string | undefined, +): Promise { + await updateOccurrence(instance, syntheticId, (options) => withNewOverrideDetails(instance, {}, options), false, accountId); + const current = await findServerOccurrence(instance, accountId).catch(() => null); + await updateOccurrence(current ?? instance, current?.id ?? syntheticId, () => patch, sendSchedulingMessages, accountId); +} + +/** + * Turn the calendar's default alerts off for a whole series, keeping the + * alerts it shows as its own - before one occurrence's alerts change in a way + * that turns them off (reminderFieldsToSave). While a series uses them, + * Stalwart 1.0 shows the calendar's defaults in place of any occurrence's own + * alerts and refuses useDefaultAlerts on a single occurrence ("This property + * cannot be modified on a single occurrence."): the change was stored and + * never shown. 0.16 stores and shows it either way; its series keeps the + * same alerts. + */ +async function stopSeriesDefaultAlerts(baseId: string, accountId: string | undefined): Promise { + const [base] = await fetchEvents([baseId], accountId); + if (!base?.useDefaultAlerts) return; + const shown = base.alerts ?? {}; + await apiUpdateEvent(baseId, { + useDefaultAlerts: false, + // Under ids of their own: 1.0 drops alerts saved under a default alert's id. + alerts: Object.keys(shown).length > 0 ? withOwnAlertIds(Object.values(shown), Object.keys(shown)) : null, + }, false, accountId); +} + /** Destroy one server-expanded occurrence; falls back to excluding it on the base event. */ async function destroyOccurrence( instance: CalendarEvent, @@ -550,6 +613,23 @@ async function updateBrowserOccurrence( await updateBaseEvent(target.realId, patch, sendSchedulingMessages, target.accountId); } +/** + * A first change to one occurrence the device expanded that keeps its + * attendees' answers: the override on the base event is written first as + * the occurrence stands, without scheduling, then updated with the change + * (see updateOccurrenceInTwoSteps). + */ +async function updateBrowserOccurrenceInTwoSteps( + target: MutationTarget, + updates: Partial, + sendSchedulingMessages: boolean | undefined, +): Promise { + const steps = buildOverrideInTwoSteps(target.storeEvent!, updates); + if (!steps) throw new Error('Cannot resolve the occurrence to override'); + await updateBaseEvent(target.realId, steps.quiet, false, target.accountId); + await updateBaseEvent(target.realId, steps.change, sendSchedulingMessages, target.accountId); +} + /** Delete one occurrence the device expanded by excluding it on its base event. */ async function destroyBrowserOccurrence( target: MutationTarget, @@ -852,16 +932,36 @@ export const useCalendarStore = create()( : undefined); const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) || hasServerOccurrencesOf(get().events, realId, accountId); + // An occurrence's alerts that turn the calendar's defaults off. + const stopsDefaultAlerts = patch.useDefaultAlerts === false && 'alerts' in patch; + // A first change to one occurrence that goes out to attendees of whom + // some answered and keep their answers (see updateOccurrenceInTwoSteps). + const twoSteps = !!storeEvent && schedule === true && createsOverride(storeEvent) + && hasAnsweredAttendees('participants' in patch + ? patch.participants as CalendarEvent['participants'] + : storeEvent.participants); if (target.isOccurrence && storeEvent) { - await updateOccurrence( - storeEvent, - realId, - (options) => withNewOverrideDetails(storeEvent, patch as Partial, options), - schedule, - accountId, - ); + if (stopsDefaultAlerts && storeEvent.baseEventId) { + await stopSeriesDefaultAlerts(storeEvent.baseEventId, accountId); + } + if (twoSteps) { + await updateOccurrenceInTwoSteps(storeEvent, realId, patch as Partial, schedule, accountId); + } else { + await updateOccurrence( + storeEvent, + realId, + (options) => withNewOverrideDetails(storeEvent, patch as Partial, options), + schedule, + accountId, + ); + } } else if (target.isBrowserOccurrence) { - await updateBrowserOccurrence(target, patch as Partial, schedule); + if (stopsDefaultAlerts) await stopSeriesDefaultAlerts(realId, accountId); + if (twoSteps) { + await updateBrowserOccurrenceInTwoSteps(target, patch as Partial, schedule); + } else { + await updateBrowserOccurrence(target, patch as Partial, schedule); + } } else { await updateBaseEvent(realId, patch, schedule, accountId); }