diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
index 0bcebcf..c3ad959 100644
--- a/backend/app/api/settings.py
+++ b/backend/app/api/settings.py
@@ -19,6 +19,14 @@ DbSession = Annotated[AsyncSession, Depends(get_session)]
_ALLOWED_ORDER = {"asc", "desc"}
_ALLOWED_TIME_FORMAT = {"12", "24"}
+_ALLOWED_REMINDER_METHOD = {"alert", "email"}
+_ALLOWED_INVITE_POLICY = {"add_update", "update_only", "none"}
+_ALLOWED_INVITE_FROM = {"contacts", "anyone"}
+
+
+class ReminderPref(BaseModel):
+ method: str = "alert" # alert = in-app notification, email = email me
+ minutes: int = 0 # minutes before the event start (0 = at time of event)
class SettingsOut(BaseModel):
@@ -27,8 +35,12 @@ class SettingsOut(BaseModel):
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
jitsi_server: str = "" # blank => https://meet.jit.si/
default_calendar: str = "" # calendar id new events default to (blank => first)
- default_reminders_timed: list[int] = [] # minutes-before defaults for timed events
- default_reminders_allday: list[int] = [] # minutes-before defaults for all-day events
+ default_reminders_timed: list[ReminderPref] = [] # defaults for timed events
+ default_reminders_allday: list[ReminderPref] = [] # defaults for all-day events
+ invitation_policy: str = "add_update" # how emailed events update the calendar
+ invitation_from: str = "contacts" # whose emailed events auto-add (contacts|anyone)
+ invitation_calendar: str = "" # calendar id emailed events land in (blank => default)
+ invitation_file: bool = False # also mark the invite message read + filed
allowed_image_senders: list[str] = []
@@ -38,8 +50,12 @@ class SettingsPatch(BaseModel):
time_format: str | None = None
jitsi_server: str | None = None
default_calendar: str | None = None
- default_reminders_timed: list[int] | None = None
- default_reminders_allday: list[int] | None = None
+ default_reminders_timed: list[ReminderPref] | None = None
+ default_reminders_allday: list[ReminderPref] | None = None
+ invitation_policy: str | None = None
+ invitation_from: str | None = None
+ invitation_calendar: str | None = None
+ invitation_file: bool | None = None
class QuotaOut(BaseModel):
@@ -62,8 +78,24 @@ def add_allowed_image_sender(user: User, email: str) -> None:
user.settings = {**user.settings, "allowed_image_senders": current}
-def _int_list(value: object) -> list[int]:
- return [int(x) for x in value] if isinstance(value, list) else []
+def _reminder_list(value: object) -> list[ReminderPref]:
+ """Normalize stored reminders. Accepts the legacy ``list[int]`` (minutes only,
+ method defaults to alert) as well as the ``{method, minutes}`` object form."""
+ if not isinstance(value, list):
+ return []
+ out: list[ReminderPref] = []
+ for x in value:
+ if isinstance(x, dict):
+ method = str(x.get("method", "alert"))
+ out.append(
+ ReminderPref(
+ method=method if method in _ALLOWED_REMINDER_METHOD else "alert",
+ minutes=int(x.get("minutes", 0)),
+ )
+ )
+ else:
+ out.append(ReminderPref(method="alert", minutes=int(x)))
+ return out
def _settings_out(user: User) -> SettingsOut:
@@ -74,8 +106,12 @@ def _settings_out(user: User) -> SettingsOut:
time_format=str(s.get("time_format", "24")),
jitsi_server=str(s.get("jitsi_server", "")),
default_calendar=str(s.get("default_calendar", "")),
- default_reminders_timed=_int_list(s.get("default_reminders_timed")),
- default_reminders_allday=_int_list(s.get("default_reminders_allday")),
+ default_reminders_timed=_reminder_list(s.get("default_reminders_timed")),
+ default_reminders_allday=_reminder_list(s.get("default_reminders_allday")),
+ invitation_policy=str(s.get("invitation_policy", "add_update")),
+ invitation_from=str(s.get("invitation_from", "contacts")),
+ invitation_calendar=str(s.get("invitation_calendar", "")),
+ invitation_file=bool(s.get("invitation_file", False)),
allowed_image_senders=sorted(allowed_image_senders(user)),
)
@@ -108,9 +144,21 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
if body.default_calendar is not None:
updates["default_calendar"] = body.default_calendar
if body.default_reminders_timed is not None:
- updates["default_reminders_timed"] = [int(x) for x in body.default_reminders_timed]
+ updates["default_reminders_timed"] = [
+ r.model_dump() for r in body.default_reminders_timed
+ ]
if body.default_reminders_allday is not None:
- updates["default_reminders_allday"] = [int(x) for x in body.default_reminders_allday]
+ updates["default_reminders_allday"] = [
+ r.model_dump() for r in body.default_reminders_allday
+ ]
+ if body.invitation_policy is not None and body.invitation_policy in _ALLOWED_INVITE_POLICY:
+ updates["invitation_policy"] = body.invitation_policy
+ if body.invitation_from is not None and body.invitation_from in _ALLOWED_INVITE_FROM:
+ updates["invitation_from"] = body.invitation_from
+ if body.invitation_calendar is not None:
+ updates["invitation_calendar"] = body.invitation_calendar
+ if body.invitation_file is not None:
+ updates["invitation_file"] = body.invitation_file
user.settings = updates
await db.commit()
return _settings_out(user)
diff --git a/backend/app/plugins/sdk.py b/backend/app/plugins/sdk.py
index 3185f68..b4692b4 100644
--- a/backend/app/plugins/sdk.py
+++ b/backend/app/plugins/sdk.py
@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.ssrf import guarded_get
from app.config import Settings, get_settings
+from app.db import session_scope
from app.events import get_event_bus
from app.identity.deps import current_user, require_csrf
from app.indexer import blobstore
@@ -95,6 +96,16 @@ class PluginSdk:
across users (see :mod:`app.plugins.db`)."""
return plugin_session(user_id)
+ def public_session(self) -> AbstractAsyncContextManager[AsyncSession]:
+ """A privileged, NON-RLS session for PUBLIC, unauthenticated endpoints that
+ resolve their own opaque token and therefore must look up rows before any
+ user is known (e.g. a public ``.ics`` feed keyed by an unguessable slug).
+
+ There is no row-level safety net here: EVERY query MUST filter explicitly by
+ the token (or an id derived from it). Never use this for authenticated
+ handlers — use :meth:`session` so RLS scopes queries to the user."""
+ return session_scope()
+
def kv(self, user_id: uuid.UUID) -> PluginKvStore:
"""The namespaced KV store for this plugin and ``user_id`` (SPEC §11)."""
return PluginKvStore(self.name, user_id)
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index fb09e22..f05e8d0 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -38,6 +38,7 @@ export const en = {
"auth.register": "Register",
"nav.mail": "Mail",
+ "nav.back": "Back",
"nav.settings": "Settings",
"nav.compose": "Compose",
"nav.collapseSidebar": "Collapse sidebar",
@@ -254,6 +255,23 @@ export const en = {
"settings.subscribed": "Subscribed",
"settings.comingSoon": "Coming soon",
"settings.subscribeError": "Couldn't subscribe to that URL.",
+ "settings.removeSubscription": "Remove subscription",
+ "settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?",
+ "settings.deleteCalendar": "Delete calendar",
+ "settings.thisCalendar": "this calendar",
+ "settings.deleteCalendarConfirm": "Delete “{name}” and all of its events? This can't be undone.",
+ "settings.showAlert": "Show an alert",
+ "settings.emailMe": "Email me",
+ "settings.invitations": "Invitations",
+ "settings.inviteWhen": "When I receive events by email",
+ "settings.inviteAddUpdate": "Add new events to my calendar and update events that have changed",
+ "settings.inviteUpdateOnly": "Only update events that I have already added to my calendar",
+ "settings.inviteNone": "Do not automatically update my calendar",
+ "settings.addNewFrom": "Add new events from",
+ "settings.myContacts": "My contacts",
+ "settings.anyone": "Anyone",
+ "settings.addToCalendar": "Add to calendar",
+ "settings.alsoFile": "Also mark the message as read and file it",
"settings.integrations": "Integrations",
"settings.jitsi": "Jitsi Meet",
"settings.jitsiHint":
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 37976c0..3a51e9a 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -178,6 +178,10 @@ export const api = {
| "default_calendar"
| "default_reminders_timed"
| "default_reminders_allday"
+ | "invitation_policy"
+ | "invitation_from"
+ | "invitation_calendar"
+ | "invitation_file"
>
>,
) =>
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 1555f83..56ab4c4 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -159,14 +159,23 @@ export interface CalendarOut {
subscription_url: string | null;
}
+export interface ReminderPref {
+ method: "alert" | "email";
+ minutes: number;
+}
+
export interface UserSettings {
thread_order: "asc" | "desc";
tz: string;
time_format: "12" | "24";
jitsi_server: string;
default_calendar: string;
- default_reminders_timed: number[];
- default_reminders_allday: number[];
+ default_reminders_timed: ReminderPref[];
+ default_reminders_allday: ReminderPref[];
+ invitation_policy: "add_update" | "update_only" | "none";
+ invitation_from: "contacts" | "anyone";
+ invitation_calendar: string;
+ invitation_file: boolean;
allowed_image_senders: string[];
}
diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx
index a86158e..c3d09d6 100644
--- a/frontend/src/routes/settings/Settings.tsx
+++ b/frontend/src/routes/settings/Settings.tsx
@@ -6,7 +6,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
-import type { CalendarOut } from "../../lib/types";
+import type { CalendarOut, ReminderPref } from "../../lib/types";
import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat";
@@ -18,13 +18,19 @@ type Section = (typeof SECTIONS)[number];
export function Settings() {
const t = useT();
const params = useParams();
+ const [searchParams] = useSearchParams();
+ // Where the back button returns to — set by whoever opened Settings (Mail or a
+ // plugin like Calendar via ?from=). Only accept internal paths; default to Mail.
+ const rawFrom = searchParams.get("from") ?? "";
+ const from = rawFrom.startsWith("/") ? rawFrom : "/mail";
+ const fromQuery = `?from=${encodeURIComponent(from)}`;
const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section;
return (
-
- ← {t("nav.mail")}
+
+ ← {t("nav.back")}
{t("settings.title")}
@@ -33,7 +39,7 @@ export function Settings() {
{SECTIONS.map((s) => (
+
+
+
+ );
+}
+
+// A compact colour picker showing circular swatches only (no text).
+function ColorSelect({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
+ const [open, setOpen] = useState(false);
+ const palette: [string, string][] = CAL_COLORS.some(([, hex]) => hex === value)
+ ? CAL_COLORS
+ : [["Custom", value], ...CAL_COLORS];
+ return (
+
+
+ {open && (
+ <>
+
setOpen(false)} />
+
+ {palette.map(([name, hex]) => (
+
+ >
+ )}
+
+ );
+}
+
+function ConfirmDialog({
+ title,
+ message,
+ confirmLabel,
+ onConfirm,
+ onCancel,
+}: {
+ title: string;
+ message: string;
+ confirmLabel: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ const t = useT();
+ return (
+
+
e.stopPropagation()}
+ >
+
{title}
+
{message}
+
+
+
+
+
+
+ );
+}
+
function SettingsToggle({
checked,
onChange,
@@ -699,9 +816,41 @@ const TZ_RAW: string[] = (() => {
return [];
}
})();
-const TZ_LIST = TZ_RAW.length
+const TZ_ZONES = TZ_RAW.length
? TZ_RAW
: ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"];
+
+// The GMT offset code (e.g. "GMT+2") for a zone, as shown next to its name.
+function tzGmt(zone: string): string {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: zone,
+ timeZoneName: "shortOffset",
+ }).formatToParts(new Date());
+ return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
+ } catch {
+ return "";
+ }
+}
+
+function tzLongName(zone: string): string {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: zone,
+ timeZoneName: "long",
+ }).formatToParts(new Date());
+ return parts.find((p) => p.type === "timeZoneName")?.value ?? zone;
+ } catch {
+ return zone;
+ }
+}
+
+// [zone, "Long Name (GMT+2)"] sorted by offset then label, computed once.
+const TZ_LIST: [string, string][] = TZ_ZONES.map((z): [string, string] => {
+ const gmt = tzGmt(z);
+ return [z, gmt ? `${tzLongName(z)} (${gmt})` : tzLongName(z)];
+}).sort((a, b) => a[1].localeCompare(b[1]));
+
const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880];
function reminderText(m: number): string {
@@ -772,6 +921,11 @@ function CalendarList({
queryClient.invalidateQueries({ queryKey: ["calendars"] });
},
});
+ const [removeSub, setRemoveSub] = useState
(null);
+ const removeCal = useMutation({
+ mutationFn: (id: string) => api.deleteCalendar(id),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["calendars"] }),
+ });
const subscriptions = calendars.filter((c) => c.subscription_url !== null);
const accounts: [string, string][] = [
["Google", "google"],
@@ -856,11 +1010,7 @@ function CalendarList({
@@ -888,6 +1038,19 @@ function CalendarList({
{t("settings.subscribeError")}
)}
+
+ {removeSub && (
+ setRemoveSub(null)}
+ onConfirm={() => {
+ removeCal.mutate(removeSub.id);
+ setRemoveSub(null);
+ }}
+ />
+ )}
);
}
@@ -896,7 +1059,9 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
const t = useT();
const queryClient = useQueryClient();
const [calId, setCalId] = useState(calendar?.id ?? null);
- const editing = calId !== null;
+ // Whether we opened an existing calendar to edit — fixed for the form's lifetime, so
+ // auto-creating on a Publish toggle doesn't flip the "Create calendar" heading.
+ const startedNew = calendar === null;
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(calendar?.color ?? CAL_COLORS[0][1]);
const [publishToken, setPublishToken] = useState(calendar?.publish_token ?? null);
@@ -916,6 +1081,7 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
onSuccess: done,
});
const del = useMutation({ mutationFn: () => api.deleteCalendar(calId!), onSuccess: done });
+ const [confirmDel, setConfirmDel] = useState(false);
// Turning on a Publish option auto-creates the calendar first (create mode) so a
// shareable .ics link can be shown immediately, then flips the public feed on/off.
@@ -957,43 +1123,16 @@ function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBa
);
- const colorOptions: [string, string][] = CAL_COLORS.some(([, hex]) => hex === color)
- ? CAL_COLORS
- : [["Custom", color], ...CAL_COLORS];
-
return (
-
-
- ›
-
- {editing ? t("settings.editCalendar") : t("settings.createCalendar")}
-
-
-
- {editing ? t("settings.editCalendar") : t("settings.createCalendar")}
+ {startedNew ? t("settings.createCalendar") : t("settings.editCalendar")}
{t("settings.color")}
-
-
-
-
+
+
+ {confirmDel && (
+
setConfirmDel(false)}
+ onConfirm={() => {
+ setConfirmDel(false);
+ del.mutate();
+ }}
+ />
+ )}
);
}
+const REM_SELECT_CLASS =
+ "w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm text-app-fg outline-none focus:border-app-accent";
+
function ReminderList({
label,
value,
onChange,
}: {
label: string;
- value: number[];
- onChange: (v: number[]) => void;
+ value: ReminderPref[];
+ onChange: (v: ReminderPref[]) => void;
}) {
const t = useT();
+ const update = (i: number, patch: Partial
) =>
+ onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
return (
{label}
-
- {value.map((m, i) => (
-
-
);
}
@@ -1120,6 +1292,15 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
const s = settings.data;
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+ // When no default is set, fall back to "Personal" (the bootstrapped calendar) so the
+ // dropdown shows Personal rather than a generic placeholder; else the first calendar.
+ const preferredDefault =
+ calendars.find((c) => c.name === "Personal") ?? calendars[0];
+ const defaultCalValue =
+ s?.default_calendar && calendars.some((c) => c.id === s.default_calendar)
+ ? s.default_calendar
+ : (preferredDefault?.id ?? "");
+
// Detect and persist the browser time zone on first visit (backend default is UTC).
useEffect(() => {
if (s && (!s.tz || s.tz === "UTC") && browserTz !== "UTC") patch.mutate({ tz: browserTz });
@@ -1137,9 +1318,9 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
value={s?.tz && s.tz !== "UTC" ? s.tz : browserTz}
onChange={(e) => patch.mutate({ tz: e.target.value })}
>
- {TZ_LIST.map((z) => (
-
- {z}
+ {TZ_LIST.map(([zone, tzLabel]) => (
+
+ {tzLabel}
))}
@@ -1153,12 +1334,9 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
{t("settings.defaultCalendar")}
+
+
+
{t("settings.invitations")}
+
+
+
+
+
+
+
+
+
+ {t("settings.alsoFile")}
+ patch.mutate({ invitation_file: v })}
+ />
+
+
+
);
}
diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py
index 4596b6d..c42e670 100644
--- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py
+++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py
@@ -8,9 +8,9 @@ is expanded at query time (see queries.py / recurrence.py). Read-only calendars
from __future__ import annotations
-import base64
import json
import logging
+import secrets
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -68,20 +68,10 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non
# --- calendars ---------------------------------------------------------------
-def _make_publish_token(user_id: uuid.UUID, calendar_id: uuid.UUID) -> str:
- """Vault-seal {user_id, calendar_id} so the public /public/{token} feed can open
- the right RLS session without exposing the ids or needing auth."""
- payload = json.dumps({"u": str(user_id), "c": str(calendar_id)}).encode()
- return base64.urlsafe_b64encode(_SDK.vault.encrypt(payload)).decode().rstrip("=")
-
-
-def _read_publish_token(token: str) -> tuple[uuid.UUID, uuid.UUID] | None:
- try:
- pad = "=" * (-len(token) % 4)
- data = json.loads(_SDK.vault.decrypt(base64.urlsafe_b64decode(token + pad)))
- return uuid.UUID(data["u"]), uuid.UUID(data["c"])
- except Exception:
- return None
+def _make_publish_token() -> str:
+ """A short, unguessable slug for the public /public/{token} .ics feed. Stored in
+ plg_calendar_calendars.publish_token; the public route resolves it directly."""
+ return secrets.token_urlsafe(12)
def _calendar_ics(name: object, rows: list[dict[str, object]], *, busy_only: bool) -> bytes:
@@ -199,7 +189,7 @@ async def patch_calendar(
{"id": str(calendar_id)},
)
).scalar_one_or_none()
- token = existing or _make_publish_token(user_id, calendar_id)
+ token = existing or _make_publish_token()
set_parts += ["publish_token = :ptok", "publish_busy = :pbusy"]
params["ptok"] = token
params["pbusy"] = publish == "busy"
@@ -700,23 +690,21 @@ async def delete_event(
@router.get("/public/{token}")
async def public_calendar(token: str) -> Response:
- parsed = _read_publish_token(token)
- if parsed is None:
- raise HTTPException(status_code=404, detail="not_found")
- user_id, cal_id = parsed
- async with _SDK.session(user_id) as s:
+ # Public + unauthenticated: resolve the opaque slug directly (privileged session,
+ # every query filtered explicitly by the token / its calendar id — no RLS).
+ async with _SDK.public_session() as s:
row = (
await s.execute(
text(
- "SELECT name, publish_token, publish_busy "
- "FROM plg_calendar_calendars WHERE id = :id"
+ "SELECT id, name, publish_busy FROM plg_calendar_calendars "
+ "WHERE publish_token = :tok"
),
- {"id": str(cal_id)},
+ {"tok": token},
)
).mappings().first()
- if row is None or row["publish_token"] is None:
+ if row is None:
raise HTTPException(status_code=404, detail="not_found")
- rows = await _calendar_event_rows(s, cal_id)
+ rows = await _calendar_event_rows(s, row["id"])
return Response(
content=_calendar_ics(row["name"], rows, busy_only=row["publish_busy"]),
media_type="text/calendar",
diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js
index f7482b6..32e8cbe 100644
--- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js
+++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js
@@ -1199,8 +1199,9 @@ export default function mount(el, ctx) {
state.menuOpen = false;
if (a.to) ctx.navigate(a.to); else render();
});
- act("settings", () => ctx.navigate("/settings"));
- act("add-calendar", () => ctx.navigate("/settings/calendars?create=1"));
+ // Pass ?from=/p/calendar so Settings' back button returns here, not to Mail.
+ act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcalendar"));
+ act("add-calendar", () => ctx.navigate("/settings/calendars?from=%2Fp%2Fcalendar&create=1"));
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
act("new", () => openForm(null));
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
@@ -1373,8 +1374,11 @@ export default function mount(el, ctx) {
HOUR12 = s && s.time_format === "12"; // 24h is the default
state.jitsi = (s && s.jitsi_server) || "";
state.defaultCalendar = (s && s.default_calendar) || "";
- state.defaultRemTimed = (s && s.default_reminders_timed) || [];
- state.defaultRemAllday = (s && s.default_reminders_allday) || [];
+ // Reminders are stored as {method, minutes}; event reminders only use minutes.
+ const remMins = (arr) =>
+ (arr || []).map((x) => (typeof x === "number" ? x : (x && x.minutes) || 0));
+ state.defaultRemTimed = remMins(s && s.default_reminders_timed);
+ state.defaultRemAllday = remMins(s && s.default_reminders_allday);
} catch { /* keep defaults (24h, public Jitsi) */ }
try {
const q = await api.fetch("/settings/quota");
diff --git a/screenshots/calendar-set-4.png b/screenshots/calendar-set-4.png
new file mode 100644
index 0000000..23b41e2
Binary files /dev/null and b/screenshots/calendar-set-4.png differ
diff --git a/screenshots/contacts-1.png b/screenshots/contacts-1.png
new file mode 100644
index 0000000..0ce1233
Binary files /dev/null and b/screenshots/contacts-1.png differ