@rapidmx/web-client 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/apps/admin/domains/[uid].tsx +73 -2
  2. package/apps/admin/domains/index.tsx +2 -1
  3. package/apps/admin/domains/new/index.tsx +28 -3
  4. package/apps/shared/calendar/calendarReminders.ts +76 -0
  5. package/apps/shared/calendar/useCalendarReminders.ts +91 -0
  6. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +8 -0
  7. package/apps/shared/components/calendar/EventModal.tsx +236 -11
  8. package/apps/shared/components/layout/AppShell.tsx +3 -0
  9. package/apps/shared/components/mail/compose/ComposeWindow.tsx +37 -8
  10. package/apps/shared/notifications/NotificationCenter.tsx +2 -0
  11. package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
  12. package/apps/shared/notifications/store.ts +3 -1
  13. package/apps/shared/search/localIndexRpcClient.ts +33 -2
  14. package/apps/shared/search/searchTier2.ts +34 -16
  15. package/apps/www/index.tsx +49 -8
  16. package/dist/apps/admin/domains/[uid].js +38 -2
  17. package/dist/apps/admin/domains/index.js +1 -1
  18. package/dist/apps/admin/domains/new/index.js +13 -4
  19. package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
  20. package/dist/apps/shared/calendar/calendarReminders.js +55 -0
  21. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
  22. package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
  23. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +2 -2
  24. package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
  25. package/dist/apps/shared/components/calendar/EventModal.js +186 -12
  26. package/dist/apps/shared/components/layout/AppShell.js +3 -0
  27. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +36 -8
  28. package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
  29. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
  30. package/dist/apps/shared/notifications/store.d.ts +1 -1
  31. package/dist/apps/shared/notifications/store.js +2 -0
  32. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -0
  33. package/dist/apps/shared/search/localIndexRpcClient.js +28 -2
  34. package/dist/apps/shared/search/searchTier2.d.ts +7 -1
  35. package/dist/apps/shared/search/searchTier2.js +34 -17
  36. package/dist/apps/www/index.js +37 -8
  37. package/package.json +2 -2
@@ -4,13 +4,17 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import { deleteDomain, Domain } from "@rapidmx/react-shared/admin/domainsApi.js";
7
+ import { deleteDomain, Domain, updateDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
8
  import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
9
  import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.js";
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
13
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
13
14
 
15
+ const INPUT_CLASS =
16
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
+
14
18
  export default function DomainDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
15
19
  return (
16
20
  <AdminShell {...props} active="domains">
@@ -24,6 +28,15 @@ function DomainDetailContent({ uid }: { uid: string }) {
24
28
  const [confirmingDelete, setConfirmingDelete] = useState(false);
25
29
  const [deleting, setDeleting] = useState(false);
26
30
  const [deleteError, setDeleteError] = useState<string | null>(null);
31
+ // Not initialized from `domain.aliasOf` until it first loads (`null` means "not yet") - once set, kept as
32
+ // the user's own edit rather than fighting later unrelated reloads (e.g. after "Verify now").
33
+ const [aliasInput, setAliasInput] = useState<string | null>(null);
34
+ const [savingAlias, setSavingAlias] = useState(false);
35
+ const [aliasError, setAliasError] = useState<string | null>(null);
36
+ const [aliasSaved, setAliasSaved] = useState(false);
37
+ // Bumped after a successful aliasOf save to force `DomainDnsSetup` (which owns its own `domain` state,
38
+ // fetched only on mount/uid change) to reload and reflect the new value in its own status panel.
39
+ const [refreshCount, setRefreshCount] = useState(0);
27
40
 
28
41
  function closeDeleteModal() {
29
42
  setConfirmingDelete(false);
@@ -42,6 +55,28 @@ function DomainDetailContent({ uid }: { uid: string }) {
42
55
  }
43
56
  }
44
57
 
58
+ function handleLoaded(d: Domain) {
59
+ setDomain(d);
60
+ setAliasInput((current) => (current === null ? d?.aliasOf ?? "" : current));
61
+ }
62
+
63
+ async function handleSaveAlias() {
64
+ setSavingAlias(true);
65
+ setAliasError(null);
66
+ setAliasSaved(false);
67
+ try {
68
+ const updated = await updateDomain({ uid: domain!.uid, version: domain!.version, aliasOf: aliasInput!.trim() || undefined });
69
+ setDomain(updated);
70
+ setAliasInput(updated.aliasOf ?? "");
71
+ setAliasSaved(true);
72
+ setRefreshCount((c) => c + 1);
73
+ } catch (err) {
74
+ setAliasError(err instanceof ApiRequestError ? err.message : "Could not update this domain.");
75
+ } finally {
76
+ setSavingAlias(false);
77
+ }
78
+ }
79
+
45
80
  return (
46
81
  <div className="max-w-3xl flex flex-col gap-5">
47
82
  {domain && (
@@ -63,7 +98,43 @@ function DomainDetailContent({ uid }: { uid: string }) {
63
98
  </div>
64
99
  )}
65
100
 
66
- <DomainDnsSetup uid={uid} onLoaded={setDomain} />
101
+ <DomainDnsSetup key={refreshCount} uid={uid} onLoaded={handleLoaded} />
102
+
103
+ {domain && (
104
+ <div className="bg-surface border border-border rounded-md p-6">
105
+ <h2 className="text-sm font-bold uppercase tracking-wide mb-3">Alias</h2>
106
+ <p className="text-sm text-text-muted mb-3">
107
+ Make this domain a pure alias of another one - it will still receive (and may send as) mail for the
108
+ same addresses, but have no mailboxes of its own. Leave blank for a regular domain.
109
+ </p>
110
+ {aliasError && <Alert>{aliasError}</Alert>}
111
+ <FormField label="Alias of" htmlFor="aliasOf">
112
+ <input
113
+ id="aliasOf"
114
+ type="text"
115
+ className={INPUT_CLASS}
116
+ value={aliasInput ?? ""}
117
+ onChange={(e) => {
118
+ setAliasInput(e.target.value);
119
+ setAliasSaved(false);
120
+ }}
121
+ placeholder="e.g. powerlevel.gg"
122
+ />
123
+ </FormField>
124
+ <div className="flex items-center gap-3">
125
+ <Button
126
+ type="button"
127
+ className="!w-auto"
128
+ loading={savingAlias}
129
+ disabled={savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? "")}
130
+ onClick={handleSaveAlias}
131
+ >
132
+ Save
133
+ </Button>
134
+ {aliasSaved && <span className="text-sm text-text-muted">Saved.</span>}
135
+ </div>
136
+ </div>
137
+ )}
67
138
 
68
139
  {domain && (
69
140
  <Modal open={confirmingDelete} onClose={closeDeleteModal} title="Delete domain">
@@ -58,7 +58,7 @@ function DomainsListContent() {
58
58
  <table className="w-full text-sm border-collapse">
59
59
  <thead>
60
60
  <tr>
61
- {["Name", "Enabled", "Verified", ""].map((h) => (
61
+ {["Name", "Enabled", "Verified", "Alias of", ""].map((h) => (
62
62
  <th
63
63
  key={h}
64
64
  className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
@@ -84,6 +84,7 @@ function DomainsListContent() {
84
84
  </span>
85
85
  )}
86
86
  </td>
87
+ <td className="py-2.5 px-2.5 border-b border-border text-text-muted">{domain.aliasOf || "—"}</td>
87
88
  <td className="py-2.5 px-2.5 border-b border-border text-right">
88
89
  <a
89
90
  href={`/admin/domains/${encodeURIComponent(domain.uid)}`}
@@ -2,9 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useState } from "react";
5
+ import React, { FormEvent, useEffect, useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import { createDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
7
+ import { createDomain, Domain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
8
8
  import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -23,9 +23,19 @@ export default function NewDomainPage(props: Omit<AdminShellProps, "active">) {
23
23
 
24
24
  function NewDomainForm() {
25
25
  const [name, setName] = useState("");
26
+ const [aliasOf, setAliasOf] = useState("");
27
+ // Only an existing, non-alias domain can be aliased (no chains) - see `Domain.aliasOf`'s own doc comment
28
+ // on the `@rapidmx/restapi` side.
29
+ const [primaryDomains, setPrimaryDomains] = useState<Domain[]>([]);
26
30
  const [error, setError] = useState<string | null>(null);
27
31
  const [saving, setSaving] = useState(false);
28
32
 
33
+ useEffect(() => {
34
+ listDomains({ limit: 200 })
35
+ .then((domains) => setPrimaryDomains(domains.filter((d) => !d.aliasOf)))
36
+ .catch(() => setPrimaryDomains([]));
37
+ }, []);
38
+
29
39
  async function handleSubmit(e: FormEvent) {
30
40
  e.preventDefault();
31
41
  setError(null);
@@ -37,7 +47,7 @@ function NewDomainForm() {
37
47
 
38
48
  setSaving(true);
39
49
  try {
40
- const domain = await createDomain({ name: name.trim() });
50
+ const domain = await createDomain({ name: name.trim(), aliasOf: aliasOf || undefined });
41
51
  window.location.href = `/admin/domains/${encodeURIComponent(domain.uid)}`;
42
52
  } catch (err) {
43
53
  setError(err instanceof ApiRequestError ? err.message : "Could not create the domain.");
@@ -68,6 +78,21 @@ function NewDomainForm() {
68
78
  />
69
79
  </FormField>
70
80
 
81
+ <FormField label="Alias of" htmlFor="aliasOf">
82
+ <select id="aliasOf" className={INPUT_CLASS} value={aliasOf} onChange={(e) => setAliasOf(e.target.value)}>
83
+ <option value="">None - a regular domain with mailboxes of its own</option>
84
+ {primaryDomains.map((domain) => (
85
+ <option key={domain.uid} value={domain.name}>
86
+ {domain.name}
87
+ </option>
88
+ ))}
89
+ </select>
90
+ <p className="text-xs text-text-muted mt-1.5">
91
+ A pure alias receives (and may send as) mail for the same addresses as the domain it aliases, but
92
+ has no mailboxes of its own - it still needs its own DNS setup below.
93
+ </p>
94
+ </FormField>
95
+
71
96
  <div className="flex gap-3 mt-2">
72
97
  <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
73
98
  Create domain
@@ -0,0 +1,76 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
6
+ * arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
7
+
8
+ import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
9
+
10
+ /** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
11
+ export const SNOOZE_MS = 5 * 60_000;
12
+
13
+ /** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
14
+ export interface CalendarReminderNotice {
15
+ eventUid: string;
16
+ title: string;
17
+ /** The due occurrence's own start (an ISO string) - the series' `startDate` for a non-recurring event, one occurrence's for a recurring one. */
18
+ startDate: string;
19
+ /** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
20
+ * see `joinMeetingUrl()` for when it's shown as a join link. */
21
+ location?: string | null;
22
+ }
23
+
24
+ function isReminderNotice(data: unknown): data is CalendarReminderNotice {
25
+ const notice = data as Partial<CalendarReminderNotice> | null;
26
+ return (
27
+ !!notice &&
28
+ typeof notice.eventUid === "string" &&
29
+ typeof notice.title === "string" &&
30
+ typeof notice.startDate === "string" &&
31
+ (notice.location === undefined || notice.location === null || typeof notice.location === "string")
32
+ );
33
+ }
34
+
35
+ /** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
36
+ * else (a room name, an address, free-text instructions are all real, common `location` values this must not
37
+ * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
38
+ export function joinMeetingUrl(location: string | null | undefined): string | undefined {
39
+ const trimmed = location?.trim();
40
+ if (!trimmed) {
41
+ return undefined;
42
+ }
43
+ let url: URL;
44
+ try {
45
+ url = new URL(trimmed);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
50
+ }
51
+
52
+ /** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
53
+ export function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined {
54
+ return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
55
+ }
56
+
57
+ /** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
58
+ * reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
59
+ export function snoozeDelayMs(startDate: string, now: number = Date.now()): number {
60
+ return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
61
+ }
62
+
63
+ /** The pop-up's message line: when the meeting starts, relative to `now`. */
64
+ export function reminderMessage(startDate: string, now: number = Date.now()): string {
65
+ const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
66
+ const diffMinutes = Math.round((new Date(startDate).getTime() - now) / 60_000);
67
+ if (diffMinutes <= 0) {
68
+ return `Starting now - ${time}`;
69
+ }
70
+ return `Starting in ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} - ${time}`;
71
+ }
72
+
73
+ /** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
74
+ export function reminderNotificationId(eventUid: string, startDate: string): string {
75
+ return `calendar-reminder:${eventUid}:${startDate}`;
76
+ }
@@ -0,0 +1,91 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect } from "react";
6
+ import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
7
+ import { notify } from "../notifications/store.js";
8
+ import { CalendarReminderNotice, calendarReminderOf, joinMeetingUrl, reminderMessage, reminderNotificationId, snoozeDelayMs } from "./calendarReminders.js";
9
+
10
+ /** Where a reminder's title leads. There is no per-event deep link yet (the calendar opens an event by local state, not a URL - see
11
+ * `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
12
+ export const CALENDAR_HREF = "/calendar";
13
+
14
+ export interface UseCalendarRemindersOptions {
15
+ /** Nothing is shown without a signed-in user. */
16
+ userUid?: string;
17
+ /** Runs only while true - the persistent app frame turns it on, like `useMailConnection`'s identical option. */
18
+ enabled: boolean;
19
+ }
20
+
21
+ /**
22
+ * Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
23
+ * when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
24
+ * (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
25
+ * event is published to the mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its
26
+ * own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
27
+ * Mail, Calendar, Contacts, Settings.
28
+ *
29
+ * Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
30
+ * closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
31
+ * sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
32
+ * reminder itself (the push event is never replayed), it does not survive the tab closing.
33
+ */
34
+ export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): void {
35
+ useEffect(() => {
36
+ if (!enabled || !userUid) {
37
+ return;
38
+ }
39
+ const timers = new Set<ReturnType<typeof setTimeout>>();
40
+
41
+ function show(notice: CalendarReminderNotice): void {
42
+ const joinUrl = joinMeetingUrl(notice.location);
43
+ notify({
44
+ id: reminderNotificationId(notice.eventUid, notice.startDate),
45
+ kind: "calendar",
46
+ title: notice.title,
47
+ message: reminderMessage(notice.startDate),
48
+ href: CALENDAR_HREF,
49
+ sticky: true,
50
+ actions: [
51
+ ...(joinUrl
52
+ ? [
53
+ {
54
+ label: "Join Meeting",
55
+ onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
56
+ // Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
57
+ keepOpen: true,
58
+ },
59
+ ]
60
+ : []),
61
+ { label: "Dismiss" },
62
+ {
63
+ label: "Snooze",
64
+ onClick: () => {
65
+ const timer = setTimeout(() => {
66
+ timers.delete(timer);
67
+ show(notice);
68
+ }, snoozeDelayMs(notice.startDate));
69
+ timers.add(timer);
70
+ },
71
+ },
72
+ ],
73
+ });
74
+ }
75
+
76
+ const offEvent = getPushClient().onEvent((event) => {
77
+ const notice = calendarReminderOf(event);
78
+ if (notice) {
79
+ show(notice);
80
+ }
81
+ });
82
+
83
+ return () => {
84
+ offEvent();
85
+ for (const timer of timers) {
86
+ clearTimeout(timer);
87
+ }
88
+ timers.clear();
89
+ };
90
+ }, [enabled, userUid]);
91
+ }
@@ -146,6 +146,14 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
146
146
  </dd>
147
147
  <dt className="text-text-muted">Last checked</dt>
148
148
  <dd>{domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never"}</dd>
149
+ {domain.aliasOf && (
150
+ <>
151
+ <dt className="text-text-muted">Alias of</dt>
152
+ <dd>
153
+ {domain.aliasOf} <span className="text-xs text-text-muted">(no mailboxes of its own)</span>
154
+ </dd>
155
+ </>
156
+ )}
149
157
  </dl>
150
158
 
151
159
  {!domain.verified && (