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',