mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
fix: add a typed participant once and show suggestions above the keyboard
This commit is contained in:
@@ -67,6 +67,7 @@ import {
|
|||||||
participantLimitMessage,
|
participantLimitMessage,
|
||||||
seedAttendees,
|
seedAttendees,
|
||||||
userOrganizes,
|
userOrganizes,
|
||||||
|
withAttendee,
|
||||||
type Attendee,
|
type Attendee,
|
||||||
} from '../../lib/calendar-participants';
|
} from '../../lib/calendar-participants';
|
||||||
import type { SeriesEdit } from '../../lib/recurrence-scope-edit';
|
import type { SeriesEdit } from '../../lib/recurrence-scope-edit';
|
||||||
@@ -880,7 +881,7 @@ export function EventModal({
|
|||||||
>
|
>
|
||||||
<ParticipantInput
|
<ParticipantInput
|
||||||
attendees={attendees}
|
attendees={attendees}
|
||||||
onAdd={(a) => setAttendees((prev) => [...prev, a])}
|
onAdd={(a) => setAttendees((prev) => withAttendee(prev, a))}
|
||||||
onRemove={(email) =>
|
onRemove={(email) =>
|
||||||
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
|
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const [draft, setDraft] = React.useState('');
|
const [draft, setDraftState] = React.useState('');
|
||||||
|
// Done submits and then blurs the field; both handlers run before a
|
||||||
|
// re-render, so they read the text from here, not from `draft`.
|
||||||
|
const draftRef = React.useRef('');
|
||||||
|
const setDraft = React.useCallback((value: string) => {
|
||||||
|
draftRef.current = value;
|
||||||
|
setDraftState(value);
|
||||||
|
}, []);
|
||||||
// Contacts, groups and recent Sent recipients, as the composer offers them.
|
// Contacts, groups and recent Sent recipients, as the composer offers them.
|
||||||
const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
|
const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
|
||||||
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
|
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
|
||||||
@@ -68,7 +75,8 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (draft.trim()) addParticipant(draft);
|
const typed = draftRef.current;
|
||||||
|
if (typed.trim()) addParticipant(typed);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,28 +101,8 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextInput
|
{/* Above the field, not below it: below, the keyboard covered the
|
||||||
accessibilityLabel={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
|
list (the editor keeps only the focused field in view). */}
|
||||||
// No full-screen keyboard in landscape: it would hide the suggestions
|
|
||||||
// and the text around the field.
|
|
||||||
disableFullscreenUI
|
|
||||||
value={draft}
|
|
||||||
onChangeText={setDraft}
|
|
||||||
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
|
|
||||||
placeholderTextColor={c.textMuted}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="email-address"
|
|
||||||
// The app's own suggestions only: system autofill would cover them.
|
|
||||||
autoComplete="off"
|
|
||||||
importantForAutofill="no"
|
|
||||||
textContentType="none"
|
|
||||||
onSubmitEditing={handleSubmit}
|
|
||||||
onBlur={handleSubmit}
|
|
||||||
returnKeyType="done"
|
|
||||||
style={styles.input}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{filteredSuggestions.length > 0 && (
|
{filteredSuggestions.length > 0 && (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.suggestions}
|
style={styles.suggestions}
|
||||||
@@ -140,6 +128,28 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
|||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
|
||||||
|
// No full-screen keyboard in landscape: it would hide the suggestions
|
||||||
|
// and the text around the field.
|
||||||
|
disableFullscreenUI
|
||||||
|
value={draft}
|
||||||
|
onChangeText={setDraft}
|
||||||
|
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
// The app's own suggestions only: system autofill would cover them.
|
||||||
|
autoComplete="off"
|
||||||
|
importantForAutofill="no"
|
||||||
|
textContentType="none"
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
onBlur={handleSubmit}
|
||||||
|
returnKeyType="done"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { withAttendee, type Attendee } from '../../../lib/calendar-participants';
|
||||||
|
|
||||||
|
const input = fs.readFileSync(path.resolve(__dirname, '../ParticipantInput.tsx'), 'utf8');
|
||||||
|
const editor = fs.readFileSync(path.resolve(__dirname, '../EventModal.tsx'), 'utf8');
|
||||||
|
|
||||||
|
describe('adding a participant with Done', () => {
|
||||||
|
it('invites a typed address once although submit and blur both add it', () => {
|
||||||
|
// Done fires onSubmitEditing, then onBlur, before the editor re-renders:
|
||||||
|
// two updaters run on the same queue.
|
||||||
|
const typed: Attendee = { name: '', email: 'userc@example.org' };
|
||||||
|
let attendees: Attendee[] = [];
|
||||||
|
for (const updater of [(prev: Attendee[]) => withAttendee(prev, typed), (prev: Attendee[]) => withAttendee(prev, typed)]) {
|
||||||
|
attendees = updater(attendees);
|
||||||
|
}
|
||||||
|
expect(attendees).toEqual([typed]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an address in other case or with spaces as the same person', () => {
|
||||||
|
const list = [{ name: 'C', email: 'userc@example.org' }];
|
||||||
|
expect(withAttendee(list, { name: '', email: ' UserC@Example.org ' })).toBe(list);
|
||||||
|
expect(withAttendee(list, { name: '', email: 'userb@example.org' })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds through the dedupe in the editor and reads the typed text from a ref', () => {
|
||||||
|
expect(editor).toMatch(/onAdd=\{\(a\) => setAttendees\(\(prev\) => withAttendee\(prev, a\)\)\}/);
|
||||||
|
const submit = input.slice(input.indexOf('const handleSubmit'), input.indexOf('};', input.indexOf('const handleSubmit')));
|
||||||
|
expect(submit).toMatch(/draftRef\.current/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the suggestions above the field, where the keyboard does not cover them', () => {
|
||||||
|
const jsx = input.slice(input.indexOf('return ('));
|
||||||
|
expect(jsx.indexOf('filteredSuggestions.length > 0')).toBeGreaterThan(-1);
|
||||||
|
expect(jsx.indexOf('filteredSuggestions.length > 0')).toBeLessThan(jsx.indexOf('<TextInput'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -269,6 +269,18 @@ export interface Attendee {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `attendees` with `added` at the end, unless that address is invited
|
||||||
|
* already. Done/Enter on Android submits and then blurs the field, and both
|
||||||
|
* add what was typed: checking against the latest list (a state updater's
|
||||||
|
* `prev`) keeps the second add from inviting the same person twice.
|
||||||
|
*/
|
||||||
|
export function withAttendee(attendees: Attendee[], added: Attendee): Attendee[] {
|
||||||
|
const key = added.email.trim().toLowerCase();
|
||||||
|
if (!key || attendees.some((a) => a.email.trim().toLowerCase() === key)) return attendees;
|
||||||
|
return [...attendees, added];
|
||||||
|
}
|
||||||
|
|
||||||
type ParticipationStatus = Participant['participationStatus'];
|
type ParticipationStatus = Participant['participationStatus'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user