mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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,
|
||||
seedAttendees,
|
||||
userOrganizes,
|
||||
withAttendee,
|
||||
type Attendee,
|
||||
} from '../../lib/calendar-participants';
|
||||
import type { SeriesEdit } from '../../lib/recurrence-scope-edit';
|
||||
@@ -880,7 +881,7 @@ export function EventModal({
|
||||
>
|
||||
<ParticipantInput
|
||||
attendees={attendees}
|
||||
onAdd={(a) => setAttendees((prev) => [...prev, a])}
|
||||
onAdd={(a) => setAttendees((prev) => withAttendee(prev, a))}
|
||||
onRemove={(email) =>
|
||||
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 styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
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.
|
||||
const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
|
||||
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
|
||||
@@ -68,7 +75,8 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (draft.trim()) addParticipant(draft);
|
||||
const typed = draftRef.current;
|
||||
if (typed.trim()) addParticipant(typed);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -93,28 +101,8 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
||||
</View>
|
||||
)}
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Above the field, not below it: below, the keyboard covered the
|
||||
list (the editor keeps only the focused field in view). */}
|
||||
{filteredSuggestions.length > 0 && (
|
||||
<ScrollView
|
||||
style={styles.suggestions}
|
||||
@@ -140,6 +128,28 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* `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'];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user