feat: copy an event's title and add a note to it from the event details

This commit is contained in:
Linus Rath
2026-10-31 04:35:30 +01:00
parent 574db42652
commit baa3ff5956
4 changed files with 133 additions and 1 deletions
+89 -1
View File
@@ -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<void>;
/** Append a quick note to the description (webmail's "Add note"); offered where the event can be edited. */
onSaveNote?: (event: CalendarEvent, note: string) => Promise<void>;
}
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 ? (
<View style={styles.noteBlock}>
<TextInput
value={noteText}
onChangeText={setNoteText}
placeholder={t('calendar.detail.add_note', 'Add note')}
placeholderTextColor={c.textMuted}
accessibilityLabel={t('calendar.detail.add_note', 'Add note')}
style={styles.noteInput}
multiline
autoFocus
/>
<View style={styles.noteButtons}>
<Pressable onPress={() => { setNoteText(''); setNoteOpen(false); }} style={styles.noteButton} accessibilityRole="button">
<Text style={styles.noteCancel}>{t('calendar.form.cancel', 'Cancel')}</Text>
</Pressable>
<Pressable
onPress={() => { void saveNote(); }}
disabled={!noteText.trim() || noteBusy}
style={[styles.noteButton, (!noteText.trim() || noteBusy) && { opacity: 0.5 }]}
accessibilityRole="button"
>
<Text style={styles.noteSave}>{t('calendar.detail.save_note', 'Save note')}</Text>
</Pressable>
</View>
</View>
) : (
<Pressable onPress={() => setNoteOpen(true)} style={styles.noteToggle} accessibilityRole="button">
<AlignLeft size={16} color={c.textMuted} />
<Text style={styles.noteToggleText}>{t('calendar.detail.add_note', 'Add note')}</Text>
</Pressable>
))}
{recurrence && (
<DetailRow
icon={<Repeat size={16} color={c.textMuted} />}
@@ -419,7 +484,7 @@ export function EventDetailSheet({
)}
</ScrollView>
{(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && (
{(
<View style={styles.actions}>
{onEdit && canEdit && (
<ActionButton
@@ -442,6 +507,11 @@ export function EventDetailSheet({
onPress={() => onExport(event)}
/>
)}
<ActionButton
icon={<Copy size={18} color={c.text} />}
label={t('calendar.events.copy_title_short', 'Title')}
onPress={copyTitle}
/>
{onCopyLink && videoUri && (
<ActionButton
icon={<Link2 size={18} color={c.text} />}
@@ -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: {
@@ -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');
});
});
+10
View File
@@ -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}`;
}
+19
View File
@@ -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).