mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: suggest directory people and search the sent folder for recipients in the composer
This commit is contained in:
@@ -38,6 +38,8 @@ import { htmlToPlainText } from '../../lib/compose-html';
|
||||
import { buildEmbeddedSignatureHtml, spliceSignature } from '../../lib/signature-utils';
|
||||
import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files';
|
||||
import { servedLoginOf } from '../../lib/draft-login';
|
||||
import { mergeRecipientSuggestions } from '../../lib/recipient-suggestions';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { useTemplatesStore } from '../../stores/templates-store';
|
||||
|
||||
@@ -315,6 +317,44 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// The composer's suggestions: "Search the server" finds a recipient in
|
||||
// the Sent folder the recent list never loaded, and directory people are
|
||||
// offered (parity 06).
|
||||
describe('recipient suggestions', () => {
|
||||
it('finds a Sent recipient on demand and suggests people from the directory', async () => {
|
||||
const rcpt = `rcpt-${RUN}@example.net`;
|
||||
const saved = await saveDraftVersion({
|
||||
from: [{ email: USER }], to: [{ name: `Rcpt ${RUN}`, email: rcpt }], subject: `suggest ${tag}`, textBody: 'hi',
|
||||
}, sent);
|
||||
createdEmails.push(saved.id);
|
||||
useContactsStore.getState().reset();
|
||||
useContactsStore.setState({ sentMailboxId: sent, recentRecipientsLoaded: true });
|
||||
|
||||
const local = useContactsStore.getState().getAutocomplete(`rcpt-${RUN}`, 16);
|
||||
expect(local).toEqual([]);
|
||||
// The server indexes a new message shortly after it is stored.
|
||||
let server: Awaited<ReturnType<ReturnType<typeof useContactsStore.getState>['searchRecipients']>> = [];
|
||||
await vi.waitFor(async () => {
|
||||
server = await useContactsStore.getState().searchRecipients(`rcpt-${RUN}`);
|
||||
expect(server.map((s) => s.email)).toContain(rcpt);
|
||||
}, { timeout: 20_000, interval: 1_000 });
|
||||
const merged = mergeRecipientSuggestions({
|
||||
local, isContact: () => false, directory: [], server, exclude: new Set(), limit: 8,
|
||||
});
|
||||
expect(merged).toEqual([{ name: `Rcpt ${RUN}`, email: rcpt }]);
|
||||
|
||||
await useContactsStore.getState().fetchDirectory();
|
||||
const me = USER.split('@')[0];
|
||||
const withDirectory = mergeRecipientSuggestions({
|
||||
local: [], isContact: () => false,
|
||||
directory: useContactsStore.getState().lookupDirectory(me, 16),
|
||||
server: [], exclude: new Set(), limit: 8,
|
||||
});
|
||||
expect(withDirectory.map((s) => s.email.toLowerCase())).toContain(USER.toLowerCase());
|
||||
useContactsStore.getState().reset();
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('the body of a composed message (compose pass 2026-11)', () => {
|
||||
it('keeps the quote marks, numbered list, signature delimiter and image text of the text part, and one signature', async () => {
|
||||
// The composer's body after a From switch with text typed under the
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { mergeRecipientSuggestions } from '../recipient-suggestions';
|
||||
import type { RecipientSuggestion } from '../../stores/contacts-store';
|
||||
|
||||
// The composer's suggestions in the webmail's order: contacts and groups,
|
||||
// directory people, recent recipients, then what "Search the server" found
|
||||
// in the Sent folder (parity 06 autocomplete row).
|
||||
|
||||
const contact = (name: string, email: string): RecipientSuggestion => ({ name, email });
|
||||
const contactEmails = new Set(['ann@example.com', 'nameless@example.com']);
|
||||
const isContact = (s: RecipientSuggestion) => !!s.group || contactEmails.has(s.email);
|
||||
|
||||
describe('mergeRecipientSuggestions', () => {
|
||||
it('puts directory people after contacts and before recent recipients', () => {
|
||||
const out = mergeRecipientSuggestions({
|
||||
local: [contact('Ann', 'ann@example.com'), contact('Recent Rob', 'rob@example.com')],
|
||||
isContact,
|
||||
directory: [contact('Dana Directory', 'dana@example.com')],
|
||||
server: [],
|
||||
exclude: new Set(),
|
||||
limit: 8,
|
||||
});
|
||||
expect(out.map((s) => s.email)).toEqual(['ann@example.com', 'dana@example.com', 'rob@example.com']);
|
||||
});
|
||||
|
||||
it('lists an address once and lends a directory name to a contact without one', () => {
|
||||
const out = mergeRecipientSuggestions({
|
||||
local: [contact('', 'nameless@example.com'), contact('Recent Dana', 'dana@example.com')],
|
||||
isContact,
|
||||
directory: [contact('Nora Nameless', 'Nameless@example.com'), contact('Dana Directory', 'dana@example.com')],
|
||||
server: [],
|
||||
exclude: new Set(),
|
||||
limit: 8,
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{ name: 'Nora Nameless', email: 'nameless@example.com' },
|
||||
{ name: 'Dana Directory', email: 'dana@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds server hits below the rest, skipping known and selected addresses', () => {
|
||||
const local = Array.from({ length: 10 }, (_, i) => contact(`Recent ${i}`, `r${i}@example.com`));
|
||||
const out = mergeRecipientSuggestions({
|
||||
local,
|
||||
isContact,
|
||||
directory: [],
|
||||
server: [contact('R0 again', 'r0@example.com'), contact('Picked', 'picked@example.com'), contact('Found', 'found@example.com'), contact('R9', 'r9@example.com')],
|
||||
exclude: new Set(['picked@example.com']),
|
||||
limit: 8,
|
||||
});
|
||||
// Eight local ones, then the server's new ones; r9 fell past the limit
|
||||
// and comes back from the server.
|
||||
expect(out.map((s) => s.email)).toEqual([
|
||||
...local.slice(0, 8).map((s) => s.email), 'found@example.com', 'r9@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps groups and leaves out addresses already on the message', () => {
|
||||
const group: RecipientSuggestion = { name: 'Team', email: '', group: { id: 'g1', memberCount: 3 } };
|
||||
const out = mergeRecipientSuggestions({
|
||||
local: [group, contact('Ann', 'ann@example.com')],
|
||||
isContact,
|
||||
directory: [contact('Ann', 'ANN@example.com')],
|
||||
server: [],
|
||||
exclude: new Set(['ann@example.com']),
|
||||
limit: 8,
|
||||
});
|
||||
expect(out).toEqual([group]);
|
||||
});
|
||||
});
|
||||
|
||||
// No React Native renderer in the test env: the composer is checked for the
|
||||
// wiring.
|
||||
describe('composer suggestions wiring', () => {
|
||||
const src = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
|
||||
|
||||
it('merges contacts, directory people and server hits', () => {
|
||||
expect(src).toMatch(/mergeRecipientSuggestions\(\{\s*local: getAutocomplete\(q, 16\),/);
|
||||
expect(src).toMatch(/directory: lookupDirectory\(q, 16\),/);
|
||||
expect(src).toMatch(/server: serverHits\?\.query === q \? serverHits\.results : \[\],/);
|
||||
expect(src).toMatch(/if \(directoryStatus === 'idle' && !loginMovedFrom\(ownerLoginRef\.current\)\) void fetchDirectory\(\);/);
|
||||
});
|
||||
|
||||
it('offers "Search the server" when a Sent folder is known, on the composer\'s login only', () => {
|
||||
expect(src).toMatch(/const results = await searchRecipients\(q\);/);
|
||||
expect(src).toMatch(/if \(!q \|\| searchingServer \|\| loginMovedFrom\(ownerLoginRef\.current\)\) return;/);
|
||||
expect(src).toMatch(/onSearchServer=\{canSearchServer \?/);
|
||||
expect(src).toMatch(/t\('email_composer\.autocomplete_search_server', 'Search the server'\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RecipientSuggestion } from '../stores/contacts-store';
|
||||
|
||||
// The composer's recipient suggestions, in the webmail's order
|
||||
// (stores/contact-store.ts getAutocomplete, email-composer.tsx
|
||||
// handleServerSearch): contacts and groups, then people in the company
|
||||
// directory (RFC 9670 principals), then recent recipients from the Sent
|
||||
// folder, then what an on-demand search of the Sent folder found. An address
|
||||
// shows once, first where it comes up; a directory entry lends its name to a
|
||||
// contact suggestion that has none.
|
||||
|
||||
export interface SuggestionSources {
|
||||
/** getAutocomplete's answer: contacts and groups first, then recent recipients. */
|
||||
local: RecipientSuggestion[];
|
||||
/** Whether a local suggestion is a contact or a group (the rest are recent recipients). */
|
||||
isContact: (s: RecipientSuggestion) => boolean;
|
||||
/** lookupDirectory's answer. */
|
||||
directory: RecipientSuggestion[];
|
||||
/** searchRecipients' answer for the same query, once asked for. */
|
||||
server: RecipientSuggestion[];
|
||||
/** Addresses already on the message (lowercase); groups are always offered. */
|
||||
exclude: ReadonlySet<string>;
|
||||
/** How many contact, directory and recent suggestions to show. Server hits come on top. */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export function mergeRecipientSuggestions(src: SuggestionSources): RecipientSuggestion[] {
|
||||
const out: RecipientSuggestion[] = [];
|
||||
const seen = new Map<string, RecipientSuggestion>();
|
||||
const offer = (s: RecipientSuggestion): boolean => {
|
||||
if (s.group) {
|
||||
out.push(s);
|
||||
return true;
|
||||
}
|
||||
const key = s.email.trim().toLowerCase();
|
||||
if (!key || seen.has(key) || src.exclude.has(key)) return false;
|
||||
const copy = { ...s };
|
||||
seen.set(key, copy);
|
||||
out.push(copy);
|
||||
return true;
|
||||
};
|
||||
|
||||
const contacts = src.local.filter((s) => src.isContact(s));
|
||||
const recent = src.local.filter((s) => !src.isContact(s));
|
||||
for (const s of contacts) offer(s);
|
||||
for (const s of src.directory) {
|
||||
const known = seen.get(s.email.trim().toLowerCase());
|
||||
if (known) {
|
||||
if (!known.name && s.name) known.name = s.name;
|
||||
continue;
|
||||
}
|
||||
offer(s);
|
||||
}
|
||||
for (const s of recent) offer(s);
|
||||
const shown = out.slice(0, src.limit);
|
||||
// What fell past the limit may come back from the server search.
|
||||
const shownKeys = new Set(shown.filter((s) => !s.group).map((s) => s.email.trim().toLowerCase()));
|
||||
for (const s of src.server) {
|
||||
const key = s.email.trim().toLowerCase();
|
||||
if (!key || shownKeys.has(key) || src.exclude.has(key)) continue;
|
||||
shownKeys.add(key);
|
||||
shown.push({ ...s });
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
||||
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
||||
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
|
||||
Users, Tag, Type, Highlighter, Trash2,
|
||||
Users, Tag, Type, Highlighter, Trash2, Search,
|
||||
} from '../components/icons';
|
||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient,
|
||||
} from '../lib/recipients';
|
||||
import { toUnicodeEmail } from '../lib/idn';
|
||||
import { mergeRecipientSuggestions } from '../lib/recipient-suggestions';
|
||||
import {
|
||||
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
|
||||
} from '../lib/reply-identity';
|
||||
@@ -250,17 +251,21 @@ function initialsOf(name: string, email: string): string {
|
||||
}
|
||||
|
||||
function SuggestionList({
|
||||
suggestions, onPick, onPressIn,
|
||||
suggestions, onPick, onPressIn, onSearchServer, searchingServer,
|
||||
}: {
|
||||
suggestions: RecipientSuggestion[];
|
||||
onPick: (s: RecipientSuggestion) => void;
|
||||
onPressIn?: () => void;
|
||||
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
|
||||
onSearchServer?: () => void;
|
||||
searchingServer?: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return (
|
||||
<View style={styles.suggestionBox}>
|
||||
<ScrollView style={styles.suggestionScroll} keyboardShouldPersistTaps="always" nestedScrollEnabled>
|
||||
{suggestions.map((s, i) => (
|
||||
<Pressable
|
||||
key={`${s.group?.id ?? s.email}-${i}`}
|
||||
@@ -287,6 +292,32 @@ function SuggestionList({
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
{onSearchServer && (
|
||||
<Pressable
|
||||
onPressIn={onPressIn}
|
||||
onPress={() => { if (!searchingServer) onSearchServer(); }}
|
||||
disabled={searchingServer}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
|
||||
style={({ pressed }) => [
|
||||
styles.suggestionRow,
|
||||
suggestions.length > 0 && styles.suggestionSearchRowDivided,
|
||||
pressed && styles.suggestionRowPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.suggestionSearchIcon}>
|
||||
{searchingServer
|
||||
? <ActivityIndicator size="small" color={c.textSecondary} />
|
||||
: <Search size={16} color={c.textSecondary} />}
|
||||
</View>
|
||||
<Text style={styles.suggestionSearchText} numberOfLines={1}>
|
||||
{searchingServer
|
||||
? t('email_composer.autocomplete_searching', 'Searching...')
|
||||
: t('email_composer.autocomplete_search_server', 'Search the server')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -740,6 +771,22 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients);
|
||||
const contactsVersion = useContactsStore((s) => s.contacts);
|
||||
const recentVersion = useContactsStore((s) => s.recentRecipients);
|
||||
const findContactByEmail = useContactsStore((s) => s.findContactByEmail);
|
||||
const lookupDirectory = useContactsStore((s) => s.lookupDirectory);
|
||||
const directoryVersion = useContactsStore((s) => s.directory);
|
||||
const directoryStatus = useContactsStore((s) => s.directoryStatus);
|
||||
const fetchDirectory = useContactsStore((s) => s.fetchDirectory);
|
||||
const searchRecipients = useContactsStore((s) => s.searchRecipients);
|
||||
// A known Sent folder makes the on-demand server search worth offering.
|
||||
const canSearchServer = useContactsStore((s) => s.sentMailboxId != null);
|
||||
const [serverHits, setServerHits] = React.useState<{ query: string; results: RecipientSuggestion[] } | null>(null);
|
||||
const [searchingServer, setSearchingServer] = React.useState(false);
|
||||
|
||||
// People in the company directory are suggested too (webmail loads the
|
||||
// directory at sign-in); the store keeps one load per login.
|
||||
React.useEffect(() => {
|
||||
if (directoryStatus === 'idle' && !loginMovedFrom(ownerLoginRef.current)) void fetchDirectory();
|
||||
}, [directoryStatus, fetchDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id);
|
||||
@@ -759,12 +806,32 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const suggestions = React.useMemo<RecipientSuggestion[]>(() => {
|
||||
const q = suggestionQuery.trim();
|
||||
if (q.length < 1) return [];
|
||||
return getAutocomplete(q, 16)
|
||||
.filter((s) => s.group || !alreadySelected.has(s.email.toLowerCase()))
|
||||
.slice(0, 8);
|
||||
// contactsVersion/recentVersion re-run the lookup when the store loads.
|
||||
return mergeRecipientSuggestions({
|
||||
local: getAutocomplete(q, 16),
|
||||
isContact: (s) => !!s.group || !!findContactByEmail(s.email),
|
||||
directory: lookupDirectory(q, 16),
|
||||
server: serverHits?.query === q ? serverHits.results : [],
|
||||
exclude: alreadySelected,
|
||||
limit: 8,
|
||||
});
|
||||
// contactsVersion/recentVersion/directoryVersion re-run the lookup when the store loads.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [suggestionQuery, alreadySelected, getAutocomplete, contactsVersion, recentVersion]);
|
||||
}, [suggestionQuery, alreadySelected, getAutocomplete, findContactByEmail, lookupDirectory, serverHits, contactsVersion, recentVersion, directoryVersion]);
|
||||
|
||||
// On demand: search the Sent folder on the server for recipients matching
|
||||
// what is typed, and add new ones below the suggestions (webmail
|
||||
// handleServerSearch). Only for the login the message is written in.
|
||||
const searchServer = async () => {
|
||||
const q = suggestionQuery.trim();
|
||||
if (!q || searchingServer || loginMovedFrom(ownerLoginRef.current)) return;
|
||||
setSearchingServer(true);
|
||||
try {
|
||||
const results = await searchRecipients(q);
|
||||
if (!loginMovedFrom(ownerLoginRef.current)) setServerHits({ query: q, results });
|
||||
} finally {
|
||||
setSearchingServer(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setterFor = (field: Field) =>
|
||||
field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients;
|
||||
@@ -2685,13 +2752,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
textContentType="none"
|
||||
/>
|
||||
</View>
|
||||
{activeField === field && suggestions.length > 0 && (
|
||||
{activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
|
||||
<SuggestionList
|
||||
suggestions={suggestions}
|
||||
onPick={pickSuggestion}
|
||||
onPressIn={() => {
|
||||
isPickingSuggestion.current = true;
|
||||
}}
|
||||
onSearchServer={canSearchServer ? () => {
|
||||
// No pick: a later blur turns the typed text into a chip again.
|
||||
setTimeout(() => { isPickingSuggestion.current = false; }, 250);
|
||||
void searchServer();
|
||||
} : undefined}
|
||||
searchingServer={searchingServer}
|
||||
/>
|
||||
)}
|
||||
{field === 'to' && !ccVisible && (
|
||||
@@ -3554,6 +3627,10 @@ function makeStyles(c: ThemePalette) {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
suggestionRowPressed: { backgroundColor: c.surfaceHover },
|
||||
suggestionScroll: { maxHeight: 320 },
|
||||
suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight },
|
||||
suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||
suggestionAvatar: {
|
||||
width: 32, height: 32,
|
||||
borderRadius: 16,
|
||||
|
||||
Reference in New Issue
Block a user