mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: copy an event's title and add a note to it from the event details
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user