diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index ddde62a..47d4125 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -2013,10 +2013,12 @@ private function statusMessage(array $all, ?User $viewer): ?Message } /** - * What the status line says under the message: staged, pushed or pulled (with when, with one person), or who - * in an org has pulled it. + * What the status line says under the message, as the web words it: staged, pushed or pulled (with when, with one + * person). In an org it's "pulled by" and the avatars of who pulled it (see partials/pulled-by), or "everyone" + * once all of more than three have, with the second dot of your glyph in the first one's lane. Someone else's + * message keeps its time in front, and says nothing more until someone pulls it. * - * @return array{state: string, text: string} + * @return array{state: string, text: string, pulledBy?: list, partnerLane?: int|string} */ private function statusOf(Message $message): array { @@ -2025,11 +2027,22 @@ private function statusOf(Message $message): array } if ($this->isOrg()) { - $pulled = array_values(array_filter($this->thread->members ?? [], fn (array $member): bool => $member['user']->id !== $message->userId && $member['read_through_id'] >= $message->id)); + $mine = $message->userId === $this->me()?->id; + $readers = array_filter($this->thread->members ?? [], fn (array $member): bool => $member['user']->id !== $message->userId); + $pulled = array_values(array_column(array_filter($readers, fn (array $member): bool => $member['read_through_id'] >= $message->id), 'user')); - return $pulled === [] - ? ['state' => 'pushed', 'text' => $message->time().' · pushed'] - : ['state' => 'pulled', 'text' => $message->time().' · pulled by '.implode(', ', array_map(fn (array $member): string => $member['user']->handle(), array_slice($pulled, 0, 5))).(count($pulled) > 5 ? ' +'.(count($pulled) - 5) : '')]; + if ($pulled === []) { + return ['state' => 'pushed', 'text' => $mine ? 'pushed' : $message->time()]; + } + + $everyone = count($pulled) === count($readers) && count($readers) > 3; + + return [ + 'state' => 'pulled', + 'text' => ($mine ? '' : $message->time().' · ').'pulled by'.($everyone ? ' everyone' : ''), + 'pulledBy' => $everyone ? [] : $pulled, + 'partnerLane' => $pulled[0]->lane(), + ]; } $pulledAt = $this->thread?->partnerReadAt; diff --git a/resources/views/native/partials/message-item.blade.php b/resources/views/native/partials/message-item.blade.php index acf2303..54c75a6 100644 --- a/resources/views/native/partials/message-item.blade.php +++ b/resources/views/native/partials/message-item.blade.php @@ -105,13 +105,16 @@ @if ($status !== null) @if ($mine) - @include('native.partials.message-status', ['status' => $status['state'], 'ref' => 'glyph-'.$key, 'lane' => $viewer?->lane(), 'partnerLane' => $partner?->lane()]) + @include('native.partials.message-status', ['status' => $status['state'], 'ref' => 'glyph-'.$key, 'lane' => $viewer?->lane(), 'partnerLane' => $status['partnerLane'] ?? $partner?->lane()]) @endif @if ($message->status === 'rejected') rejected · retry @else {{ $status['text'] }} + @if (($status['pulledBy'] ?? []) !== []) + @include('native.partials.pulled-by', ['people' => $status['pulledBy']]) + @endif @endif @elseif ($endsRun) diff --git a/resources/views/native/partials/pulled-by.blade.php b/resources/views/native/partials/pulled-by.blade.php new file mode 100644 index 0000000..e2bdc31 --- /dev/null +++ b/resources/views/native/partials/pulled-by.blade.php @@ -0,0 +1,23 @@ +{{-- + Who in an org pulled a message, as the web draws them after "pulled by": up to five 14pt avatars, each ringed in + the thread's surface and tucked 4pt under the next, then "+N" for the rest. A screen reader hears what's drawn: + the five handles and "and N others", where the web's label reads out every one of up to a hundred. + As in stacked-avatars, each avatar sits at a fixed offset; later siblings draw on top, so the last one is on top, + as on the web. +--}} +@php($shown = array_slice($people, 0, 5)) +@php($handles = implode(', ', array_map(fn (\App\Devnull\Data\User $person): string => $person->handle(), $shown)).(count($people) > 5 ? ' and '.\App\Devnull\Display::count(count($people) - 5, 'other', 'others') : '')) + + + @foreach ($shown as $index => $person) + + + @include('native.partials.avatar', ['user' => $person, 'size' => 14]) + + + @endforeach + + @if (count($people) > 5) + +{{ count($people) - 5 }} + @endif + diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 6f20619..eea047f 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -598,6 +598,103 @@ function pickerShows(TestableComponent $screen): bool finishRefreshes(Native::visit('/remotes/orgs/org-1'))->tap('author-2')->assertNavigatedTo('/remotes/users/ada'); }); +/** + * Someone in dev/null core, with their own avatar. + * + * @return array + */ +function pullerJson(string $handle): array +{ + return userJson(['id' => 'u-'.$handle, 'handle' => $handle, 'name' => ucfirst($handle), 'avatar_url' => "https://avatars.test/{$handle}"]); +} + +/** + * dev/null core's members: you, pulled through the message given, then everyone else by handle with theirs. + * + * @param array $pulledThrough + * @return list> + */ +function pullersJson(int $mine, array $pulledThrough): array +{ + return [ + ['user' => userJson(), 'role' => 'maintainer', 'read_through_id' => $mine], + ...array_map(fn (string $handle, int $through): array => ['user' => pullerJson($handle), 'role' => 'contributor', 'read_through_id' => $through], array_keys($pulledThrough), $pulledThrough), + ]; +} + +/** + * The words on a message's status line, in order, with the "+N" after the avatars but not the initials under them. + * + * @return list + */ +function statusTexts(TestableComponent $screen, int $id): array +{ + $isText = fn (array $node): bool => ($node['type'] ?? null) === 'text'; + $initials = rendersRef($screen, 'pulled-by-avatars-'.$id) ? findNodes(nodeByRef($screen, 'pulled-by-avatars-'.$id), $isText) : []; + $words = array_filter(findNodes(nodeByRef($screen, 'status-'.$id), $isText), fn (array $node): bool => ! in_array($node, $initials, true)); + + return array_values(array_map(fn (array $node): string => $node['props']['text'] ?? '', $words)); +} + +/** + * The avatars stacked after "pulled by", as the image URLs drawn bottom to top. + * + * @return list + */ +function pulledAvatars(TestableComponent $screen, int $id): array +{ + return array_map(fn (array $image): string => $image['props']['src'] ?? '', findNodes(nodeByRef($screen, 'pulled-by-avatars-'.$id), fn (array $node): bool => ($node['type'] ?? null) === 'image')); +} + +it('stacks the avatars of who in an org pulled your latest message, five of them and a count for the rest', function () { + serveOrgThread(orgThreadJson([ + orgMessageJson(['id' => 1, 'body' => 'standup in 10?']), + orgMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'on my way']), + ], ['members' => pullersJson(2, ['ada' => 2, 'grace' => 2, 'linus' => 2, 'margaret' => 2, 'ken' => 2, 'barbara' => 2, 'dennis' => 1])])); + + $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')); + + // Like your own messages anywhere, it goes without its time. Dennis hasn't pulled it yet. + expect(statusTexts($screen, 2))->toBe(['pulled by', '+1']) + ->and(pulledAvatars($screen, 2))->toBe(['https://avatars.test/ada', 'https://avatars.test/grace', 'https://avatars.test/linus', 'https://avatars.test/margaret', 'https://avatars.test/ken']) + ->and(nodeByRef($screen, 'pulled-by-2')['props']['a11y_label'] ?? null)->toBe('@ada, @grace, @linus, @margaret, @ken and 1 other'); +}); + +it("puts the time before who pulled someone else's message, you among them", function () { + serveOrgThread(orgThreadJson([ + orgMessageJson(['id' => 1, 'user_id' => 'u-grace', 'user' => pullerJson('grace'), 'body' => 'standup in 10?', 'created_at' => '2026-10-19T14:00:00Z']), + ], ['members' => pullersJson(1, ['grace' => 1, 'linus' => 1, 'ken' => 0])])); + + $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')); + + expect(statusTexts($screen, 1))->toBe(['14:00 · pulled by']) + ->and(pulledAvatars($screen, 1))->toBe([userJson()['avatar_url'], 'https://avatars.test/linus']) + ->and(nodeByRef($screen, 'pulled-by-1')['props']['a11y_label'] ?? null)->toBe('@local_dev, @linus'); +}); + +it('says everyone pulled a message once all of more than three have, as the web does', function (array $pulledThrough, array $words) { + serveOrgThread(orgThreadJson([orgMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'on my way'])], ['members' => pullersJson(2, $pulledThrough)])); + + $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')); + + expect(statusTexts($screen, 2))->toBe($words) + ->and(rendersRef($screen, 'pulled-by-2'))->toBe($words === ['pulled by']); +})->with([ + 'four of four' => [['ada' => 2, 'grace' => 2, 'linus' => 2, 'ken' => 2], ['pulled by everyone']], + 'three of three' => [['ada' => 2, 'grace' => 2, 'linus' => 2], ['pulled by']], +]); + +it("says pushed under your org message nobody has pulled, and only the time under someone else's", function (string $author, array $words) { + serveOrgThread(orgThreadJson([ + orgMessageJson(['id' => 1, 'user_id' => $author, 'user' => $author === RemotesViewerId ? userJson() : pullerJson('ada'), 'body' => 'standup in 10?', 'created_at' => '2026-10-19T14:00:00Z']), + ], ['members' => pullersJson(0, ['ada' => 0, 'grace' => 0])])); + + expect(statusTexts(finishRefreshes(Native::visit('/remotes/orgs/org-1')), 1))->toBe($words); +})->with([ + 'yours' => [RemotesViewerId, ['pushed']], + 'theirs' => ['u-ada', ['14:00']], +]); + it('stashes the conversation from its menu and goes back', function () { serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1]));