mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Merge branch 'cu-calcon' into catch-up-2026-11
This commit is contained in:
+6
-2
@@ -76,8 +76,10 @@
|
||||
"clear_confirm": "Alle entfernen",
|
||||
"clear_description": "Alle Termine in diesem Kalender werden gelöscht. Termine, die auch zu einem anderen Kalender gehören, werden nur aus diesem entfernt.",
|
||||
"clear_title": "Alle Termine entfernen?",
|
||||
"color_invalid": "Geben Sie eine Farbe als sechs Hex-Ziffern ein, mit oder ohne #, etwa #3b82f6.",
|
||||
"delete_description": "Der Kalender und alle seine Termine werden gelöscht. Dies kann nicht rückgängig gemacht werden.",
|
||||
"delete_title": "Kalender löschen?",
|
||||
"description_field": "Beschreibung",
|
||||
"description_placeholder": "Optional",
|
||||
"edit_calendar": "Kalender bearbeiten",
|
||||
"new_calendar": "Neuer Kalender"
|
||||
@@ -91,7 +93,8 @@
|
||||
},
|
||||
"occurrence_not_found_again": "Die Änderung wurde nicht gespeichert: Der Termin konnte auf dem Server nicht wiedergefunden werden. Versuchen Sie es erneut.",
|
||||
"participants": {
|
||||
"limit_warning": "Mehr als {max, plural, one {# Teilnehmer} other {# Teilnehmer}}, den Organisator mitgezählt: Der Server lehnt das Speichern dieses Termins möglicherweise ab. Ihr Administrator kann das Limit erhöhen."
|
||||
"limit_warning": "Mehr als {max, plural, one {# Teilnehmer} other {# Teilnehmer}}, den Organisator mitgezählt: Der Server lehnt das Speichern dieses Termins möglicherweise ab. Ihr Administrator kann das Limit erhöhen.",
|
||||
"remove_named": "{name} entfernen"
|
||||
},
|
||||
"recurrence": {
|
||||
"editor_occurrences_count": "{count, plural, one {Termin} other {Terminen}}",
|
||||
@@ -146,7 +149,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"delete_confirm": "Dieses Ereignis löschen?"
|
||||
}
|
||||
},
|
||||
"change_month_named": "{month}, Monat wechseln"
|
||||
},
|
||||
"common": {
|
||||
"add": "Hinzufügen",
|
||||
|
||||
+5
-2
@@ -13,6 +13,7 @@
|
||||
"alerts": {
|
||||
"minutes_after": "{count, plural, one {# minute after} other {# minutes after}}"
|
||||
},
|
||||
"change_month_named": "{month}, change month",
|
||||
"colors": {
|
||||
"indigo": "Indigo",
|
||||
"teal": "Teal"
|
||||
@@ -95,10 +96,11 @@
|
||||
"clear_confirm": "Remove all",
|
||||
"clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.",
|
||||
"clear_title": "Remove all events?",
|
||||
"color_invalid": "Enter a color as # and six hex digits, such as #3b82f6.",
|
||||
"color_invalid": "Enter a color as six hex digits, with or without #, such as #3b82f6.",
|
||||
"custom_color": "Custom color (hex)",
|
||||
"delete_description": "The calendar and all of its events will be deleted. This cannot be undone.",
|
||||
"delete_title": "Delete calendar?",
|
||||
"description_field": "Description",
|
||||
"description_placeholder": "Optional",
|
||||
"edit_calendar": "Edit calendar",
|
||||
"fewer_colors": "Fewer colors",
|
||||
@@ -115,7 +117,8 @@
|
||||
"occurrence_not_found_again": "The change was not saved: the occurrence could not be found again on the server. Please try again.",
|
||||
"participants": {
|
||||
"group_members": "{count, plural, one {# member} other {# members}}",
|
||||
"limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit."
|
||||
"limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit.",
|
||||
"remove_named": "Remove {name}"
|
||||
},
|
||||
"recurrence": {
|
||||
"editor_occurrences_count": "{count, plural, one {occurrence} other {occurrences}}",
|
||||
|
||||
@@ -24,6 +24,20 @@ index ed1af56..12c3630 100644
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
| PendingIntent.FLAG_MUTABLE
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java
|
||||
index c138d81..bcd490d 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java
|
||||
@@ -133,6 +133,9 @@ public class RNWidgetProvider extends AppWidgetProvider {
|
||||
try {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
+ // This app's own activity, never a chooser: another build of the app
|
||||
+ // (another flavor) handles the same scheme and would be offered too.
|
||||
+ intent.setPackage(context.getPackageName());
|
||||
context.startActivity(intent);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
index 2a34305..711503b 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
|
||||
@@ -251,7 +251,8 @@ describe('calendar operations', () => {
|
||||
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02', start: '2026-03-02T00:00:00',
|
||||
timeZone: 'Europe/Berlin', title: 'Daily',
|
||||
} as any;
|
||||
const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00', title: 'Once' } as any;
|
||||
// Stalwart 1.0 returns a one-off event under its own id.
|
||||
const single = { id: 's9', baseEventId: null, start: '2026-03-02T09:00:00', title: 'Once' } as any;
|
||||
|
||||
const [hydrated, untouched] = await hydrateExpandedOccurrences([occurrence, single]);
|
||||
|
||||
@@ -267,10 +268,40 @@ describe('calendar operations', () => {
|
||||
});
|
||||
|
||||
it('skips the round trip when nothing recurs', async () => {
|
||||
const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00' } as any;
|
||||
expect(await hydrateExpandedOccurrences([single])).toEqual([single]);
|
||||
const single = { id: 's9', baseEventId: null, start: '2026-03-02T09:00:00' } as any;
|
||||
const own = { id: 'd', baseEventId: 'd', start: '2026-03-02T09:00:00' } as any;
|
||||
expect(await hydrateExpandedOccurrences([single, own])).toEqual([single, own]);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Stalwart 0.16 hands a one-off event out under a synthetic id, with no
|
||||
// recurrenceId and no showWithoutTime, in the query's time zone: the
|
||||
// all-day "Company holiday" read as 00:00 to 00:00.
|
||||
it('gives a one-off all-day event under a synthetic id its all-day flag back', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', {
|
||||
list: [
|
||||
{ id: 'c', showWithoutTime: true, duration: 'P1D' },
|
||||
{ id: 'd', timeZone: 'Europe/Berlin', duration: 'PT1H' },
|
||||
],
|
||||
}, '0']],
|
||||
});
|
||||
const holiday = {
|
||||
id: 'h1pceeaaaaac', baseEventId: 'c', '@type': 'Event', title: 'Company holiday',
|
||||
start: '2026-11-01T00:00:00', duration: 'P1D', timeZone: 'Europe/Berlin',
|
||||
utcStart: '2026-10-31T23:00:00Z', utcEnd: '2026-11-01T23:00:00Z',
|
||||
} as any;
|
||||
const review = {
|
||||
id: 'h1pceeaaaaad', baseEventId: 'd', start: '2026-11-01T14:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
|
||||
} as any;
|
||||
|
||||
const [allDay, timed] = await hydrateExpandedOccurrences([holiday, review]);
|
||||
|
||||
expect(mockRequest.mock.calls[0][0][0][1]).toMatchObject({ ids: ['c', 'd'] });
|
||||
expect(allDay).toMatchObject({ id: 'h1pceeaaaaac', showWithoutTime: true, timeZone: null, start: '2026-11-01T00:00:00' });
|
||||
expect(allDay.recurrenceRules).toBeUndefined();
|
||||
expect(timed).toBe(review);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEvents for expanded occurrences', () => {
|
||||
|
||||
@@ -937,6 +937,28 @@ live('live Stalwart calendar', () => {
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('an all-day event', () => {
|
||||
// 0.16 expands a one-off event to a synthetic id without showWithoutTime;
|
||||
// the detail sheet then said 00:00 - 00:00 and the editor's switch was off.
|
||||
it('stays all-day on the calendar and in the editor', async () => {
|
||||
await connectSingleton();
|
||||
const created = await createEvent({
|
||||
'@type': 'Event',
|
||||
title: `${RUN} all day`,
|
||||
start: '2027-11-10T00:00:00',
|
||||
duration: 'P1D',
|
||||
showWithoutTime: true,
|
||||
} as Partial<CalendarEvent>, calendarId, false);
|
||||
const uid = created.uid ?? (await getEvents([created.id]))[0].uid!;
|
||||
const [event] = await loadOccurrences(uid, '2027-11-08T00:00:00Z', '2027-11-15T00:00:00Z');
|
||||
expect(event, 'the event is on the calendar').toBeTruthy();
|
||||
expect(event.showWithoutTime).toBe(true);
|
||||
expect(event.timeZone ?? null).toBeNull();
|
||||
expect(editorTimesFromEvent(event).allDay).toBe(true);
|
||||
expect(getEventStartDate(event).getDate()).toBe(10);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('a subscribed feed', () => {
|
||||
const FEED_URL = 'https://feeds.invalid/live.ics';
|
||||
let feed = '';
|
||||
|
||||
@@ -22,6 +22,12 @@ import { JMAPClient } from '../jmap-client';
|
||||
import { CAPABILITIES } from '../types';
|
||||
import { applyShareChanges, getShareTargets, supportsDelegation, supportsMailboxSharing } from '../delegation';
|
||||
import { buildGrantChanges, buildRevokeChanges, summarizeDelegates } from '../../lib/delegation/delegation';
|
||||
import { lookupMessage, lookupResult, principalRowDisplay, resolvePrincipal } from '../../lib/delegation/display';
|
||||
import { listPrincipals } from '../files';
|
||||
import { findPrincipalByEmail } from '../delegation';
|
||||
import { getAddressBooks, setAddressBookShare } from '../contacts';
|
||||
|
||||
const t = (_key: string, fallback?: string) => fallback ?? '';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
@@ -120,4 +126,57 @@ live('live Stalwart: delegates', () => {
|
||||
expect(gone.calendars.filter((id) => changes.some((c) => c.id === id))).toEqual([]);
|
||||
expect(gone.addressBooks.filter((id) => changes.some((c) => c.id === id))).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
// Stalwart 1.0 with directory queries off (VM 106's 1.0.1) names nobody
|
||||
// the owner merely shares with: Principal/get answers notFound for the
|
||||
// delegate's id, a letter or two. The row reads "Unknown person" then, not
|
||||
// "Unknown person / e"; 0.16 names them.
|
||||
it('names who has access where the server lets it, and shows no id where it does not', async () => {
|
||||
const { targets } = await getShareTargets();
|
||||
const principalId = delegate.accountId;
|
||||
const results = await applyShareChanges(buildGrantChanges(targets, principalId, { mail: 'none', calendars: 'none', addressBooks: 'default' }, 'read'));
|
||||
expect(results.filter((r) => !r.ok)).toEqual([]);
|
||||
try {
|
||||
const listing = await listPrincipals();
|
||||
const sessionAccounts = new Map(Object.entries(owner.currentSession?.accounts ?? {})
|
||||
.filter(([id]) => id !== owner.accountId)
|
||||
.map(([id, info]) => [id, info.name]));
|
||||
const principal = resolvePrincipal(principalId, { directory: listing.principals, remembered: new Map(), sessionAccounts });
|
||||
const row = principalRowDisplay(principal, t);
|
||||
if (listing.directoryQueriesOff) {
|
||||
expect(principal).toBeNull();
|
||||
expect(row).toEqual({ name: 'Unknown person', detail: null });
|
||||
} else {
|
||||
expect(principal?.email?.toLowerCase()).toBe(USER2!.toLowerCase());
|
||||
expect(row.name).not.toBe('Unknown person');
|
||||
}
|
||||
} finally {
|
||||
expect(await revokeAll()).toBe(true);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// The calendar and address book share sheet looks a typed address up on
|
||||
// its own. 0.16 finds it and the book is shared; 1.0 with directory
|
||||
// queries off refuses every lookup, and the sheet says an administrator
|
||||
// has to turn them on instead of "No matches".
|
||||
it('shares an address book with a typed address the server finds, and says why when it will not look', async () => {
|
||||
await useOwner();
|
||||
const listing = await listPrincipals();
|
||||
const found = await findPrincipalByEmail(USER2!);
|
||||
const result = lookupResult(USER2!, found, [owner.accountId]);
|
||||
if (listing.directoryQueriesOff) {
|
||||
expect(found).toMatchObject({ principal: null, status: 'forbidden', directoryQueriesOff: true });
|
||||
expect(lookupMessage(result.lookup, t)).toMatch(/administrator has to turn on directory lookups/);
|
||||
return;
|
||||
}
|
||||
expect(result.picked?.id).toBe(delegate.accountId);
|
||||
const book = (await getAddressBooks()).find((b) => b.isDefault) ?? (await getAddressBooks())[0];
|
||||
try {
|
||||
await setAddressBookShare(book.id, result.picked!.id, { mayRead: true, mayWrite: false, mayDelete: false, mayShare: false } as never);
|
||||
expect((await delegateView()).addressBooks).toContain(book.id);
|
||||
} finally {
|
||||
await useOwner();
|
||||
expect(await revokeAll()).toBe(true);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
+5
-2
@@ -865,7 +865,10 @@ async function queryExpandedRange(
|
||||
* rule / overrides (and all-day ones without showWithoutTime). Fetch those
|
||||
* from the base events in one batched get and merge them in, so an
|
||||
* occurrence carries the same series context the device-side expansion
|
||||
* copies from its master.
|
||||
* copies from its master. Stalwart 0.16 also hands a one-off event out
|
||||
* under a synthetic id, without a recurrenceId and without
|
||||
* showWithoutTime: an all-day one would read as 00:00 to 00:00, so its base
|
||||
* is fetched as well.
|
||||
*/
|
||||
export async function hydrateExpandedOccurrences(
|
||||
events: CalendarEvent[],
|
||||
@@ -873,7 +876,7 @@ export async function hydrateExpandedOccurrences(
|
||||
): Promise<CalendarEvent[]> {
|
||||
const baseIds = Array.from(new Set(
|
||||
events
|
||||
.filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId)
|
||||
.filter((event) => isServerRecurrenceInstance(event))
|
||||
.map((event) => event.baseEventId as string),
|
||||
));
|
||||
if (baseIds.length === 0) return events;
|
||||
|
||||
@@ -27,7 +27,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
|
||||
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { customColorGrid, normalizeHexColor } from '../../lib/calendar-custom-color';
|
||||
import { customColorGrid, HEX_FIELD_MAX_LENGTH, hexFieldText, normalizeHexColor } from '../../lib/calendar-custom-color';
|
||||
|
||||
const CUSTOM_COLOR_GRID = customColorGrid();
|
||||
import Button from '../Button';
|
||||
@@ -260,11 +260,14 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
const hex = normalizeHexColor(v);
|
||||
if (hex) setColor(hex);
|
||||
}}
|
||||
// "3b82f6" works as well as "#3b82f6"; leaving the field
|
||||
// shows it with its "#".
|
||||
onBlur={() => setHexInput((v) => hexFieldText(v))}
|
||||
placeholder="#3b82f6"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={7}
|
||||
maxLength={HEX_FIELD_MAX_LENGTH}
|
||||
accessibilityLabel={t('calendar.management.custom_color', 'Custom color (hex)')}
|
||||
style={[styles.input, styles.hexInput, hexInvalid && styles.inputInvalid]}
|
||||
/>
|
||||
@@ -283,7 +286,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
</View>
|
||||
{hexInvalid && (
|
||||
<Text style={styles.error}>
|
||||
{t('calendar.management.color_invalid', 'Enter a color as # and six hex digits, such as #3b82f6.')}
|
||||
{t('calendar.management.color_invalid', 'Enter a color as six hex digits, with or without #, such as #3b82f6.')}
|
||||
</Text>
|
||||
)}
|
||||
{moreColors && (
|
||||
@@ -307,9 +310,11 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.label}>{t('calendar.management.description', 'Description')}</Text>
|
||||
{/* Not calendar.management.description: in the webmail's
|
||||
catalog that is the management page's desktop help text. */}
|
||||
<Text style={styles.label}>{t('calendar.management.description_field', 'Description')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel={t('calendar.management.description', 'Description')}
|
||||
accessibilityLabel={t('calendar.management.description_field', 'Description')}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t('calendar.management.description_placeholder', 'Optional')}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useContactsStore, type RecipientSuggestion } from '../../stores/contact
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import type { Attendee } from '../../lib/calendar-participants';
|
||||
import { participantSuggestions, participantsFromSuggestion } from '../../lib/calendar-participant-suggestions';
|
||||
import { participantRemoveAccessibilityLabel } from '../../lib/calendar-a11y';
|
||||
|
||||
interface ParticipantInputProps {
|
||||
attendees: Attendee[];
|
||||
@@ -31,7 +32,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 +76,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 (
|
||||
@@ -84,7 +93,7 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
||||
onPress={() => onRemove(a.email)}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.participants.remove', 'Remove')}
|
||||
accessibilityLabel={participantRemoveAccessibilityLabel(a, t)}
|
||||
>
|
||||
<X size={12} color={c.textMuted} />
|
||||
</Pressable>
|
||||
@@ -93,28 +102,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 +129,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ interface TimeGridScrollViewProps {
|
||||
onSelectDate?: (date: Date) => void;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
/** A change takes the "New event" block away (the editor closed, the tab was left). */
|
||||
draftResetKey?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +121,7 @@ function TimeGridScrollViewInner({
|
||||
onSelectDate,
|
||||
onSelectEvent,
|
||||
onCreateAtTime,
|
||||
draftResetKey = 0,
|
||||
}: TimeGridScrollViewProps) {
|
||||
const c = useColors();
|
||||
// "Light event colors" (webmail calendarTintedEvents).
|
||||
@@ -314,6 +317,9 @@ function TimeGridScrollViewInner({
|
||||
// A tap on an empty slot marks it with a "New event" block; tapping that
|
||||
// block creates the event. Two taps, so a stray tap never opens the editor.
|
||||
const [draft, setDraft] = React.useState<{ key: string; hour: number } | null>(null);
|
||||
React.useEffect(() => {
|
||||
setDraft(null);
|
||||
}, [draftResetKey]);
|
||||
const handleTapAt = React.useCallback((day: Date, hour: number) => {
|
||||
const key = dayKey(day);
|
||||
setDraft((cur) => (cur && cur.key === key && cur.hour === hour ? null : { key, hour }));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../CalendarEditSheet.tsx'), 'utf8');
|
||||
const catalog = (file: string) => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
|
||||
const common = catalog('locales/en/common.json');
|
||||
const overlay = catalog('locales/rn/en.json');
|
||||
|
||||
function lookup(tree: Record<string, unknown>, key: string): unknown {
|
||||
return key.split('.').reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], tree);
|
||||
}
|
||||
|
||||
// The app reads the overlay over the webmail's catalog.
|
||||
const english = (key: string) => (lookup(overlay, key) ?? lookup(common, key)) as string | undefined;
|
||||
|
||||
describe('the calendar sheet’s description field', () => {
|
||||
it('is named "Description", not by the webmail’s desktop help text', () => {
|
||||
const before = source.slice(0, source.indexOf('value={description}'));
|
||||
const input = before.slice(before.lastIndexOf('<TextInput'));
|
||||
const key = input.match(/accessibilityLabel=\{t\('([^']+)'/)?.[1];
|
||||
expect(key).toBeTruthy();
|
||||
expect(english(key!)).toBe('Description');
|
||||
|
||||
const labels = [...before.matchAll(/<Text style=\{styles\.label\}>\{t\('([^']+)'/g)];
|
||||
expect(english(labels[labels.length - 1][1])).toBe('Description');
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Checked on the source: the suite has no React Native renderer.
|
||||
const grid = fs.readFileSync(path.resolve(__dirname, '../TimeGridScrollView.tsx'), 'utf8');
|
||||
const screen = fs.readFileSync(path.resolve(__dirname, '../../../screens/CalendarScreen.tsx'), 'utf8');
|
||||
|
||||
describe('the "New event" block a tap on an empty slot leaves', () => {
|
||||
it('goes when the grid is told to drop it', () => {
|
||||
expect(grid).toMatch(/React\.useEffect\(\(\) => \{\s*setDraft\(null\);\s*\}, \[draftResetKey\]\);/);
|
||||
});
|
||||
|
||||
it('is dropped when the editor opens or closes and when the user leaves the tab', () => {
|
||||
const grids = screen.slice(screen.indexOf('<TimeGridScrollView'), screen.indexOf('/>', screen.indexOf('<TimeGridScrollView')));
|
||||
expect(grids).toMatch(/draftResetKey=\{slotDraftReset\}/);
|
||||
expect(screen).toMatch(/React\.useEffect\(\(\) => \{\s*setSlotDraftReset\(\(n\) => n \+ 1\);\s*\}, \[modalVisible\]\);/);
|
||||
// The focus effect's cleanup runs on blur.
|
||||
expect(screen).toMatch(/useFocusEffect\(\s*React\.useCallback\(\(\) => \(\) => setSlotDraftReset\(\(n\) => n \+ 1\), \[\]\),?\s*\)/);
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
} from 'react-native';
|
||||
import { Trash2, UserPlus, Users } from '../icons';
|
||||
import { listPrincipals, ownPrincipalId, type PrincipalListing } from '../../api/files';
|
||||
import { findPrincipalByEmail } from '../../api/delegation';
|
||||
import type { Principal } from '../../api/types';
|
||||
import { directoryNotice } from '../../lib/principal-directory';
|
||||
import { canLookUpAddress, lookupMessage, lookupResult, type Lookup } from '../../lib/delegation/display';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
@@ -72,12 +74,17 @@ export function ShareCollectionSheet<R extends object>({
|
||||
const [shares, setShares] = React.useState<Record<string, R>>({});
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [savingId, setSavingId] = React.useState<string | null>(null);
|
||||
// A typed address nobody listed has: looked up on its own, as the
|
||||
// Delegates dialog does (webmail delegate-dialog.tsx). The listing can
|
||||
// miss people the server still finds by address.
|
||||
const [lookup, setLookup] = React.useState<Lookup>({ state: 'idle' });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!collection) return undefined;
|
||||
let cancelled = false;
|
||||
setShares(collection.shareWith ?? {});
|
||||
setSearch('');
|
||||
setLookup({ state: 'idle' });
|
||||
setLoading(true);
|
||||
void listPrincipals().then(({ principals: list, ...listing }) => {
|
||||
if (cancelled) return;
|
||||
@@ -138,6 +145,29 @@ export function ShareCollectionSheet<R extends object>({
|
||||
}
|
||||
};
|
||||
|
||||
const typedAddress = search.trim();
|
||||
// Anyone listed with that address is a candidate, has access or is the user.
|
||||
const offerLookup = !loading && candidates.length === 0 && canLookUpAddress(search, principals);
|
||||
|
||||
const lookUp = async () => {
|
||||
if (!offerLookup || lookup.state === 'busy' || savingId) return;
|
||||
setLookup({ state: 'busy' });
|
||||
const found = await findPrincipalByEmail(typedAddress);
|
||||
let ownIds: string[] = [];
|
||||
try {
|
||||
ownIds = [ownPrincipalId()];
|
||||
} catch {
|
||||
// No session yet: the server's answer still says who it is.
|
||||
}
|
||||
const result = lookupResult(typedAddress, found, ownIds);
|
||||
setLookup(result.lookup);
|
||||
const picked = result.picked;
|
||||
if (!picked) return;
|
||||
setPrincipals((prev) => (prev.some((p) => p.id === picked.id) ? prev : [...prev, picked]));
|
||||
if (shares[picked.id] == null) await applyShare(picked.id, addRights);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
// The list below is partial or empty for a reason the user can't see
|
||||
@@ -164,8 +194,10 @@ export function ShareCollectionSheet<R extends object>({
|
||||
<Text style={styles.principalName} numberOfLines={1}>
|
||||
{p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)}
|
||||
</Text>
|
||||
{p?.email || unresolved ? (
|
||||
<Text style={styles.principalEmail} numberOfLines={1}>{p?.email || principalId}</Text>
|
||||
{/* Never the id in its place: Stalwart 1.0's are a letter or two
|
||||
("e") and read as a broken address. */}
|
||||
{p?.email ? (
|
||||
<Text style={styles.principalEmail} numberOfLines={1}>{p.email}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@@ -238,7 +270,10 @@ export function ShareCollectionSheet<R extends object>({
|
||||
<TextInput
|
||||
accessibilityLabel={t('sharing.add_person', 'Add person or group')}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
onChangeText={(text) => {
|
||||
setSearch(text);
|
||||
setLookup({ state: 'idle' });
|
||||
}}
|
||||
placeholder={t('calendar.share.search_placeholder', 'Search by name or email')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
@@ -250,6 +285,30 @@ export function ShareCollectionSheet<R extends object>({
|
||||
) : null}
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} style={{ marginTop: spacing.md }} />
|
||||
) : offerLookup ? (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={() => void lookUp()}
|
||||
disabled={lookup.state === 'busy' || !!savingId}
|
||||
style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ busy: lookup.state === 'busy' }}
|
||||
>
|
||||
<Text style={[styles.principalName, styles.principalInfo]} numberOfLines={1}>
|
||||
{lookup.state === 'busy'
|
||||
? t('delegates.looking_up', 'Looking up…')
|
||||
: t('delegates.use_address', 'Use {address}', { address: typedAddress })}
|
||||
</Text>
|
||||
{lookup.state === 'busy' || savingId ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<UserPlus size={18} color={c.primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
{lookupMessage(lookup, t) ? (
|
||||
<Text style={styles.empty} accessibilityLiveRegion="polite">{lookupMessage(lookup, t)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : candidates.length === 0 ? (
|
||||
notice && !search.trim() ? null : (
|
||||
<Text style={styles.empty}>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { translate } from '../../../i18n';
|
||||
import { canLookUpAddress, lookupMessage, lookupResult } from '../../../lib/delegation/display';
|
||||
import type { Principal } from '../../../api/types';
|
||||
|
||||
const tEn = (key: string, fallback?: string, params?: Record<string, string | number>) => translate('en', key, fallback, params);
|
||||
const sheet = fs.readFileSync(path.resolve(__dirname, '../ShareCollectionSheet.tsx'), 'utf8');
|
||||
|
||||
describe('sharing a calendar or address book with a typed address', () => {
|
||||
it('offers to look the address up when nobody listed has it', () => {
|
||||
// Stalwart 1.0 with directory queries off lists only the user and their groups.
|
||||
const listed: Principal[] = [{ id: 'd', type: 'individual', name: 'usera@example.org', email: 'usera@example.org' }];
|
||||
expect(canLookUpAddress('userc@example.org', listed)).toBe(true);
|
||||
expect(canLookUpAddress('usera@example.org', listed)).toBe(false);
|
||||
expect(canLookUpAddress('userc', listed)).toBe(false);
|
||||
});
|
||||
|
||||
it('says why the server could not find the address instead of "No matches"', () => {
|
||||
const off = lookupResult('userc@example.org', { principal: null, status: 'forbidden' }, ['d']);
|
||||
expect(off.picked).toBeNull();
|
||||
expect(lookupMessage(off.lookup, tEn)).toMatch(/administrator has to turn on directory lookups/);
|
||||
const missing = lookupResult('nobody@example.org', { principal: null, status: 'ok' }, ['d']);
|
||||
expect(lookupMessage(missing.lookup, tEn)).toBe('Nobody on this server has the address nobody@example.org.');
|
||||
expect(lookupMessage({ state: 'idle' }, tEn)).toBeNull();
|
||||
});
|
||||
|
||||
it('is wired into the share sheet: looked up on its own and shared once found', () => {
|
||||
expect(sheet).toMatch(/canLookUpAddress\(search, principals\)/);
|
||||
expect(sheet).toMatch(/await findPrincipalByEmail\(typedAddress\)/);
|
||||
expect(sheet).toMatch(/if \(shares\[picked\.id\] == null\) await applyShare\(picked\.id, addRights\);/);
|
||||
expect(sheet).toMatch(/lookupMessage\(lookup, t\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { addressBookLabel } from '../address-book-label';
|
||||
|
||||
describe('addressBookLabel', () => {
|
||||
it('names a shared book once when Stalwart already put the account in its name', () => {
|
||||
// As Stalwart 0.16 and 1.0 name a group's default book.
|
||||
expect(addressBookLabel({ name: 'Stalwart Address Book (team@example.org)', isShared: true, accountName: 'team@example.org' }))
|
||||
.toBe('Stalwart Address Book (team@example.org)');
|
||||
});
|
||||
|
||||
it('adds the account to a shared book whose name does not say where it is from', () => {
|
||||
expect(addressBookLabel({ name: 'Clients', isShared: true, accountName: 'team@example.org' })).toBe('Clients (team@example.org)');
|
||||
expect(addressBookLabel({ name: 'Clients', isShared: false, accountName: 'usera@example.org' })).toBe('Clients');
|
||||
});
|
||||
|
||||
it('is what the contact form shows', () => {
|
||||
const form = fs.readFileSync(path.resolve(__dirname, '../../screens/ContactFormScreen.tsx'), 'utf8');
|
||||
expect(form).toMatch(/\{addressBookLabel\(book\)\}/);
|
||||
expect(form).not.toMatch(/\$\{book\.name\} \(\$\{book\.accountName\}\)/);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,12 @@ vi.hoisted(() => {
|
||||
import { enUS } from 'date-fns/locale';
|
||||
import type { CalendarEvent } from '../../api/types';
|
||||
import { formatMessage } from '../../i18n/format';
|
||||
import { dayCellAccessibilityLabel, eventAccessibilityLabel } from '../calendar-a11y';
|
||||
import {
|
||||
dayCellAccessibilityLabel,
|
||||
eventAccessibilityLabel,
|
||||
monthButtonAccessibilityLabel,
|
||||
participantRemoveAccessibilityLabel,
|
||||
} from '../calendar-a11y';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
|
||||
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
|
||||
@@ -46,3 +51,24 @@ describe('eventAccessibilityLabel', () => {
|
||||
.toMatch(/^\(No title\), Monday, Nov(ember)? 2, 2026, 9:00 AM – 9:15 AM, Cancelled$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('header and chip buttons', () => {
|
||||
it('names the month button by the month it shows, not "Click to change month"', () => {
|
||||
expect(monthButtonAccessibilityLabel('October 2026', t)).toBe('October 2026, change month');
|
||||
});
|
||||
|
||||
it('names the cross on a participant chip by the person it removes', () => {
|
||||
expect(participantRemoveAccessibilityLabel({ name: 'Ana Lima', email: 'ana@example.com' }, t)).toBe('Remove Ana Lima');
|
||||
expect(participantRemoveAccessibilityLabel({ name: '', email: 'ana@example.com' }, t)).toBe('Remove ana@example.com');
|
||||
});
|
||||
|
||||
it('is what the calendar header and the participant chips use', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/CalendarScreen.tsx'), 'utf8');
|
||||
const chips = fs.readFileSync(path.resolve(__dirname, '../../components/calendar/ParticipantInput.tsx'), 'utf8');
|
||||
expect(screen).toMatch(/accessibilityLabel=\{monthButtonAccessibilityLabel\(/);
|
||||
expect(screen).not.toMatch(/mini_calendar_change/);
|
||||
expect(chips).toMatch(/accessibilityLabel=\{participantRemoveAccessibilityLabel\(a, t\)\}/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { customColorGrid, normalizeHexColor } from '../calendar-custom-color';
|
||||
import { customColorGrid, HEX_FIELD_MAX_LENGTH, hexFieldText, normalizeHexColor } from '../calendar-custom-color';
|
||||
|
||||
describe('normalizeHexColor', () => {
|
||||
it('takes a typed hex colour in its usual spellings', () => {
|
||||
@@ -23,3 +23,31 @@ describe('customColorGrid', () => {
|
||||
expect(grid[1][0]).toBe('#c32222');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the hex field', () => {
|
||||
it('shows a colour typed without "#" with it once the user leaves the field', () => {
|
||||
expect(hexFieldText('3b82f6')).toBe('#3b82f6');
|
||||
expect(hexFieldText('3B82F6')).toBe('#3b82f6');
|
||||
expect(hexFieldText('#3b82f6')).toBe('#3b82f6');
|
||||
expect(hexFieldText('3b8')).toBe('#33bb88');
|
||||
// Not a colour: left as typed for the hint under the field.
|
||||
expect(hexFieldText('3b82f')).toBe('3b82f');
|
||||
});
|
||||
|
||||
it('takes a pasted colour with spaces around it', () => {
|
||||
const pasted = ' #3b82f6 ';
|
||||
expect(pasted.length).toBeLessThanOrEqual(HEX_FIELD_MAX_LENGTH);
|
||||
expect(normalizeHexColor(pasted.slice(0, HEX_FIELD_MAX_LENGTH))).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the calendar sheet’s hex field', () => {
|
||||
it('spells the colour out on blur and takes the longer pasted text', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const sheet = fs.readFileSync(path.resolve(__dirname, '../../components/calendar/CalendarEditSheet.tsx'), 'utf8');
|
||||
const field = sheet.slice(sheet.indexOf('value={hexInput}'), sheet.indexOf('/>', sheet.indexOf('value={hexInput}')));
|
||||
expect(field).toMatch(/onBlur=\{\(\) => setHexInput\(\(v\) => hexFieldText\(v\)\)\}/);
|
||||
expect(field).toMatch(/maxLength=\{HEX_FIELD_MAX_LENGTH\}/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AddressBook } from '../api/types';
|
||||
|
||||
/**
|
||||
* How an address book is named in a picker: a shared one with the account
|
||||
* it comes from, unless its name already says so. Stalwart names a default
|
||||
* book "Stalwart Address Book (team@example.org)", which came out as
|
||||
* "... (team@example.org) (team@example.org)". Webmail contact-form.tsx.
|
||||
*/
|
||||
export function addressBookLabel(book: Pick<AddressBook, 'name' | 'isShared' | 'accountName'>): string {
|
||||
const name = book.name ?? '';
|
||||
if (!book.isShared || !book.accountName || name.includes(book.accountName)) return name;
|
||||
return `${name} (${book.accountName})`;
|
||||
}
|
||||
@@ -11,6 +11,20 @@ type Translate = (key: string, fallback?: string, params?: Record<string, string
|
||||
// 19e0acde9): a day cell by its full date and how many events it has, an
|
||||
// event by its title, date, time, calendar and state.
|
||||
|
||||
/**
|
||||
* "October 2026, change month" for the header button that opens the month
|
||||
* panel: the month shown first, then what the button does. Not the
|
||||
* webmail's "Click to change month" (calendar.mini_calendar_change).
|
||||
*/
|
||||
export function monthButtonAccessibilityLabel(title: string, t: Translate): string {
|
||||
return t('calendar.change_month_named', '{month}, change month', { month: title });
|
||||
}
|
||||
|
||||
/** "Remove Ana" for the cross on an invited participant's chip (webmail recipient_remove_named). */
|
||||
export function participantRemoveAccessibilityLabel(attendee: { name?: string; email: string }, t: Translate): string {
|
||||
return t('calendar.participants.remove_named', 'Remove {name}', { name: attendee.name?.trim() || attendee.email });
|
||||
}
|
||||
|
||||
/** "Monday, November 2, 2026, 3 events" for a month day cell. */
|
||||
export function dayCellAccessibilityLabel(day: Date, eventCount: number, t: Translate, locale: Locale): string {
|
||||
const date = formatCalendarDate(day, locale.code, 'longWeekdayMonthDayYear');
|
||||
|
||||
@@ -10,6 +10,21 @@ export function normalizeHexColor(input: string): string | null {
|
||||
return `#${hex.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the hex field shows once the user leaves it: a colour typed without
|
||||
* its "#", in capitals or as three digits, spelled out as "#rrggbb"; anything
|
||||
* else stays as typed, so the hint under the field still applies to it.
|
||||
*/
|
||||
export function hexFieldText(input: string): string {
|
||||
return normalizeHexColor(input) ?? input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest text the hex field takes: "#rrggbb" plus a space either side, as a
|
||||
* pasted value can carry. Seven cut a pasted " #3b82f6" to an invalid colour.
|
||||
*/
|
||||
export const HEX_FIELD_MAX_LENGTH = 9;
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { translate } from '../../../i18n';
|
||||
import { principalRowDisplay, resolvePrincipal } from '../display';
|
||||
|
||||
const tEn = (key: string, fallback?: string, params?: Record<string, string | number>) => translate('en', key, fallback, params);
|
||||
|
||||
describe('someone with access the server does not name', () => {
|
||||
// Stalwart 1.0 with directory queries off: Principal/get answers notFound
|
||||
// for the sharee's id ("e") and the session does not list them.
|
||||
it('reads as "Unknown person" without the id as an address', () => {
|
||||
const principal = resolvePrincipal('e', { directory: [], remembered: new Map(), sessionAccounts: new Map() });
|
||||
expect(principal).toBeNull();
|
||||
expect(principalRowDisplay(principal, tEn)).toEqual({ name: 'Unknown person', detail: null });
|
||||
});
|
||||
|
||||
it('reads as the person when the directory names them (0.16)', () => {
|
||||
const userB = { id: 'e', type: 'individual' as const, name: 'userb@example.org', email: 'userb@example.org', description: 'User B' };
|
||||
const principal = resolvePrincipal('e', { directory: [userB], remembered: new Map(), sessionAccounts: new Map() });
|
||||
expect(principalRowDisplay(principal, tEn)).toEqual({ name: 'userb@example.org', detail: 'User B' });
|
||||
});
|
||||
|
||||
it('gets no id for an address line in the calendar and address book share sheet', () => {
|
||||
const sheet = fs.readFileSync(path.resolve(__dirname, '../../../components/sharing/ShareCollectionSheet.tsx'), 'utf8');
|
||||
const label = sheet.slice(sheet.indexOf('const renderPrincipalLabel'), sheet.indexOf('return (', sheet.indexOf('const renderPrincipalLabel')) + 900);
|
||||
expect(label).not.toMatch(/p\?\.email \|\| principalId/);
|
||||
expect(label).toMatch(/\{p\?\.email \? \(/);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,17 @@ export function principalDisplay(principal: Pick<Principal, 'id' | 'name' | 'ema
|
||||
return { name, detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* The row for someone with access: the principal's name and address, or
|
||||
* "Unknown person" with nothing under it. Stalwart 1.0 with directory
|
||||
* queries off names nobody the user merely shares with; its ids are a
|
||||
* letter or two ("e"), which read as a broken address when shown.
|
||||
*/
|
||||
export function principalRowDisplay(principal: Principal | null, t: Translate): { name: string; detail: string | null } {
|
||||
if (principal) return principalDisplay(principal);
|
||||
return { name: t('delegates.unknown_person', 'Unknown person'), detail: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The principal behind an id: from the directory, the names kept on this
|
||||
* device, or, as a last way to name someone the directory won't (Stalwart
|
||||
@@ -86,6 +97,22 @@ export type Lookup =
|
||||
| { state: 'self' }
|
||||
| { state: 'failed' };
|
||||
|
||||
/** The line under a typed address after a lookup, or null (webmail delegate-dialog.tsx). */
|
||||
export function lookupMessage(lookup: Lookup, t: Translate): string | null {
|
||||
switch (lookup.state) {
|
||||
case 'notFound':
|
||||
return t('delegates.not_found', 'Nobody on this server has the address {address}.', { address: lookup.address });
|
||||
case 'failed':
|
||||
return t('delegates.lookup_failed', "Couldn't look up this address.");
|
||||
case 'self':
|
||||
return t('delegates.is_you', "That's you.");
|
||||
case 'directoryOff':
|
||||
return t('delegates.directory_off_note', 'Your administrator has to turn on directory lookups in Stalwart before you can add someone new.');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What a lookup of `address` found, as the dialog shows it; `picked` is set when it found someone. */
|
||||
export function lookupResult(
|
||||
address: string,
|
||||
|
||||
@@ -349,9 +349,16 @@ export function hydrateRecurrenceInstances(
|
||||
bases: ReadonlyMap<string, Partial<CalendarEvent>>,
|
||||
): CalendarEvent[] {
|
||||
return collapseDuplicateInstances(instances, bases).map((instance) => {
|
||||
if (!isServerRecurrenceInstance(instance) || !instance.recurrenceId) return instance;
|
||||
if (!isServerRecurrenceInstance(instance)) return instance;
|
||||
const base = instance.baseEventId ? bases.get(instance.baseEventId) : undefined;
|
||||
if (!base) return instance;
|
||||
// Stalwart 0.16 expands a one-off event to a synthetic id too, without
|
||||
// a recurrenceId and without showWithoutTime, in the query's time zone.
|
||||
if (!instance.recurrenceId) {
|
||||
return base.showWithoutTime
|
||||
? { ...instance, showWithoutTime: true, timeZone: base.timeZone ?? null }
|
||||
: instance;
|
||||
}
|
||||
|
||||
const hydrated: CalendarEvent = {
|
||||
...instance,
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { monthButtonAccessibilityLabel } from '../lib/calendar-a11y';
|
||||
import { TASK_CHIP_ID_PREFIX, multiDayCount } from '../lib/calendar-time-grid';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
@@ -239,6 +241,15 @@ export default function CalendarScreen() {
|
||||
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
// The "New event" block a tap on an empty slot leaves: gone once the
|
||||
// editor opens or closes, or the user leaves the tab.
|
||||
const [slotDraftReset, setSlotDraftReset] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
setSlotDraftReset((n) => n + 1);
|
||||
}, [modalVisible]);
|
||||
useFocusEffect(
|
||||
React.useCallback(() => () => setSlotDraftReset((n) => n + 1), []),
|
||||
);
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = React.useState(false);
|
||||
const [tasksVisible, setTasksVisible] = React.useState(false);
|
||||
@@ -1027,7 +1038,7 @@ export default function CalendarScreen() {
|
||||
style={styles.titleBtn}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.mini_calendar_change', 'Change month')}
|
||||
accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale), t)}
|
||||
accessibilityState={{ expanded: monthPanelOpen }}
|
||||
>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
@@ -1177,6 +1188,7 @@ export default function CalendarScreen() {
|
||||
onSelectDate={viewMode === 'day' ? handleSelectDate : openDay}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onCreateAtTime={openCreate}
|
||||
draftResetKey={slotDraftReset}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'week' && !freeScroll && (
|
||||
|
||||
@@ -30,6 +30,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { addressBookLabel } from '../lib/address-book-label';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
|
||||
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
|
||||
@@ -517,7 +518,7 @@ export default function ContactFormScreen() {
|
||||
style={[styles.pill, form.addressBookId === book.id && styles.pillActive]}
|
||||
>
|
||||
<Text style={[styles.pillText, form.addressBookId === book.id && styles.pillTextActive]}>
|
||||
{book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
{addressBookLabel(book)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { open, links } from '../clicks';
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const patchFile = path.join(root, 'patches/react-native-android-widget+0.22.1.patch');
|
||||
const provider = 'node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java';
|
||||
|
||||
// The library's own openUri, as published: what the patch is applied to.
|
||||
const UPSTREAM_OPEN_URI = [
|
||||
' private void openUri(Context context, String uri) {',
|
||||
' try {',
|
||||
' Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));',
|
||||
' intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);',
|
||||
' context.startActivity(intent);',
|
||||
' } catch (Exception e) {',
|
||||
' e.printStackTrace();',
|
||||
' }',
|
||||
' }',
|
||||
];
|
||||
|
||||
describe('a tap that opens the app from a widget', () => {
|
||||
it('goes through the library’s OPEN_URI, which the patch makes explicit', () => {
|
||||
expect(open(links.inbox())).toEqual({ clickAction: 'OPEN_URI', clickActionData: { uri: 'bulwarkmobile://mail/folder/inbox' } });
|
||||
});
|
||||
|
||||
it('is sent to this app’s own package, so no chooser offers another build', async () => {
|
||||
const { parsePatchFile } = await import(path.join(root, 'node_modules/patch-package/dist/patch/parse.js'));
|
||||
const { executeEffects } = await import(path.join(root, 'node_modules/patch-package/dist/patch/apply.js'));
|
||||
const effects = parsePatchFile(fs.readFileSync(patchFile, 'utf8'))
|
||||
.filter((e: { path?: string }) => e.path?.endsWith('RNWidgetProvider.java'));
|
||||
expect(effects).toHaveLength(1);
|
||||
|
||||
// The published file around openUri, padded to its line numbers.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'widget-patch-'));
|
||||
const target = path.join(dir, provider);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const lines = Array.from({ length: 131 }, () => '').concat(UPSTREAM_OPEN_URI, ['', '}']);
|
||||
fs.writeFileSync(target, lines.join('\n'));
|
||||
const cwd = process.cwd();
|
||||
try {
|
||||
process.chdir(dir);
|
||||
executeEffects(effects, { dryRun: false });
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
const patched = fs.readFileSync(target, 'utf8');
|
||||
const openUri = patched.slice(patched.indexOf('private void openUri'), patched.indexOf('startActivity'));
|
||||
expect(openUri).toMatch(/intent\.setPackage\(context\.getPackageName\(\)\);/);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user