@rapidmx/web-client 0.32.0 → 0.34.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.
- package/apps/shared/calendar/calendarReminders.ts +16 -7
- package/apps/shared/calendar/useCalendarReminders.ts +66 -61
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +13 -2
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +24 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +3 -3
- package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
- package/apps/shared/components/calendar/EventDetails.tsx +10 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +10 -0
- package/apps/shared/components/calendar/MonthView.tsx +10 -4
- package/apps/shared/components/contacts/ContactCard.tsx +4 -2
- package/apps/shared/components/contacts/ContactsSidebar.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +6 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/layout/UserMenu.tsx +29 -1
- package/apps/shared/components/mail/ConversationThreadPane.tsx +34 -2
- package/apps/shared/components/mail/compose/ComposeContext.tsx +19 -1
- package/apps/shared/components/mail/compose/draftResume.ts +54 -0
- package/apps/shared/notifications/preferences.ts +27 -0
- package/apps/shared/notifications/sounds.ts +135 -0
- package/apps/shared/notifications/store.ts +9 -0
- package/apps/www/calendar/index.tsx +35 -5
- package/apps/www/contacts/index.tsx +75 -3
- package/apps/www/tasks/index.tsx +1 -1
- package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
- package/dist/apps/shared/calendar/calendarReminders.js +14 -6
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
- package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +3 -2
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +6 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -3
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +2 -1
- package/dist/apps/shared/components/calendar/MonthView.d.ts +1 -1
- package/dist/apps/shared/components/calendar/MonthView.js +4 -4
- package/dist/apps/shared/components/contacts/ContactCard.js +4 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +6 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +3 -3
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -1
- package/dist/apps/shared/components/layout/AppShell.js +4 -3
- package/dist/apps/shared/components/layout/UserMenu.js +9 -3
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +35 -1
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +17 -1
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +10 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +48 -0
- package/dist/apps/shared/notifications/preferences.d.ts +8 -0
- package/dist/apps/shared/notifications/preferences.js +27 -0
- package/dist/apps/shared/notifications/sounds.d.ts +15 -0
- package/dist/apps/shared/notifications/sounds.js +101 -0
- package/dist/apps/shared/notifications/store.js +11 -0
- package/dist/apps/www/calendar/index.js +34 -5
- package/dist/apps/www/contacts/index.js +63 -5
- package/dist/apps/www/tasks/index.js +1 -1
- package/dist/lib/calendar/recurrence.js +1 -1
- package/dist/lib/components/overlays/overlayStack.d.ts +6 -0
- package/dist/lib/components/overlays/overlayStack.js +10 -0
- package/dist/lib/components/pickers/MiniDatePicker.d.ts +1 -1
- package/dist/lib/components/pickers/MiniDatePicker.js +4 -4
- package/dist/lib/contacts/contactsApi.d.ts +12 -0
- package/dist/lib/contacts/contactsApi.js +8 -0
- package/lib/calendar/recurrence.ts +1 -1
- package/lib/components/overlays/overlayStack.ts +11 -0
- package/lib/components/pickers/MiniDatePicker.tsx +4 -4
- package/lib/contacts/contactsApi.ts +16 -0
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
HiOutlineArrowUturnLeft,
|
|
11
11
|
HiOutlineBell,
|
|
12
12
|
HiOutlineBellAlert,
|
|
13
|
+
HiOutlineSpeakerWave,
|
|
13
14
|
HiOutlineClock,
|
|
14
15
|
HiOutlineCog6Tooth,
|
|
15
16
|
HiOutlineCommandLine,
|
|
@@ -34,7 +35,12 @@ import {
|
|
|
34
35
|
requestDesktopPermission,
|
|
35
36
|
setDesktopOfferDismissed,
|
|
36
37
|
} from "../../mail/newMailNotifications.js";
|
|
37
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
getNotificationSoundsEnabled,
|
|
40
|
+
getNotificationsEnabled,
|
|
41
|
+
setNotificationSoundsEnabled,
|
|
42
|
+
setNotificationsEnabled,
|
|
43
|
+
} from "../../notifications/preferences.js";
|
|
38
44
|
import { dismissAll } from "../../notifications/store.js";
|
|
39
45
|
|
|
40
46
|
export interface UserMenuProps {
|
|
@@ -134,6 +140,7 @@ export default function UserMenu({
|
|
|
134
140
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
135
141
|
const [detectedAdmin, setDetectedAdmin] = useState(false);
|
|
136
142
|
const [popups, setPopups] = useState(true);
|
|
143
|
+
const [sounds, setSounds] = useState(true);
|
|
137
144
|
const [permission, setPermission] = useState<DesktopPermission>("unsupported");
|
|
138
145
|
const [profile, setProfile] = useState<Profile | undefined>(undefined);
|
|
139
146
|
// Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
|
|
@@ -230,10 +237,16 @@ export default function UserMenu({
|
|
|
230
237
|
useEffect(() => {
|
|
231
238
|
if (open && showNotificationSettings) {
|
|
232
239
|
setPopups(getNotificationsEnabled());
|
|
240
|
+
setSounds(getNotificationSoundsEnabled());
|
|
233
241
|
setPermission(desktopPermission());
|
|
234
242
|
}
|
|
235
243
|
}, [open, showNotificationSettings]);
|
|
236
244
|
|
|
245
|
+
function toggleSounds() {
|
|
246
|
+
setNotificationSoundsEnabled(!sounds);
|
|
247
|
+
setSounds(!sounds);
|
|
248
|
+
}
|
|
249
|
+
|
|
237
250
|
function togglePopups() {
|
|
238
251
|
setNotificationsEnabled(!popups);
|
|
239
252
|
setPopups(!popups);
|
|
@@ -386,6 +399,21 @@ export default function UserMenu({
|
|
|
386
399
|
<span className="text-xs font-semibold text-text-muted">{popups ? "On" : "Off"}</span>
|
|
387
400
|
</button>
|
|
388
401
|
)}
|
|
402
|
+
{showNotificationSettings && (
|
|
403
|
+
<button
|
|
404
|
+
role="menuitemcheckbox"
|
|
405
|
+
aria-checked={sounds}
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={toggleSounds}
|
|
408
|
+
className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
409
|
+
>
|
|
410
|
+
<span className="flex items-center gap-2">
|
|
411
|
+
<HiOutlineSpeakerWave size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
412
|
+
Notification sounds
|
|
413
|
+
</span>
|
|
414
|
+
<span className="text-xs font-semibold text-text-muted">{sounds ? "On" : "Off"}</span>
|
|
415
|
+
</button>
|
|
416
|
+
)}
|
|
389
417
|
{showNotificationSettings && permission === "default" && (
|
|
390
418
|
<button
|
|
391
419
|
role="menuitem"
|
|
@@ -15,7 +15,8 @@ import { ENCRYPTED_SUBJECT_PLACEHOLDER, EncryptedPreview, displaySubject } from
|
|
|
15
15
|
import { useDecryptedMessages } from "../../mail/decryptedMessages.js";
|
|
16
16
|
import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
|
|
17
17
|
import PendingMessageCard from "./reading/PendingMessageCard.js";
|
|
18
|
-
import { InlineComposeSlot, useInlineCompose } from "./compose/ComposeContext.js";
|
|
18
|
+
import { InlineComposeSlot, useCompose, useInlineCompose } from "./compose/ComposeContext.js";
|
|
19
|
+
import { resumeFromDraft } from "./compose/draftResume.js";
|
|
19
20
|
import { useMailShell } from "./layout/MailShell.js";
|
|
20
21
|
import { setReadState } from "../../mail/messageReadState.js";
|
|
21
22
|
import { adoptedOutgoing, belongsToThread, settleOutgoing, useOutgoingReplies } from "../../mail/outbox/outgoingReplies.js";
|
|
@@ -206,6 +207,11 @@ export default function ConversationThreadPane({
|
|
|
206
207
|
// The Reply / Reply all / Forward compose windows of this thread's messages, drawn as cards at the top of the list; the thread's messages are
|
|
207
208
|
// what makes the compose provider open a reply here rather than floating at the bottom right.
|
|
208
209
|
const inlineSessions = useInlineCompose(messages.map((message) => message.uid));
|
|
210
|
+
const { openCompose } = useCompose();
|
|
211
|
+
/** The draft this pane has already opened an editor for, so it is opened once per visit - closing the editor leaves the draft as a message to read. */
|
|
212
|
+
const openedDraftRef = useRef<string | null>(null);
|
|
213
|
+
const selectedUidRef = useRef(selectedUid);
|
|
214
|
+
selectedUidRef.current = selectedUid;
|
|
209
215
|
|
|
210
216
|
// Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
|
|
211
217
|
// older generation belongs to a superseded conversation and is dropped rather than applied.
|
|
@@ -291,6 +297,26 @@ export default function ConversationThreadPane({
|
|
|
291
297
|
setPendingFocusUid([...expanded][expanded.size - 1]);
|
|
292
298
|
}, [conversationId, selectedUid, messages]);
|
|
293
299
|
|
|
300
|
+
// A draft the reader opens is edited where it is read: its compose window opens as the card at the top of the thread, with the draft as it was
|
|
301
|
+
// saved. It is opened once per visit to the draft (an encrypted draft, which only the server holds the ciphertext of, stays a message to read).
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (openedDraftRef.current !== null && openedDraftRef.current !== selectedUid) {
|
|
304
|
+
openedDraftRef.current = null;
|
|
305
|
+
}
|
|
306
|
+
if (loading || loadedIdRef.current !== conversationId || !selectedUid || openedDraftRef.current === selectedUid) return;
|
|
307
|
+
const draft = messages.find((message) => message.uid === selectedUid);
|
|
308
|
+
if (!draft || folders.find((folder) => folder.uid === draft.folderUid)?.type !== "drafts") return;
|
|
309
|
+
openedDraftRef.current = draft.uid;
|
|
310
|
+
void resumeFromDraft(draft, client)
|
|
311
|
+
.then((resume) => {
|
|
312
|
+
// The reader may have moved on while it loaded.
|
|
313
|
+
if (resume && selectedUidRef.current === draft.uid) {
|
|
314
|
+
openCompose({ mailboxUid: draft.mailboxUid, resume, inlineFor: draft.uid });
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
.catch(() => undefined);
|
|
318
|
+
}, [conversationId, selectedUid, messages, loading, folders]);
|
|
319
|
+
|
|
294
320
|
useLayoutEffect(() => {
|
|
295
321
|
if (!pendingFocusUid) return;
|
|
296
322
|
// The row is always rendered by now: this runs after the DOM update that added the message it
|
|
@@ -301,7 +327,9 @@ export default function ConversationThreadPane({
|
|
|
301
327
|
// took the app header and the folder rail off the screen - so the adjustment goes on whichever
|
|
302
328
|
// ancestor actually scrolls, exactly as the toggle below already does it. When that is the page
|
|
303
329
|
// itself, nothing inside the pane scrolls and the row is already in view, so it is left alone.
|
|
304
|
-
const row = rowRefs.current[pendingFocusUid]
|
|
330
|
+
const row = rowRefs.current[pendingFocusUid];
|
|
331
|
+
// None for a draft opened as the editor card (see the effect above): the card takes the view when it is placed.
|
|
332
|
+
if (!row) return;
|
|
305
333
|
const scroller = scrollingAncestor(row);
|
|
306
334
|
if (scroller !== document.documentElement) {
|
|
307
335
|
const rect = row.getBoundingClientRect();
|
|
@@ -571,6 +599,10 @@ export default function ConversationThreadPane({
|
|
|
571
599
|
))}
|
|
572
600
|
{messages.map((message) => {
|
|
573
601
|
const uid = message.uid;
|
|
602
|
+
// A draft being edited here is the editor card above, not a message to read as well.
|
|
603
|
+
if (inlineSessions.some((session) => session.draftUid === uid)) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
574
606
|
const expanded = expandedUids.has(uid);
|
|
575
607
|
const bodyId = `thread-message-${uid}`;
|
|
576
608
|
const messageUnread = isUnread(message);
|
|
@@ -198,6 +198,8 @@ export interface InlineComposeSession {
|
|
|
198
198
|
id: string;
|
|
199
199
|
/** The uid of the message the window answers. */
|
|
200
200
|
inlineFor: string;
|
|
201
|
+
/** The uid of the draft the window continues, when it was opened to edit one: the draft is then the card, not a message beside it. */
|
|
202
|
+
draftUid?: string;
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
interface InlineComposeValue {
|
|
@@ -258,6 +260,9 @@ export function useCompose(): ComposeContextValue {
|
|
|
258
260
|
*/
|
|
259
261
|
export default function ComposeProvider({ children, userUid, trusted }: PropsWithChildren<{ userUid?: string; trusted?: boolean }>) {
|
|
260
262
|
const [sessions, setSessions] = useState<ComposeSession[]>([]);
|
|
263
|
+
// The sessions as of the last render, for `openCompose()` to read at the click.
|
|
264
|
+
const sessionsRef = useRef(sessions);
|
|
265
|
+
sessionsRef.current = sessions;
|
|
261
266
|
const client = useApiClient();
|
|
262
267
|
const isMobile = useIsMobile();
|
|
263
268
|
const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
|
|
@@ -283,7 +288,7 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
|
|
|
283
288
|
const isInline = (session: ComposeSession) => !!session.inlineFor && !parked.has(session.id) && hosted.has(session.inlineFor);
|
|
284
289
|
const inlineKey = sessions.filter(isInline).map((session) => `${session.id}:${session.inlineFor}`).join("|");
|
|
285
290
|
const inlineSessions = useMemo<InlineComposeSession[]>(
|
|
286
|
-
() => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor
|
|
291
|
+
() => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor!, draftUid: session.resume?.draft.uid })),
|
|
287
292
|
[inlineKey],
|
|
288
293
|
);
|
|
289
294
|
const inlineValue = useMemo<InlineComposeValue>(
|
|
@@ -354,6 +359,19 @@ export default function ComposeProvider({ children, userUid, trusted }: PropsWit
|
|
|
354
359
|
resume,
|
|
355
360
|
inlineFor,
|
|
356
361
|
}: OpenComposeInput) {
|
|
362
|
+
// A draft already being edited has its window: it is brought back to the pane that was holding it rather than opened a second time.
|
|
363
|
+
const editing = resume && sessionsRef.current.find((session) => session.resume?.draft.uid === resume.draft.uid);
|
|
364
|
+
if (editing) {
|
|
365
|
+
setParked((prev) => {
|
|
366
|
+
if (!prev.has(editing.id)) {
|
|
367
|
+
return prev;
|
|
368
|
+
}
|
|
369
|
+
const next = new Set(prev);
|
|
370
|
+
next.delete(editing.id);
|
|
371
|
+
return next;
|
|
372
|
+
});
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
357
375
|
const id = crypto.randomUUID();
|
|
358
376
|
markComposePhase(id, "click");
|
|
359
377
|
// The window's own element, attached to the page from the start (it is moved to its slot or the stack as soon as that is drawn).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { type Message, type Recipient, type RecipientType, listAttachments } from "../../../../../lib/mail/mailApi.js";
|
|
6
|
+
import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
|
|
7
|
+
import type { ApiClient } from "../../../../../lib/util/api.js";
|
|
8
|
+
import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
|
|
9
|
+
import { loadOriginalMessage } from "./quotedBody.js";
|
|
10
|
+
|
|
11
|
+
/** The comma-separated address text a recipient field holds, for the recipients of one type. */
|
|
12
|
+
function recipientText(recipients: Recipient[], type: RecipientType): string {
|
|
13
|
+
return recipients
|
|
14
|
+
.filter((recipient) => recipient.type === type)
|
|
15
|
+
.map((recipient) => formatMailAddress(recipient))
|
|
16
|
+
.join(", ");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Plain text as the HTML paragraphs the editor holds. */
|
|
20
|
+
function textToHtml(text: string): string {
|
|
21
|
+
const escape = (value: string): string => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
22
|
+
return text
|
|
23
|
+
.split(/\n{2,}/)
|
|
24
|
+
.map((paragraph) => `<p>${escape(paragraph).replace(/\n/g, "<br>")}</p>`)
|
|
25
|
+
.join("");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
|
|
30
|
+
* saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
|
|
31
|
+
* and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
|
|
32
|
+
* Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
|
|
33
|
+
*/
|
|
34
|
+
export async function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined> {
|
|
35
|
+
if (draft.encrypted) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const [original, attachments] = await Promise.all([loadOriginalMessage(draft, null), listAttachments(draft.folderUid, draft.uid, client)]);
|
|
39
|
+
const html =
|
|
40
|
+
original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
|
|
41
|
+
return {
|
|
42
|
+
draft,
|
|
43
|
+
mailboxUid: draft.mailboxUid,
|
|
44
|
+
to: recipientText(draft.recipients, "to"),
|
|
45
|
+
cc: recipientText(draft.recipients, "cc"),
|
|
46
|
+
bcc: recipientText(draft.recipients, "bcc"),
|
|
47
|
+
subject: draft.subject,
|
|
48
|
+
html,
|
|
49
|
+
attachments,
|
|
50
|
+
requestReceipt: !!draft.requestReceipt,
|
|
51
|
+
signEnabled: false,
|
|
52
|
+
encryptRequested: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -34,3 +34,30 @@ export function setNotificationsEnabled(enabled: boolean): void {
|
|
|
34
34
|
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `localStorage` key of the switch for the sounds that go with notifications (a chime for new mail, a bell for a calendar alarm, a buzz for an
|
|
40
|
+
* error). Absent means on; `"off"` means the user turned them off. Independent of the pop-ups themselves.
|
|
41
|
+
*/
|
|
42
|
+
export const NOTIFICATION_SOUNDS_KEY = "rapidmx-notification-sounds";
|
|
43
|
+
|
|
44
|
+
/** Whether notification sounds are on (the default). */
|
|
45
|
+
export function getNotificationSoundsEnabled(): boolean {
|
|
46
|
+
try {
|
|
47
|
+
return localStorage.getItem(NOTIFICATION_SOUNDS_KEY) !== "off";
|
|
48
|
+
} catch {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setNotificationSoundsEnabled(enabled: boolean): void {
|
|
54
|
+
try {
|
|
55
|
+
if (enabled) {
|
|
56
|
+
localStorage.removeItem(NOTIFICATION_SOUNDS_KEY);
|
|
57
|
+
} else {
|
|
58
|
+
localStorage.setItem(NOTIFICATION_SOUNDS_KEY, "off");
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Storage blocked or full: the choice lasts until the page is reloaded, no longer.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { getNotificationSoundsEnabled } from "./preferences.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The sounds that go with the app's notifications: a chime for new mail, a bell for a calendar alarm and a low double buzz for an error. They are
|
|
9
|
+
* made with the Web Audio API as they are played - no audio files to download, cache or ship - so they work offline and cost nothing until used.
|
|
10
|
+
*
|
|
11
|
+
* Browsers only let a page make sound after the person has interacted with it; until then the audio context stays suspended and a sound is silently
|
|
12
|
+
* skipped (it is resumed, and plays from then on, as soon as the page has had a click or a key press). A sound is never an error: whatever
|
|
13
|
+
* goes wrong with the audio is swallowed. The same sound is not played twice within `MIN_GAP_MS`, so a burst of notifications is one sound, not a clatter.
|
|
14
|
+
*/
|
|
15
|
+
export type NotificationSound = "mail" | "calendar" | "error";
|
|
16
|
+
|
|
17
|
+
/** The shortest time between two plays of the same sound. */
|
|
18
|
+
export const MIN_GAP_MS = 750;
|
|
19
|
+
|
|
20
|
+
interface Tone {
|
|
21
|
+
/** Pitch in Hz at the start. */
|
|
22
|
+
frequency: number;
|
|
23
|
+
/** Pitch in Hz at the end, when it slides. */
|
|
24
|
+
endFrequency?: number;
|
|
25
|
+
/** Seconds after the sound starts. */
|
|
26
|
+
start: number;
|
|
27
|
+
/** Seconds it takes to die away. */
|
|
28
|
+
duration: number;
|
|
29
|
+
type: OscillatorType;
|
|
30
|
+
/** Peak loudness, 0 to 1. */
|
|
31
|
+
gain: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A soft two-note rising chime. */
|
|
35
|
+
const CHIME: Tone[] = [
|
|
36
|
+
{ frequency: 1046.5, start: 0, duration: 0.5, type: "sine", gain: 0.16 },
|
|
37
|
+
{ frequency: 1568, start: 0.14, duration: 0.8, type: "sine", gain: 0.14 },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** A struck bell, twice: a fundamental with the inharmonic partials of a bell, each dying away slowly. */
|
|
41
|
+
const BELL: Tone[] = [0, 0.75].flatMap((start) =>
|
|
42
|
+
[
|
|
43
|
+
{ ratio: 1, gain: 0.16, duration: 1.4 },
|
|
44
|
+
{ ratio: 2.76, gain: 0.08, duration: 1.0 },
|
|
45
|
+
{ ratio: 5.4, gain: 0.04, duration: 0.7 },
|
|
46
|
+
{ ratio: 8.93, gain: 0.02, duration: 0.4 },
|
|
47
|
+
].map<Tone>((partial) => ({ frequency: 880 * partial.ratio, start, duration: partial.duration, type: "sine", gain: partial.gain })),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** Two short falling buzzes - nothing like the other two. */
|
|
51
|
+
const ERROR: Tone[] = [
|
|
52
|
+
{ frequency: 300, endFrequency: 190, start: 0, duration: 0.2, type: "sawtooth", gain: 0.09 },
|
|
53
|
+
{ frequency: 300, endFrequency: 190, start: 0.26, duration: 0.2, type: "sawtooth", gain: 0.09 },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const SOUNDS: Record<NotificationSound, Tone[]> = { mail: CHIME, calendar: BELL, error: ERROR };
|
|
57
|
+
|
|
58
|
+
let context: AudioContext | undefined;
|
|
59
|
+
const lastPlayed: Partial<Record<NotificationSound, number>> = {};
|
|
60
|
+
|
|
61
|
+
function audioContext(): AudioContext | undefined {
|
|
62
|
+
if (context) {
|
|
63
|
+
return context;
|
|
64
|
+
}
|
|
65
|
+
const Ctor: typeof AudioContext | undefined =
|
|
66
|
+
typeof window === "undefined" ? undefined : (window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext);
|
|
67
|
+
if (!Ctor) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
context = new Ctor();
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return context;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function playTone(audio: AudioContext, tone: Tone): void {
|
|
79
|
+
const begin = audio.currentTime + tone.start;
|
|
80
|
+
const end = begin + tone.duration;
|
|
81
|
+
const oscillator = audio.createOscillator();
|
|
82
|
+
const volume = audio.createGain();
|
|
83
|
+
oscillator.type = tone.type;
|
|
84
|
+
oscillator.frequency.setValueAtTime(tone.frequency, begin);
|
|
85
|
+
if (tone.endFrequency !== undefined) {
|
|
86
|
+
oscillator.frequency.exponentialRampToValueAtTime(tone.endFrequency, end);
|
|
87
|
+
}
|
|
88
|
+
// A short attack so it doesn't click, then an exponential die-away.
|
|
89
|
+
volume.gain.setValueAtTime(0.0001, begin);
|
|
90
|
+
volume.gain.exponentialRampToValueAtTime(tone.gain, begin + 0.01);
|
|
91
|
+
volume.gain.exponentialRampToValueAtTime(0.0001, end);
|
|
92
|
+
oscillator.connect(volume);
|
|
93
|
+
volume.connect(audio.destination);
|
|
94
|
+
oscillator.start(begin);
|
|
95
|
+
oscillator.stop(end + 0.05);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Plays `sound`, unless the person turned notification sounds off, the same sound just played, or the browser won't make sound yet. Never throws. */
|
|
99
|
+
export function playNotificationSound(sound: NotificationSound, now: number = Date.now()): void {
|
|
100
|
+
if (!getNotificationSoundsEnabled()) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const last = lastPlayed[sound];
|
|
104
|
+
if (last !== undefined && now - last < MIN_GAP_MS) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const audio = audioContext();
|
|
109
|
+
if (!audio) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
lastPlayed[sound] = now;
|
|
113
|
+
const play = (): void => {
|
|
114
|
+
for (const tone of SOUNDS[sound]) {
|
|
115
|
+
playTone(audio, tone);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
if (audio.state === "suspended") {
|
|
119
|
+
// Allowed once the page has been interacted with; until then this is refused and the sound is skipped.
|
|
120
|
+
void audio.resume().then(play, () => undefined);
|
|
121
|
+
} else {
|
|
122
|
+
play();
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Audio is a nicety: a failure to make a sound never reaches the notification.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Forgets the audio context and what was played when - for tests. */
|
|
130
|
+
export function resetNotificationSounds(): void {
|
|
131
|
+
context = undefined;
|
|
132
|
+
for (const key of Object.keys(lastPlayed) as NotificationSound[]) {
|
|
133
|
+
delete lastPlayed[key];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { getNotificationsEnabled } from "./preferences.js";
|
|
6
|
+
import { playNotificationSound } from "./sounds.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The app's one notification store: every pop-up - new mail, a failed send, an API error, an expired session - goes through
|
|
@@ -390,6 +391,14 @@ export function notify(input: NotifyInput): string {
|
|
|
390
391
|
emit();
|
|
391
392
|
return id;
|
|
392
393
|
}
|
|
394
|
+
// What is shown is heard: a chime for new mail, a bell for a calendar notice, a buzz for an error.
|
|
395
|
+
if (entry.kind === "mail") {
|
|
396
|
+
playNotificationSound("mail");
|
|
397
|
+
} else if (entry.kind === "calendar") {
|
|
398
|
+
playNotificationSound("calendar");
|
|
399
|
+
} else if (entry.kind === "error") {
|
|
400
|
+
playNotificationSound("error");
|
|
401
|
+
}
|
|
393
402
|
const room = visibleCount() < MAX_VISIBLE;
|
|
394
403
|
entries.push(entry);
|
|
395
404
|
if (room) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
6
|
+
import { useLocation, useNavigate } from "../../shared/navigation/index.js";
|
|
6
7
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
7
8
|
import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
8
9
|
import { HiOutlineBars3, HiOutlinePlus } from "react-icons/hi2";
|
|
@@ -28,7 +29,7 @@ import { getPushClient } from "../../../lib/mail/pushClient.js";
|
|
|
28
29
|
import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
|
|
29
30
|
import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
|
|
30
31
|
import { createFolder } from "../../../lib/mail/mailApi.js";
|
|
31
|
-
import { CalendarOccurrence, expandAllOccurrences } from "../../../lib/calendar/recurrence.js";
|
|
32
|
+
import { CalendarOccurrence, expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
|
|
32
33
|
import { useDayKey } from "../../../lib/calendar/useDayKey.js";
|
|
33
34
|
import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
|
|
34
35
|
import Drawer from "../../../lib/components/overlays/Drawer.js";
|
|
@@ -217,13 +218,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
|
|
|
217
218
|
return { rangeStart: new Date(-MAX_DATE_MS), rangeEnd: new Date(MAX_DATE_MS), days: [] as Date[] };
|
|
218
219
|
}
|
|
219
220
|
if (view === "month") {
|
|
220
|
-
const start = startOfWeek(startOfMonth(viewDate), { weekStartsOn:
|
|
221
|
-
const end = endOfWeek(endOfMonth(viewDate), { weekStartsOn:
|
|
221
|
+
const start = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 0 });
|
|
222
|
+
const end = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 0 });
|
|
222
223
|
return { rangeStart: start, rangeEnd: end, days: [] as Date[] };
|
|
223
224
|
}
|
|
224
225
|
if (view === "week") {
|
|
225
|
-
const start = startOfWeek(viewDate, { weekStartsOn:
|
|
226
|
-
const end = endOfWeek(viewDate, { weekStartsOn:
|
|
226
|
+
const start = startOfWeek(viewDate, { weekStartsOn: 0 });
|
|
227
|
+
const end = endOfWeek(viewDate, { weekStartsOn: 0 });
|
|
227
228
|
return { rangeStart: start, rangeEnd: end, days: eachDayOfInterval({ start, end }) };
|
|
228
229
|
}
|
|
229
230
|
if (view === "workWeek") {
|
|
@@ -538,6 +539,35 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
|
|
|
538
539
|
setModal(null);
|
|
539
540
|
}
|
|
540
541
|
|
|
542
|
+
// `?event=<uid>&start=<iso>` opens that event's card on the day it is on - where a calendar alarm's View button leads. `start` names which occurrence
|
|
543
|
+
// of a recurring event. Read from the page's own location, so it also works from the Calendar itself (a shallow navigation), and then taken off the
|
|
544
|
+
// address so a reload doesn't open it again.
|
|
545
|
+
const { search: locationSearch } = useLocation();
|
|
546
|
+
const navigateTo = useNavigate();
|
|
547
|
+
const openedFromAddressRef = useRef("");
|
|
548
|
+
useEffect(() => {
|
|
549
|
+
const params = new URLSearchParams(locationSearch);
|
|
550
|
+
const eventUid = params.get("event");
|
|
551
|
+
const wanted = `${eventUid}|${params.get("start")}`;
|
|
552
|
+
if (!eventUid || openedFromAddressRef.current === wanted) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
openedFromAddressRef.current = wanted;
|
|
556
|
+
const requestedStart = new Date(params.get("start") ?? "");
|
|
557
|
+
navigateTo("/calendar", { replace: true });
|
|
558
|
+
getCalendarEvent(eventUid, client)
|
|
559
|
+
.then((event) => {
|
|
560
|
+
const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
|
|
561
|
+
const candidates = expandOccurrences(event, new Date(from.getTime() - 86_400_000), new Date(from.getTime() + 86_400_000));
|
|
562
|
+
const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ?? candidates[0];
|
|
563
|
+
if (match) {
|
|
564
|
+
setViewDate(occurrenceDay(match));
|
|
565
|
+
openEvent(match);
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
.catch((err) => notifyApiError(err, "Couldn't open the event"));
|
|
569
|
+
}, [locationSearch]);
|
|
570
|
+
|
|
541
571
|
// Keyboard shortcuts - the toolbar's own actions. (A dialog open - the event editor - silences them.)
|
|
542
572
|
useShortcut(SHORTCUTS.calendar.create, () => openNewEvent(), { enabled: !!mailboxUid && !!folderUid });
|
|
543
573
|
useShortcut(SHORTCUTS.calendar.today, goToday);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
createContact,
|
|
12
12
|
deleteContact,
|
|
13
13
|
listContacts,
|
|
14
|
+
ensureSuggestedContacts,
|
|
14
15
|
listContactsInFolders,
|
|
15
16
|
listDeletedContacts,
|
|
16
17
|
setContactFavorite,
|
|
@@ -91,6 +92,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
91
92
|
const client = useApiClient();
|
|
92
93
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
93
94
|
const [deletedContacts, setDeletedContacts] = useState<Contact[]>([]);
|
|
95
|
+
// The mailbox's Suggested Contacts folder - the people it has corresponded with - once the server has made it; `null` while unknown (an older server has none).
|
|
96
|
+
const [suggestedFolderUid, setSuggestedFolderUid] = useState<string | null>(null);
|
|
97
|
+
const [suggestedContacts, setSuggestedContacts] = useState<Contact[]>([]);
|
|
94
98
|
const [deletedLoading, setDeletedLoading] = useState(false);
|
|
95
99
|
const [deletedError, setDeletedError] = useState<string | null>(null);
|
|
96
100
|
const [loading, setLoading] = useState(true);
|
|
@@ -121,6 +125,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
121
125
|
}
|
|
122
126
|
setLoading(true);
|
|
123
127
|
setError(null);
|
|
128
|
+
if (suggestedFolderUid) {
|
|
129
|
+
void loadSuggested(suggestedFolderUid);
|
|
130
|
+
}
|
|
124
131
|
return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
|
|
125
132
|
.then((result) => {
|
|
126
133
|
setContacts(result.items);
|
|
@@ -134,6 +141,31 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
134
141
|
void reload();
|
|
135
142
|
}, [folderUid]);
|
|
136
143
|
|
|
144
|
+
/** Reads the Suggested Contacts folder's contacts. A failure leaves the list as it was. */
|
|
145
|
+
function loadSuggested(suggestedUid: string): Promise<void> {
|
|
146
|
+
return listAllPages((page) => listContacts(suggestedUid, { limit: LIST_PAGE_SIZE, page }, client))
|
|
147
|
+
.then((result) => setSuggestedContacts(result.items))
|
|
148
|
+
.catch(() => undefined);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Asks the server to turn the people this mailbox has corresponded with into suggested contacts (it does so once for each person, so this is cheap when
|
|
152
|
+
// nothing is new), then lists them. An older server, or one that refuses, simply has no such view.
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
let cancelled = false;
|
|
155
|
+
// `ContactsShell` only renders this with a resolved mailbox (see the Deleted view's comment above).
|
|
156
|
+
void ensureSuggestedContacts(mailboxUid!, client)
|
|
157
|
+
.then(async (result) => {
|
|
158
|
+
if (!cancelled) {
|
|
159
|
+
setSuggestedFolderUid(result.folderUid);
|
|
160
|
+
await loadSuggested(result.folderUid);
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
.catch(() => undefined);
|
|
164
|
+
return () => {
|
|
165
|
+
cancelled = true;
|
|
166
|
+
};
|
|
167
|
+
}, [mailboxUid]);
|
|
168
|
+
|
|
137
169
|
// restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
|
|
138
170
|
// (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
|
|
139
171
|
// contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
|
|
@@ -196,6 +228,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
196
228
|
return contacts;
|
|
197
229
|
case "favorites":
|
|
198
230
|
return contacts.filter((c) => c.favorite);
|
|
231
|
+
case "suggested":
|
|
232
|
+
return suggestedContacts;
|
|
199
233
|
case "list":
|
|
200
234
|
return contacts.filter((c) => c.contactListUid === view.uid);
|
|
201
235
|
case "category":
|
|
@@ -203,7 +237,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
203
237
|
case "deleted":
|
|
204
238
|
return deletedContacts;
|
|
205
239
|
}
|
|
206
|
-
}, [contacts, deletedContacts, view]);
|
|
240
|
+
}, [contacts, deletedContacts, suggestedContacts, view]);
|
|
207
241
|
|
|
208
242
|
const filtered = useMemo(() => {
|
|
209
243
|
const q = query.trim().toLowerCase();
|
|
@@ -226,7 +260,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
226
260
|
return arr;
|
|
227
261
|
}, [filtered, sortColumn, sortDesc]);
|
|
228
262
|
|
|
229
|
-
const selected = contacts.find((c) => c.uid === selectedUid) ?? null;
|
|
263
|
+
const selected = contacts.find((c) => c.uid === selectedUid) ?? suggestedContacts.find((c) => c.uid === selectedUid) ?? null;
|
|
230
264
|
const isDeletedView = view.type === "deleted";
|
|
231
265
|
const checkedContacts = sorted.filter((c) => checkedUids.has(c.uid));
|
|
232
266
|
const allChecked = sorted.length > 0 && sorted.every((c) => checkedUids.has(c.uid));
|
|
@@ -392,6 +426,36 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
392
426
|
await reload();
|
|
393
427
|
});
|
|
394
428
|
|
|
429
|
+
// A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
|
|
430
|
+
const handleAddToContacts = singleFlight(async () => {
|
|
431
|
+
for (const contact of checkedContacts) {
|
|
432
|
+
try {
|
|
433
|
+
await createContact(
|
|
434
|
+
{
|
|
435
|
+
mailboxUid: contact.mailboxUid,
|
|
436
|
+
folderUid: folderUid!,
|
|
437
|
+
displayName: contact.displayName,
|
|
438
|
+
givenName: contact.givenName,
|
|
439
|
+
surname: contact.surname,
|
|
440
|
+
emails: contact.emails,
|
|
441
|
+
phones: contact.phones,
|
|
442
|
+
addresses: contact.addresses,
|
|
443
|
+
company: contact.company,
|
|
444
|
+
jobTitle: contact.jobTitle,
|
|
445
|
+
notes: contact.notes,
|
|
446
|
+
categories: contact.categories,
|
|
447
|
+
},
|
|
448
|
+
client,
|
|
449
|
+
);
|
|
450
|
+
await deleteContact(contact.uid, contact.version, client);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
notifyApiError(err, "Couldn't add some of the contacts");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
setCheckedUids(new Set());
|
|
456
|
+
await reload();
|
|
457
|
+
});
|
|
458
|
+
|
|
395
459
|
function handleExportVCard() {
|
|
396
460
|
downloadTextFile(
|
|
397
461
|
checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf",
|
|
@@ -447,7 +511,14 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
447
511
|
// open-ended, so a column's `overflow` would otherwise never apply); on a phone the whole page scrolls, as it always did. Below `lg` the
|
|
448
512
|
// contacts menu is a drawer (a button above the columns), so the list and the detail pane keep a usable width.
|
|
449
513
|
<div className="flex-1 min-w-0 min-h-0 flex flex-col lg:flex-row md:flex-none md:h-[calc(100dvh_-_var(--rr-header-h,4rem))] md:overflow-hidden">
|
|
450
|
-
<ContactsSidebar
|
|
514
|
+
<ContactsSidebar
|
|
515
|
+
mailboxUid={mailboxUid}
|
|
516
|
+
contacts={contacts}
|
|
517
|
+
active={view}
|
|
518
|
+
onSelect={handleSelectView}
|
|
519
|
+
showDeleted={canViewDeleted}
|
|
520
|
+
suggestedCount={suggestedFolderUid ? suggestedContacts.length : undefined}
|
|
521
|
+
/>
|
|
451
522
|
<div className="flex-1 min-w-0 min-h-0 flex">
|
|
452
523
|
{/* Hidden on mobile while the "new contact" form (the one form-pane state mobile keeps
|
|
453
524
|
in-place — see the pane's own comment below) is showing, so the two never compete for the
|
|
@@ -469,6 +540,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
469
540
|
onToggleFavorite={handleToggleFavorite}
|
|
470
541
|
onAddCategory={handleAddCategory}
|
|
471
542
|
onExportVCard={handleExportVCard}
|
|
543
|
+
onAddToContacts={view.type === "suggested" ? handleAddToContacts : undefined}
|
|
472
544
|
onImportFile={handleImportFile}
|
|
473
545
|
shortcuts
|
|
474
546
|
hideNew={isMobile}
|
package/apps/www/tasks/index.tsx
CHANGED
|
@@ -61,7 +61,7 @@ function bucketFor(task: Task, now: Date): Bucket {
|
|
|
61
61
|
if (isToday(due)) {
|
|
62
62
|
return "Today";
|
|
63
63
|
}
|
|
64
|
-
if (!isAfter(due, endOfWeek(now, { weekStartsOn:
|
|
64
|
+
if (!isAfter(due, endOfWeek(now, { weekStartsOn: 0 }))) {
|
|
65
65
|
return "This Week";
|
|
66
66
|
}
|
|
67
67
|
return "Later";
|