Merge branch 'cu-calcon' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 13:48:41 +01:00
29 changed files with 651 additions and 48 deletions
+6 -2
View File
@@ -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
View File
@@ -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
+34 -3
View File
@@ -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
View File
@@ -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;
+10 -5
View File
@@ -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')}
+2 -1
View File
@@ -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()))
}
+36 -25
View File
@@ -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\}\)/);
});
});
+27 -1
View File
@@ -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\}/);
});
});
+13
View File
@@ -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})`;
}
+14
View File
@@ -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');
+15
View File
@@ -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) => {
+12
View File
@@ -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 \? \(/);
});
});
+27
View File
@@ -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,
+8 -1
View File
@@ -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,
+13 -1
View File
@@ -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 && (
+2 -1
View File
@@ -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 });
});
});