diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index 88cc4fefd6..fcb7111b60 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -740,6 +740,9 @@ export function NotificationContent({ notification.type === 'followed' || notification.type === 'starred' ? actorsCount - 1 : 0; + // Two people are both named; "and N others" is kept for three or more. + const secondActor = + otherActors === 1 && actors[1]?.id !== actor.id ? actors[1] : undefined; const grouped = summary && (actorsCount > 1 || @@ -1140,14 +1143,33 @@ export function NotificationContent({ > {actor.name} {' '} - {otherActors > 0 && ( - - {`and ${formatCount(otherActors)} ${otherActors === 1 ? 'other' : 'others'} `} - + {secondActor ? ( + <> + and{' '} + + { + secondActor.name + } + {' '} + + ) : ( + otherActors > 0 && ( + + {`and ${formatCount(otherActors)} ${otherActors === 1 ? 'other' : 'others'} `} + + ) )} ) : ( diff --git a/tests/Unit/NotificationIndex.test.tsx b/tests/Unit/NotificationIndex.test.tsx index be2fdc8bd8..4d517de7fa 100644 --- a/tests/Unit/NotificationIndex.test.tsx +++ b/tests/Unit/NotificationIndex.test.tsx @@ -117,7 +117,7 @@ const previewedNames = (body: string): number => const other = { ...actor, id: 'other', name: 'Other', handle: 'other' }; - test('separates last-visit activity without a border under the preceding notification', async () => { +test('separates last-visit activity without a border under the preceding notification', async () => { const body = await render([ { ...notification, read_at: '2026-10-16T12:00:00.000000Z' }, { @@ -136,7 +136,7 @@ const other = { ...actor, id: 'other', name: 'Other', handle: 'other' }; assert.match(after, /class="min-w-0 flex-1 py-3 pr-4 border-b"/); }); - test('colors each notification rail by actor and ends it in a dot', async () => { +test('colors each notification rail by actor and ends it in a dot', async () => { const nextActor = { ...other, lane: 5, is_followed: false }; const body = await render([ notification, @@ -156,7 +156,7 @@ const other = { ...actor, id: 'other', name: 'Other', handle: 'other' }; assert.match(body, /border-\(--lane\)/); }); - test('renders a code block highlighted in a notification', async () => { +test('renders a code block highlighted in a notification', async () => { const body = await render([notification]); assert.match(body, /]*>return<\/span>/); @@ -167,7 +167,7 @@ const other = { ...actor, id: 'other', name: 'Other', handle: 'other' }; }); for (const isFollowing of [false, true]) { - test(`keeps ${isFollowing ? 'unfollow' : 'follow-back'} separate from highlighted notification links`, async () => { + test(`keeps ${isFollowing ? 'unfollow' : 'follow-back'} separate from highlighted notification links`, async () => { const followed = { ...notification, id: 'follow-notification', @@ -195,7 +195,7 @@ for (const isFollowing of [false, true]) { }); } - test('shows the image of a starred image-only commit beside who starred it', async () => { +test('shows the image of a starred image-only commit beside who starred it', async () => { const starred = { ...notification, type: 'starred', @@ -225,7 +225,44 @@ for (const isFollowing of [false, true]) { assert.doesNotMatch(body, /]*>(?:]*>)*(?:<\/div>)+<\/a>/); }); - test('links the actor to their profile apart from the commit link', async () => { +for (const type of ['followed', 'starred']) { + test(`names both people of a ${type} group of two`, async () => { + const body = ( + await render([ + { + ...notification, + type, + actors: [actor, other], + actors_count: 2, + } as NotificationGroup, + ]) + ).replaceAll('', ''); + + assert.match( + body, + /<\/a> and ]*href="\/@other"[^>]*>Other<\/a> /, + ); + assert.doesNotMatch(body, /1 other/); + }); + + test(`counts the others of a ${type} group of three`, async () => { + const body = ( + await render([ + { + ...notification, + type, + actors: [actor, other, { ...other, id: 'third' }], + actors_count: 3, + } as NotificationGroup, + ]) + ).replaceAll('', ''); + + assert.match(body, /and 2 others /); + assert.doesNotMatch(body, /href="\/@other"[^>]*>Other<\/a>/); + }); +} + +test('links the actor to their profile apart from the commit link', async () => { const starred = { ...notification, type: 'starred' } as NotificationGroup; const body = await render([starred]); const links = [...body.matchAll(/]*>/g)].map((match) => match[0]); @@ -241,14 +278,14 @@ for (const isFollowing of [false, true]) { assert.match(body, /starred your commit<\/a>/); }); - test('previews the person of a single reply from the avatar and the name', async () => { +test('previews the person of a single reply from the avatar and the name', async () => { const body = await render([notification]); assert.equal(previewedAvatars(body), 1); assert.equal(previewedNames(body), 1); }); - test('previews the person of a single star from the avatar and the name', async () => { +test('previews the person of a single star from the avatar and the name', async () => { const body = await render([ { ...notification, type: 'starred' } as NotificationGroup, ]); @@ -258,7 +295,7 @@ for (const isFollowing of [false, true]) { }); for (const type of ['replied', 'starred']) { - test(`leaves the header of a group of ${type} notifications without previews`, async () => { + test(`leaves the header of a group of ${type} notifications without previews`, async () => { const body = await render([ { ...notification, @@ -275,7 +312,7 @@ for (const type of ['replied', 'starred']) { } for (const type of ['replied', 'starred']) { - test(`previews the person of an expanded ${type} notification`, async () => { + test(`previews the person of an expanded ${type} notification`, async () => { const body = await renderItem({ ...notification, type, @@ -292,7 +329,7 @@ const avatarLink = const nestedLink = /).)* { + test(`links the avatar of a single ${type} notification to the profile`, async () => { const body = await render([ { ...notification, type } as NotificationGroup, ]); @@ -302,7 +339,7 @@ for (const type of ['replied', 'starred']) { }); } - test('leaves the avatar of a single follow unlinked, as the row already opens the profile', async () => { +test('leaves the avatar of a single follow unlinked, as the row already opens the profile', async () => { const body = await render([ { ...notification, @@ -317,7 +354,7 @@ for (const type of ['replied', 'starred']) { assert.doesNotMatch(body, avatarLink); }); - test('links an expanded reply to its author and to the reply', async () => { +test('links an expanded reply to its author and to the reply', async () => { const body = await renderItem(notification); assert.match(body, avatarLink); @@ -336,7 +373,7 @@ for (const type of ['replied', 'starred']) { assert.doesNotMatch(body, nestedLink); }); - test('keeps an expanded star as one link to the profile', async () => { +test('keeps an expanded star as one link to the profile', async () => { const body = await renderItem({ ...notification, type: 'starred', @@ -346,7 +383,7 @@ for (const type of ['replied', 'starred']) { assert.match(body, /]*href="\/@mnapoli"/); }); - test('says which level and rank a level-up reached, linking to the profile', async () => { +test('says which level and rank a level-up reached, linking to the profile', async () => { const leveledUp = { ...notification, id: 'level',