Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions app/NativeComponents/Conversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>, partnerLane?: int|string}
*/
private function statusOf(Message $message): array
{
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion resources/views/native/partials/message-item.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,16 @@
@if ($status !== null)
<native:row ref="status-{{ $key }}" class="gap-[6] items-center px-1">
@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')
<native:text font="mono" class="text-[11px] text-theme-destructive">rejected ·</native:text>
<native:text ref="retry-{{ $key }}" font="mono-medium" class="text-[11px] text-theme-destructive" @press="retry('{{ $message->clientId }}')">retry</native:text>
@else
<native:text font="mono" class="text-[11px] text-theme-ink-muted">{{ $status['text'] }}</native:text>
@if (($status['pulledBy'] ?? []) !== [])
@include('native.partials.pulled-by', ['people' => $status['pulledBy']])
@endif
@endif
</native:row>
@elseif ($endsRun)
Expand Down
23 changes: 23 additions & 0 deletions resources/views/native/partials/pulled-by.blade.php
Original file line number Diff line number Diff line change
@@ -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') : ''))
<native:row ref="pulled-by-{{ $key }}" class="gap-[3] items-center" :a11y-label="$handles">
<native:column ref="pulled-by-avatars-{{ $key }}" :width="(count($shown) - 1) * 10 + 16" :height="16">
@foreach ($shown as $index => $person)
<native:column class="absolute top-0 left-[{{ $index * 10 }}]">
<native:stack :width="16" :height="16" center class="rounded-full bg-theme-surface">
@include('native.partials.avatar', ['user' => $person, 'size' => 14])
</native:stack>
</native:column>
@endforeach
</native:column>
@if (count($people) > 5)
<native:text font="mono" class="text-[11px] text-theme-ink-muted">+{{ count($people) - 5 }}</native:text>
@endif
</native:row>
97 changes: 97 additions & 0 deletions tests/Feature/Screens/ConversationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>
*/
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<string, int> $pulledThrough
* @return list<array<string, mixed>>
*/
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<string>
*/
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<string>
*/
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]));

Expand Down
Loading