mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 08:07:23 +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,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
@@ -82,6 +83,8 @@ interface EventDetailSheetProps {
|
|||||||
// Copy the meeting link; only offered when the event has one.
|
// Copy the meeting link; only offered when the event has one.
|
||||||
onCopyLink?: (event: CalendarEvent, link: string) => void;
|
onCopyLink?: (event: CalendarEvent, link: string) => void;
|
||||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<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 {
|
function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, locale: Locale): string {
|
||||||
@@ -134,10 +137,20 @@ export function EventDetailSheet({
|
|||||||
onExport,
|
onExport,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
|
onSaveNote,
|
||||||
}: EventDetailSheetProps) {
|
}: EventDetailSheetProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const { locale, t } = useCalendarLocale();
|
const { locale, t } = useCalendarLocale();
|
||||||
const [rsvpBusy, setRsvpBusy] = React.useState(false);
|
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]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
// Bare addresses (the organizer above all — Stalwart drops its display
|
// Bare addresses (the organizer above all — Stalwart drops its display
|
||||||
// name) render with the contact card's name instead of the raw email.
|
// 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 = () => {
|
const copyLocation = () => {
|
||||||
if (!location) return;
|
if (!location) return;
|
||||||
void Clipboard.setStringAsync(location).then(() => {
|
void Clipboard.setStringAsync(location).then(() => {
|
||||||
@@ -333,6 +366,38 @@ export function EventDetailSheet({
|
|||||||
linkify
|
linkify
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 && (
|
{recurrence && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<Repeat size={16} color={c.textMuted} />}
|
icon={<Repeat size={16} color={c.textMuted} />}
|
||||||
@@ -419,7 +484,7 @@ export function EventDetailSheet({
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && (
|
{(
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{onEdit && canEdit && (
|
{onEdit && canEdit && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -442,6 +507,11 @@ export function EventDetailSheet({
|
|||||||
onPress={() => onExport(event)}
|
onPress={() => onExport(event)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ActionButton
|
||||||
|
icon={<Copy size={18} color={c.text} />}
|
||||||
|
label={t('calendar.events.copy_title_short', 'Title')}
|
||||||
|
onPress={copyTitle}
|
||||||
|
/>
|
||||||
{onCopyLink && videoUri && (
|
{onCopyLink && videoUri && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<Link2 size={18} color={c.text} />}
|
icon={<Link2 size={18} color={c.text} />}
|
||||||
@@ -669,6 +739,24 @@ function makeStyles(c: ThemePalette) {
|
|||||||
joinBtnText: { ...typography.bodyMedium, color: c.primary },
|
joinBtnText: { ...typography.bodyMedium, color: c.primary },
|
||||||
|
|
||||||
rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs },
|
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 },
|
rsvpPrompt: { ...typography.bodyMedium, color: c.text },
|
||||||
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
|
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
|
||||||
rsvpBtn: {
|
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 { useColors } from '../theme/colors';
|
||||||
import { CalendarAccountChangedError, calendarOperationScope, useCalendarStore } from '../stores/calendar-store';
|
import { CalendarAccountChangedError, calendarOperationScope, useCalendarStore } from '../stores/calendar-store';
|
||||||
import { useSettingsStore } from '../stores/settings-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 { MonthView } from '../components/calendar/MonthView';
|
||||||
import { MonthScrollView } from '../components/calendar/MonthScrollView';
|
import { MonthScrollView } from '../components/calendar/MonthScrollView';
|
||||||
import { WeekView } from '../components/calendar/WeekView';
|
import { WeekView } from '../components/calendar/WeekView';
|
||||||
@@ -819,6 +821,22 @@ export default function CalendarScreen() {
|
|||||||
[deleteTask, t],
|
[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
|
// Clone the event one day later and open it in the editor (webmail's
|
||||||
// handleDuplicateFromDetail).
|
// handleDuplicateFromDetail).
|
||||||
const handleDuplicateFromDetail = React.useCallback(
|
const handleDuplicateFromDetail = React.useCallback(
|
||||||
@@ -1181,6 +1199,7 @@ export default function CalendarScreen() {
|
|||||||
onDuplicate={(ev) => { if (!isReadOnlyEvent(ev)) void handleDuplicateFromDetail(ev); }}
|
onDuplicate={(ev) => { if (!isReadOnlyEvent(ev)) void handleDuplicateFromDetail(ev); }}
|
||||||
onExport={handleExportFromDetail}
|
onExport={handleExportFromDetail}
|
||||||
onCopyLink={handleCopyLink}
|
onCopyLink={handleCopyLink}
|
||||||
|
onSaveNote={handleSaveNoteFromDetail}
|
||||||
onRsvp={async (ev, participantId, status) => {
|
onRsvp={async (ev, participantId, status) => {
|
||||||
// An answer on one occurrence of a series asks whether it covers
|
// An answer on one occurrence of a series asks whether it covers
|
||||||
// just that occurrence or the whole series (webmail #1086).
|
// just that occurrence or the whole series (webmail #1086).
|
||||||
|
|||||||
Reference in New Issue
Block a user