Skip to content

Commit 97419f9

Browse files
authored
Merge pull request Expensify#88372 from ryntgh/issue/85984
fix: First @mention disappears when adding a second mention at the beginning of text
2 parents 34c1fc6 + 25f8c87 commit 97419f9

2 files changed

Lines changed: 289 additions & 2 deletions

File tree

src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,67 @@ function SuggestionMention({
186186
*/
187187
const insertSelectedMention = useCallback(
188188
(highlightedMentionIndexInner: number) => {
189+
const isPrivateDomainShortMention = (token: string): boolean => {
190+
if (!token.startsWith('@')) {
191+
return false;
192+
}
193+
const currentLogin = currentUserPersonalDetails.login ?? '';
194+
return Object.values(personalDetails ?? {}).some(
195+
(detail) =>
196+
!!detail?.login &&
197+
areEmailsFromSamePrivateDomain(detail.login, currentLogin) &&
198+
`@${formatLoginPrivateDomain(detail.login, detail.login)}`.toLowerCase() === token.toLowerCase(),
199+
);
200+
};
201+
202+
const isCompleteAtMention = (token: string): boolean => {
203+
if (!token.startsWith('@')) {
204+
return false;
205+
}
206+
const withoutAt = token.slice(1);
207+
return (
208+
Str.isValidEmail(withoutAt) ||
209+
token.toLowerCase() === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.toLowerCase() ||
210+
Str.isValidPhoneFormat(withoutAt) ||
211+
isPrivateDomainShortMention(token)
212+
);
213+
};
214+
215+
const findTrailingMentionIndex = (scannedMention: string, minIndex: number): number => {
216+
for (let i = scannedMention.length - 1; i >= minIndex; i--) {
217+
const trigger = scannedMention[i];
218+
if (trigger !== '@' && trigger !== '#') {
219+
continue;
220+
}
221+
222+
const token = scannedMention.slice(i);
223+
if (trigger === '@' && isCompleteAtMention(token)) {
224+
return i;
225+
}
226+
if (trigger === '#' && isValidRoomName(token)) {
227+
return i;
228+
}
229+
}
230+
return -1;
231+
};
232+
189233
const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex);
190234
const mentionObject = suggestionValues.suggestedMentions.at(highlightedMentionIndexInner);
191235
if (!mentionObject || highlightedMentionIndexInner === -1) {
192236
return;
193237
}
194238

195239
const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType);
196-
const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix));
240+
const scannedMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix));
241+
const typedMentionLength = suggestionValues.prefixType.length + suggestionValues.mentionPrefix.length;
242+
243+
let originalMention = scannedMention;
244+
if (scannedMention.length > typedMentionLength) {
245+
const trailingStart = findTrailingMentionIndex(scannedMention, typedMentionLength);
246+
if (trailingStart !== -1) {
247+
originalMention = scannedMention.slice(0, trailingStart);
248+
}
249+
}
197250

198251
// We split trailing dot from the mention token so selecting `@a.` can become `@adam.`
199252
// (preserve sentence punctuation) instead of consuming the `.` into the replacement.
@@ -230,7 +283,19 @@ function SuggestionMention({
230283
shouldShowSuggestionMenu: false,
231284
}));
232285
},
233-
[value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, suggestionValues.prefixType, getMentionCode, updateComment, setSelection, suggestionValues.mentionPrefix],
286+
[
287+
value,
288+
suggestionValues.atSignIndex,
289+
suggestionValues.suggestedMentions,
290+
suggestionValues.prefixType,
291+
getMentionCode,
292+
updateComment,
293+
setSelection,
294+
suggestionValues.mentionPrefix,
295+
currentUserPersonalDetails.login,
296+
personalDetails,
297+
formatLoginPrivateDomain,
298+
],
234299
);
235300

236301
/**

tests/unit/SuggestionMentionTest.tsx

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,226 @@ describe('SuggestionMention', () => {
324324
expect(mentions.map((mention) => mention.handle)).toEqual(['ua@example.com', 'ub@example.com', 'uc@example.com']);
325325
});
326326
});
327+
328+
it('preserves the first mention when a second mention is inserted before it', async () => {
329+
mockPersonalDetails = {};
330+
mockPersonalDetails[2] = {
331+
accountID: 2,
332+
login: 'adam@example.com',
333+
firstName: 'Adam',
334+
lastName: 'Tester',
335+
};
336+
mockPersonalDetails[3] = {
337+
accountID: 3,
338+
login: 'bob@example.com',
339+
firstName: 'Bob',
340+
lastName: 'Tester',
341+
};
342+
343+
const updateComment = jest.fn();
344+
const {setSelection} = renderSuggestionMention('@b@adam@example.com ', updateComment, {start: 2, end: 2});
345+
346+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
347+
const {onSelect} = getLastMentionSuggestionsProps();
348+
349+
act(() => onSelect(0));
350+
351+
expect(updateComment).toHaveBeenCalledWith('@bob@example.com @adam@example.com ', true);
352+
expect(setSelection).toHaveBeenCalledWith({start: 17, end: 17});
353+
});
354+
355+
it('preserves a trailing @here mention when inserting a new mention before it', async () => {
356+
mockPersonalDetails = {};
357+
mockPersonalDetails[2] = {
358+
accountID: 2,
359+
login: 'adam@example.com',
360+
firstName: 'Adam',
361+
lastName: 'Tester',
362+
};
363+
364+
const updateComment = jest.fn();
365+
const {setSelection} = renderSuggestionMention('@adam@here', updateComment, {start: 5, end: 5});
366+
367+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
368+
const {onSelect} = getLastMentionSuggestionsProps();
369+
370+
act(() => onSelect(0));
371+
372+
expect(updateComment).toHaveBeenCalledWith('@adam@example.com @here', true);
373+
expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18});
374+
});
375+
376+
it('preserves a trailing phone number mention when inserting a new mention before it', async () => {
377+
mockPersonalDetails = {};
378+
mockPersonalDetails[2] = {
379+
accountID: 2,
380+
login: 'adam@example.com',
381+
firstName: 'Adam',
382+
lastName: 'Tester',
383+
};
384+
385+
const updateComment = jest.fn();
386+
const {setSelection} = renderSuggestionMention('@adam@+14404589784', updateComment, {start: 5, end: 5});
387+
388+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
389+
const {onSelect} = getLastMentionSuggestionsProps();
390+
391+
act(() => onSelect(0));
392+
393+
expect(updateComment).toHaveBeenCalledWith('@adam@example.com @+14404589784', true);
394+
expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18});
395+
});
396+
397+
it('preserves a trailing private domain short mention when inserting a new mention before it', async () => {
398+
mockPersonalDetails = {};
399+
mockPersonalDetails[2] = {
400+
accountID: 2,
401+
login: 'adam@example.com',
402+
firstName: 'Adam',
403+
lastName: 'Tester',
404+
};
405+
mockPersonalDetails[3] = {
406+
accountID: 3,
407+
login: 'charlie@company.com',
408+
firstName: 'Charlie',
409+
lastName: 'Tester',
410+
};
411+
412+
// Override to a private domain so charlie@company.com shows as @charlie in the composer
413+
mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 1, login: 'current@company.com'});
414+
415+
const updateComment = jest.fn();
416+
const {setSelection} = renderSuggestionMention('@adam@charlie', updateComment, {start: 5, end: 5});
417+
418+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
419+
const {onSelect} = getLastMentionSuggestionsProps();
420+
421+
act(() => onSelect(0));
422+
423+
expect(updateComment).toHaveBeenCalledWith('@adam@example.com @charlie', true);
424+
expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18});
425+
});
426+
427+
it('preserves a trailing #room mention when inserting a new mention before it', async () => {
428+
mockPersonalDetails = {};
429+
mockPersonalDetails[2] = {
430+
accountID: 2,
431+
login: 'adam@example.com',
432+
firstName: 'Adam',
433+
lastName: 'Tester',
434+
};
435+
436+
const updateComment = jest.fn();
437+
const {setSelection} = renderSuggestionMention('@adam#admins', updateComment, {start: 5, end: 5});
438+
439+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
440+
const {onSelect} = getLastMentionSuggestionsProps();
441+
442+
act(() => onSelect(0));
443+
444+
expect(updateComment).toHaveBeenCalledWith('@adam@example.com #admins', true);
445+
expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18});
446+
});
447+
448+
it('removes junk between the new mention and a trailing complete mention', async () => {
449+
mockPersonalDetails = {};
450+
mockPersonalDetails[2] = {
451+
accountID: 2,
452+
login: 'alice@example.com',
453+
firstName: 'Alice',
454+
lastName: 'Tester',
455+
};
456+
457+
const updateComment = jest.fn();
458+
// "@alice@exam@alice@example.com" — cursor at index 6 (after "@alice").
459+
// The user partially typed "alice" before a stale/duplicate mention.
460+
// Inserting the full mention should drop the "@exam" junk and keep the trailing mention.
461+
const {setSelection} = renderSuggestionMention('@alice@exam@alice@example.com', updateComment, {start: 6, end: 6});
462+
463+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
464+
const {onSelect} = getLastMentionSuggestionsProps();
465+
466+
act(() => onSelect(0));
467+
468+
expect(updateComment).toHaveBeenCalledWith('@alice@example.com @alice@example.com', true);
469+
expect(setSelection).toHaveBeenCalledWith({start: 19, end: 19});
470+
});
471+
472+
it('removes junk between the new mention and a trailing private domain short mention', async () => {
473+
mockPersonalDetails = {};
474+
mockPersonalDetails[2] = {
475+
accountID: 2,
476+
login: 'bob@company.com',
477+
firstName: 'Bob',
478+
lastName: 'Tester',
479+
};
480+
mockPersonalDetails[3] = {
481+
accountID: 3,
482+
login: 'charlie@company.com',
483+
firstName: 'Charlie',
484+
lastName: 'Tester',
485+
};
486+
487+
// Override to a private domain so charlie@company.com shows as @charlie in the composer
488+
mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 1, login: 'current@company.com'});
489+
490+
const updateComment = jest.fn();
491+
// "@b@comp@charlie" — cursor at index 2 (after "@b").
492+
// Inserting the full @bob mention should drop the "@comp" junk and keep the trailing @charlie short mention.
493+
const {setSelection} = renderSuggestionMention('@b@comp@charlie', updateComment, {start: 2, end: 2});
494+
495+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
496+
const {onSelect} = getLastMentionSuggestionsProps();
497+
498+
act(() => onSelect(0));
499+
500+
expect(updateComment).toHaveBeenCalledWith('@bob @charlie', true);
501+
expect(setSelection).toHaveBeenCalledWith({start: 5, end: 5});
502+
});
503+
504+
it('removes junk between the new mention and a trailing phone number mention', async () => {
505+
mockPersonalDetails = {};
506+
mockPersonalDetails[2] = {
507+
accountID: 2,
508+
login: 'adam@example.com',
509+
firstName: 'Adam',
510+
lastName: 'Tester',
511+
};
512+
513+
const updateComment = jest.fn();
514+
// "@adam@exam@+14404589784" — cursor at index 5 (after "@adam").
515+
// Inserting the full mention should drop the "@exam" junk and keep the trailing phone number mention.
516+
const {setSelection} = renderSuggestionMention('@adam@exam@+14404589784', updateComment, {start: 5, end: 5});
517+
518+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
519+
const {onSelect} = getLastMentionSuggestionsProps();
520+
521+
act(() => onSelect(0));
522+
523+
expect(updateComment).toHaveBeenCalledWith('@adam@example.com @+14404589784', true);
524+
expect(setSelection).toHaveBeenCalledWith({start: 18, end: 18});
525+
});
526+
527+
it('removes junk between the new mention and a trailing room mention', async () => {
528+
mockPersonalDetails = {};
529+
mockPersonalDetails[2] = {
530+
accountID: 2,
531+
login: 'alice@example.com',
532+
firstName: 'Alice',
533+
lastName: 'Tester',
534+
};
535+
536+
const updateComment = jest.fn();
537+
// "@alice@exam#admins" — cursor at index 6 (after "@alice").
538+
// Inserting the full mention should drop the "@exam" junk and keep the trailing #admins room mention.
539+
const {setSelection} = renderSuggestionMention('@alice@exam#admins', updateComment, {start: 6, end: 6});
540+
541+
await waitFor(() => expect(mockMentionSuggestionsSpy).toHaveBeenCalled());
542+
const {onSelect} = getLastMentionSuggestionsProps();
543+
544+
act(() => onSelect(0));
545+
546+
expect(updateComment).toHaveBeenCalledWith('@alice@example.com #admins', true);
547+
expect(setSelection).toHaveBeenCalledWith({start: 19, end: 19});
548+
});
327549
});

0 commit comments

Comments
 (0)