From baa3ff59561ac538cb86cbcba0b8ffe3fe4d612c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:35:30 +0100 Subject: [PATCH] feat: copy an event's title and add a note to it from the event details --- src/components/calendar/EventDetailSheet.tsx | 90 ++++++++++++++++++- src/lib/__tests__/calendar-event-note.test.ts | 15 ++++ src/lib/calendar-event-note.ts | 10 +++ src/screens/CalendarScreen.tsx | 19 ++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/calendar-event-note.test.ts create mode 100644 src/lib/calendar-event-note.ts diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 2b02d375..a03ab3a8 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -8,6 +8,7 @@ import { ScrollView, StyleSheet, Text, + TextInput, View, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; @@ -82,6 +83,8 @@ interface EventDetailSheetProps { // Copy the meeting link; only offered when the event has one. onCopyLink?: (event: CalendarEvent, link: string) => void; onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise; + /** Append a quick note to the description (webmail's "Add note"); offered where the event can be edited. */ + onSaveNote?: (event: CalendarEvent, note: string) => Promise; } function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, locale: Locale): string { @@ -134,10 +137,20 @@ export function EventDetailSheet({ onExport, onCopyLink, onRsvp, + onSaveNote, }: EventDetailSheetProps) { const c = useColors(); const { locale, t } = useCalendarLocale(); const [rsvpBusy, setRsvpBusy] = React.useState(false); + const [noteOpen, setNoteOpen] = React.useState(false); + const [noteText, setNoteText] = React.useState(''); + const [noteBusy, setNoteBusy] = React.useState(false); + // A note typed for one event is not left for the next. + const eventId = event?.id; + React.useEffect(() => { + setNoteOpen(false); + setNoteText(''); + }, [eventId]); const styles = React.useMemo(() => makeStyles(c), [c]); // Bare addresses (the organizer above all — Stalwart drops its display // name) render with the contact card's name instead of the raw email. @@ -230,6 +243,26 @@ export function EventDetailSheet({ } }; + const copyTitle = () => { + if (!event) return; + void Clipboard.setStringAsync(event.title || '').then(() => { + toast.success(t('calendar.notifications.title_copied', 'Title copied')); + }).catch(() => {}); + }; + + const saveNote = async () => { + const note = noteText.trim(); + if (!event || !onSaveNote || !note || noteBusy) return; + setNoteBusy(true); + try { + await onSaveNote(event, note); + setNoteText(''); + setNoteOpen(false); + } finally { + setNoteBusy(false); + } + }; + const copyLocation = () => { if (!location) return; void Clipboard.setStringAsync(location).then(() => { @@ -333,6 +366,38 @@ export function EventDetailSheet({ linkify /> ) : null} + {onSaveNote && canEdit && (noteOpen ? ( + + + + { setNoteText(''); setNoteOpen(false); }} style={styles.noteButton} accessibilityRole="button"> + {t('calendar.form.cancel', 'Cancel')} + + { void saveNote(); }} + disabled={!noteText.trim() || noteBusy} + style={[styles.noteButton, (!noteText.trim() || noteBusy) && { opacity: 0.5 }]} + accessibilityRole="button" + > + {t('calendar.detail.save_note', 'Save note')} + + + + ) : ( + setNoteOpen(true)} style={styles.noteToggle} accessibilityRole="button"> + + {t('calendar.detail.add_note', 'Add note')} + + ))} {recurrence && ( } @@ -419,7 +484,7 @@ export function EventDetailSheet({ )} - {(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && ( + {( {onEdit && canEdit && ( onExport(event)} /> )} + } + label={t('calendar.events.copy_title_short', 'Title')} + onPress={copyTitle} + /> {onCopyLink && videoUri && ( } @@ -669,6 +739,24 @@ function makeStyles(c: ThemePalette) { joinBtnText: { ...typography.bodyMedium, color: c.primary }, rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs }, + noteToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, paddingVertical: spacing.xs }, + noteToggleText: { ...typography.body, color: c.textMuted }, + noteBlock: { gap: spacing.xs }, + noteInput: { + ...typography.body, + color: c.text, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + minHeight: 56, + textAlignVertical: 'top', + }, + noteButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm }, + noteButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs }, + noteCancel: { ...typography.bodyMedium, color: c.textMuted }, + noteSave: { ...typography.bodyMedium, color: c.primary }, rsvpPrompt: { ...typography.bodyMedium, color: c.text }, rsvpButtons: { flexDirection: 'row', gap: spacing.sm }, rsvpBtn: { diff --git a/src/lib/__tests__/calendar-event-note.test.ts b/src/lib/__tests__/calendar-event-note.test.ts new file mode 100644 index 00000000..94f35539 --- /dev/null +++ b/src/lib/__tests__/calendar-event-note.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { appendEventNote } from '../calendar-event-note'; + +describe('appendEventNote', () => { + const now = new Date(2026, 10, 2, 9, 30); + + it('adds the note under a time stamp below what the description holds', () => { + expect(appendEventNote('Agenda', 'Bring the slides', now)).toBe('Agenda\n\n--- 2026-11-02 09:30 ---\nBring the slides'); + }); + + it('starts an empty description with the stamped note', () => { + expect(appendEventNote('', 'Room 4', now)).toBe('--- 2026-11-02 09:30 ---\nRoom 4'); + expect(appendEventNote(undefined, 'Room 4', now)).toBe('--- 2026-11-02 09:30 ---\nRoom 4'); + }); +}); diff --git a/src/lib/calendar-event-note.ts b/src/lib/calendar-event-note.ts new file mode 100644 index 00000000..67dc4577 --- /dev/null +++ b/src/lib/calendar-event-note.ts @@ -0,0 +1,10 @@ +import { format } from 'date-fns'; + +/** + * The event's description with a quick note appended under a time stamp + * (webmail calendar-app handleSaveNoteFromDetail): "--- 2026-11-02 09:30 ---". + */ +export function appendEventNote(description: string | null | undefined, note: string, now: Date): string { + const stamp = `--- ${format(now, 'yyyy-MM-dd HH:mm')} ---`; + return description ? `${description}\n\n${stamp}\n${note}` : `${stamp}\n${note}`; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 77c857c8..d769e6ec 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -42,6 +42,8 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import { CalendarAccountChangedError, calendarOperationScope, useCalendarStore } from '../stores/calendar-store'; import { useSettingsStore } from '../stores/settings-store'; +import { appendEventNote } from '../lib/calendar-event-note'; +import { toast } from '../stores/toast-store'; import { MonthView } from '../components/calendar/MonthView'; import { MonthScrollView } from '../components/calendar/MonthScrollView'; import { WeekView } from '../components/calendar/WeekView'; @@ -819,6 +821,22 @@ export default function CalendarScreen() { [deleteTask, t], ); + // A quick note from the detail sheet goes under the description with a + // time stamp (webmail's handleSaveNoteFromDetail). + const handleSaveNoteFromDetail = React.useCallback( + async (event: CalendarEvent, note: string) => { + const description = appendEventNote(event.description, note, displayNow()); + try { + await updateEvent(event.id, { description }); + setDetailEvent((current) => (current?.id === event.id ? { ...current, description } : current)); + toast.success(t('calendar.detail.note_saved', 'Note saved')); + } catch (err) { + reportError(err); + } + }, + [updateEvent, reportError, t], + ); + // Clone the event one day later and open it in the editor (webmail's // handleDuplicateFromDetail). const handleDuplicateFromDetail = React.useCallback( @@ -1181,6 +1199,7 @@ export default function CalendarScreen() { onDuplicate={(ev) => { if (!isReadOnlyEvent(ev)) void handleDuplicateFromDetail(ev); }} onExport={handleExportFromDetail} onCopyLink={handleCopyLink} + onSaveNote={handleSaveNoteFromDetail} onRsvp={async (ev, participantId, status) => { // An answer on one occurrence of a series asks whether it covers // just that occurrence or the whole series (webmail #1086).