diff --git a/app/Devnull/MessageMenu.php b/app/Devnull/MessageMenu.php index 5e1bf02b..900b291d 100644 --- a/app/Devnull/MessageMenu.php +++ b/app/Devnull/MessageMenu.php @@ -33,7 +33,7 @@ public static function items(Message $message, bool $answerable, ?bool $pinned, if ($answerable) { $items[] = self::item('reply', 'Reply', Lucide::Reply, "reply({$argument})", $key, $named); - $items[] = self::item('react', 'React', Lucide::SmilePlus, "reactFromMenu({$argument})", $key, $named); + $items[] = self::item('react', 'React', Lucide::SmilePlus, "openReactions({$argument})", $key, $named); } if ($message->body !== '') { diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index a91099b2..2302f411 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -71,12 +71,12 @@ * it's still staged or rejected when you come back to the thread, and one * still on its way when the app closed goes again when it next opens. A * toast says why devnull didn't take one, wherever you are by then, with a - * Retry of its own. Dragging a message sideways slides it out of the way - * with the finger, its React and Reply growing in where it was, and Pin - * for an org's maintainers; let go far enough and it stays open, or it - * springs back. A double tap toggles a heart. React shows the web's quick - * reactions in a bubble beside it, with More for the phone's own emoji - * picker. + * Retry of its own. Pulling a message toward its edge replies to it: Reply + * comes in from the other side, with a light buzz once it's far enough, + * and letting go there sets the composer replying to it; it springs back + * either way. A double tap toggles a heart. A long press opens its menu (see + * MessageMenu), whose React shows the web's quick reactions in a bubble + * above it, with More for the phone's own emoji picker. * * An org's pinned messages show as the web shows them: the latest in a bar * over the top of the thread, which opens them all in a sheet, and a pin @@ -132,19 +132,14 @@ class Conversation extends NativeComponent public const array QuickReactions = ['😂', '✅', '❤️']; /** - * The slot beside a bubble for its actions: React and Reply, and in an org room for Pin as well, whether it shows or - * not, as the web keeps it, so a pin arriving never rewraps a bubble. Then the gap between the slot and the bubble. + * How far a message is pulled toward its edge before letting go replies to it. Reply comes in over that distance from + * the other side of the thread. */ - public const int ActionsWidth = 64; - - public const int OrgActionsWidth = 98; - - public const int ActionsGap = 4; + public const int ReplyPull = 64; /** - * A drag slides a message out of the way by up to its actions' slot, which then takes the room the bubble left. A - * short bubble slides less, so all but the last 20 points of it past the thread's edge stay on screen, guessing its - * width from its longest line: its padding, then an average character of 15-point text. + * A short bubble follows the pull only so far, so all but the last 20 points of it past the thread's edge stay on + * screen, guessing its width from its longest line: its padding, then an average character of 15-point text. */ public const int BubblePadding = 24; @@ -154,11 +149,6 @@ class Conversation extends NativeComponent public const int LeastSlide = 16; - /** - * How long after a swipe brings a message's actions up a tap is taken as part of the swipe (see $actionsShownAt). - */ - public const float SwipeTapWindow = 0.75; - /** * The alert that asks before pinning, as the web's does, and its answers. */ @@ -275,22 +265,8 @@ class Conversation extends NativeComponent public ?int $seenPingId = null; /** - * The message whose React, Reply and Pin show beside it, as the web shows them on hover. Dragging a message aside - * shows them, and dragging it back, a tap anywhere in the thread, or opening another message's puts them away. - */ - public ?int $actionsForId = null; - - /** - * When a drag last brought a message's actions up. On Android a drag that ends inside the bubble or its row could - * also land as a tap on them, a moment later once the double tap has had its chance, which would put the actions - * straight away again; a tap that close behind the drag is the drag's own. - */ - public float $actionsShownAt = 0; - - /** - * Where each message sits sideways, shared with the gesture area that drags it (see panFor()). They're kept across - * renders, so the area moves the same value the row and its buttons follow, and a new resting place from here - * springs into place rather than jumping. + * How far each message is pulled sideways, shared with the gesture area that pulls it (see panFor()). They're kept + * across renders, so a message that comes in or changes while you pull another doesn't cut the pull short. * * @var array */ @@ -302,7 +278,7 @@ class Conversation extends NativeComponent public bool $sheetOpen = false; /** - * The message whose React shows the web's quick reactions in a bubble beside it. + * The message whose React shows the web's quick reactions in a bubble pointing at it. */ public ?int $reactionsFor = null; @@ -732,43 +708,8 @@ public function loadOlder(): void } /** - * A message let go after a drag, where it came to rest. The gesture area moves it with the finger toward its edge, - * someone else's to the left and your own to the right, and springs it open or shut on release by how far it went - * and how fast. Open, its actions stay up and any other message's go away; shut, its own go away. - */ - public function draggedMessage(int $id, float $x, float $y = 0): void - { - $message = $this->findMessage($id); - - if ($message === null || ! $this->takesActions($message)) { - return; - } - - $toward = ($message->userId === $this->me()?->id ? 1 : -1) * $x; - - if ($toward >= $this->slideOf($message) / 2) { - $this->actionsForId = $id; - $this->actionsShownAt = self::now(); - } elseif ($this->actionsForId === $id) { - $this->actionsForId = null; - } - } - - /** - * A tap anywhere in the thread puts away the actions showing beside a message. - */ - public function hideActions(): void - { - if (self::now() - $this->actionsShownAt < self::SwipeTapWindow) { - return; - } - - $this->actionsForId = null; - } - - /** - * React: the web's quick reactions and More, in a bubble beside the React that was pressed, above it unless there's - * no room. The message's options stay up underneath. + * React: the web's quick reactions and More, in a bubble above the message when React comes from its long-press + * menu, or beside React in the pinned sheet, and below either when there's no room above. */ public function openReactions(int $id): void { @@ -798,7 +739,6 @@ public function openPinned(): void return; } - $this->actionsForId = null; $this->reactionsFor = null; $this->reactsFromPinned = false; $this->sheetOpen = true; @@ -825,7 +765,6 @@ public function openEmojiPicker(int $id): void return; } - $this->actionsForId = null; $this->reactionsFor = null; $this->sheetOpen = false; $this->emojiPickerFor = $id; @@ -870,31 +809,19 @@ public function emojiPickerClosed(): void } } + /** + * Reply, from a message's long-press menu or the pinned sheet, or by pulling the message toward its edge past + * ReplyPull and letting go: the composer replies to it, and takes the keyboard if it hadn't already. + */ public function reply(int $id): void { $this->replyToId = $id; - $this->actionsForId = null; $this->reactionsFor = null; $this->sheetOpen = false; $this->reactsFromPinned = false; $this->focusField(onlyIfIdle: true); } - /** - * React, from the menu a long press opens: the quick reactions in their bubble when the message's options already - * show, so the bubble has React to point at, and otherwise the phone's emoji picker. - */ - public function reactFromMenu(int $id): void - { - if ($this->actionsForId === $id) { - $this->openReactions($id); - - return; - } - - $this->openEmojiPicker($id); - } - /** * Copy text, from the menu a long press opens: the message's text, as it was written, on the clipboard. A message * still being pushed is named by its client id. iOS says so in a toast, in the web's words. Android says so itself, @@ -938,8 +865,9 @@ public function togglePinnedText(int $id): void } /** - * Pin or Unpin beside a message, for an org's maintainers. Pinning asks first, as the web does, since everyone in - * the org sees what's pinned, whatever history they were given, until you say not to ask again. + * Pin message or Unpin message, from a message's long-press menu, for an org's maintainers. Pinning asks first, as + * the web does, since everyone in the org sees what's pinned, whatever history they were given, until you say not to + * ask again. */ public function togglePin(int $id): void { @@ -949,8 +877,6 @@ public function togglePin(int $id): void return; } - $this->actionsForId = null; - if ($this->isPinned($id)) { $this->unpinMessage($id); @@ -992,7 +918,7 @@ public function onAlertButton(string $label = '', ?string $id = null): void } /** - * Unpin, beside a pinned message in the thread or in the pinned sheet. The sheet closes once nothing is left in it. + * Unpin, from a pinned message's long-press menu or in the pinned sheet. The sheet closes once nothing is left in it. */ public function unpinMessage(int $id): void { @@ -1025,7 +951,7 @@ public function heart(int $id): void /** * Leaves the emoji on the message, or takes it back, at once. If devnull says no, it goes back as it was. Picked in - * the bubble, the bubble closes and the message's options go away. + * the bubble, the bubble closes. */ public function toggleReaction(int $id, string $emoji): void { @@ -1035,7 +961,6 @@ public function toggleReaction(int $id, string $emoji): void if ($this->reactionsFor !== null) { $this->reactionsFor = null; $this->reactsFromPinned = false; - $this->actionsForId = null; } if ($message === null || $message->isPending() || $message->isEvent() || $me === null || $this->thread?->access !== Thread::Open) { @@ -1843,7 +1768,6 @@ public function render(): View 'quickReactions' => $this->quickReactions(), 'canPush' => $thread !== null && $thread->access === Thread::Open, 'canPin' => $this->canPin(), - 'actionsWidth' => $this->actionsWidth(), 'pinnedIds' => array_flip(array_map(fn (Message $message): int => $message->id, $this->pinned)), 'longPins' => $this->longPins(), 'placeholder' => $this->isOrg() ? 'Message '.($thread->orgName ?? 'the org') : 'Message @'.$this->username, @@ -1855,45 +1779,34 @@ public function render(): View ]); } - private function actionsWidth(): int - { - return $this->isOrg() ? self::OrgActionsWidth : self::ActionsWidth; - } - /** - * Where a message rests sideways, as a value the gesture area drags and the row and its buttons follow: slid open - * toward its edge while its actions show, home otherwise. A message devnull doesn't have yet, or an org's event, - * doesn't move. + * How far a message is pulled sideways, as a value the gesture area moves and the message and its Reply follow. It + * rests at nothing, and the area springs it back there itself. A message devnull doesn't have yet, or an org's + * event, can't be pulled. */ - private function panFor(Message $message, bool $mine): ?SharedValue + private function panFor(Message $message): ?SharedValue { if ($message->isPending() || $message->isEvent()) { return null; } - $rest = $this->actionsForId === $message->id ? ($mine ? 1 : -1) * $this->slideOf($message) : 0; - $pan = $this->pans[$message->id] ??= SharedValue::make($rest); - $pan->setValue($rest); - - return $pan; + return $this->pans[$message->id] ??= SharedValue::make(); } /** - * How far a drag slides a message toward its edge: the room its actions take, or for a short bubble only as far as - * keeps some of it on screen (see BubblePadding). Images are 240 points wide, so they slide the whole way. + * How far the message itself follows a pull: all the way for most, but a short bubble only as far as keeps some of it + * on screen (see BubblePadding). Images are 240 points wide, so they go the whole way. */ private function slideOf(Message $message): int { - $room = $this->actionsWidth() + self::ActionsGap; - if ($message->body === '') { - return $room; + return self::ReplyPull; } $longest = max(array_map(mb_strlen(...), explode("\n", $message->body))); $bubble = self::BubblePadding + (int) round($longest * self::BubbleCharacterWidth); - return min($room, max(self::LeastSlide, $bubble - self::KeptOnScreen)); + return min(self::ReplyPull, max(self::LeastSlide, $bubble - self::KeptOnScreen)); } /** @@ -1998,8 +1911,8 @@ private function items(?User $viewer): array 'endsRun' => true, 'status' => $message === $status ? $this->statusOf($message) : null, 'author' => $message->user ?? $this->authorOf($message->userId), + 'pan' => $this->panFor($message), 'slide' => $this->slideOf($message), - 'pan' => $this->panFor($message, $message->userId === $viewer?->id), 'menu' => MessageMenu::items($message, $answerable, $pinned), 'photosMenu' => $message->images === [] || $message->body === '' ? [] : MessageMenu::items($message, $answerable, $pinned, named: false), ]; diff --git a/resources/views/native/partials/message-actions.blade.php b/resources/views/native/partials/message-actions.blade.php deleted file mode 100644 index f302067e..00000000 --- a/resources/views/native/partials/message-actions.blade.php +++ /dev/null @@ -1,51 +0,0 @@ -{{-- - React, Reply and Pin for the message you drag aside, right beside its bubble as the web shows them on hover: Pin - nearest it, then Reply, then React, with the web's icons. Every message keeps them, shrunk to nothing, and as the drag - slides the message aside each grows in over its share of the way, nearest the bubble first, following the finger - there and back. The slot never changes width: in an org it keeps room for Pin whether it shows or not, as the web's - does, so a pin arriving never rewraps a bubble. They only take taps once the message has settled open. React opens - the web's quick reactions in a bubble pointing at it (see quick-reactions). - - A pinned message's pin shows all the time, nearest the bubble, so the others come in beyond it without moving it. - For an org's maintainers it turns into Unpin, in the primary colour, while the others show; for everyone else it - only marks the message, as the web's does. ---}} -@php($isPinned = isset($pinnedIds[$message->id])) -@php($pinsHere = ! $isPinned && $canPin) -@php($fading = $mine ? ['react', 'reply', ...($pinsHere ? ['pin'] : [])] : [...($pinsHere ? ['pin'] : []), 'reply', 'react']) - - @if ($isPinned && ! $mine) - @include('native.partials.message-pin') - @endif - @php($share = $slide / max(1, count($fading))) - - @foreach ($fading as $action) - @php($nearness = $mine ? count($fading) - 1 - $loop->index : $loop->index) - @php($grown = $pan === null ? 0 : ($mine ? $pan->interpolate([$nearness * $share, ($nearness + 1) * $share], [0, 1]) : $pan->interpolate([-($nearness + 1) * $share, -$nearness * $share], [1, 0]))) - @php($press = match ($action) { 'reply' => 'reply', 'react' => 'openReactions', 'pin' => 'togglePin' }) - @php($icon = match ($action) { 'reply' => \App\Icons\Lucide::Reply, 'react' => \App\Icons\Lucide::SmilePlus, 'pin' => \App\Icons\Lucide::Pin }) - {{-- - Only an open message's actions take taps. A closed one's are drawn, so they grow in under the finger, but - shrunk or faded a button keeps its whole box for taps, and a tap beside a closed message would land on it. - --}} - @if ($showsActions) - - - - - - @else - - - - @endif - @endforeach - {{-- React's bubble points at React: the last button of theirs, the first of yours. --}} - @if ($reactionsFor === $message->id && ! $reactsFromPinned) - @include('native.partials.quick-reactions', ['anchor' => 'absolute top-0 bottom-0 w-[30] '.($mine ? 'left-0' : 'right-0')]) - @endif - - @if ($isPinned && $mine) - @include('native.partials.message-pin') - @endif - diff --git a/resources/views/native/partials/message-images.blade.php b/resources/views/native/partials/message-images.blade.php index 628e7b8e..e6c0bc12 100644 --- a/resources/views/native/partials/message-images.blade.php +++ b/resources/views/native/partials/message-images.blade.php @@ -1,11 +1,10 @@ {{-- - A message's images. A long press on them opens the message's menu, as on its bubble ($photosMenu), and a tap on one - puts away any actions showing, as a tap anywhere in the thread does. + A message's images. A long press on them opens the message's menu, as on its bubble ($photosMenu). --}} @if ($message->images !== []) @foreach ($message->images as $index => $image) - + @endforeach @endif diff --git a/resources/views/native/partials/message-item.blade.php b/resources/views/native/partials/message-item.blade.php index 88b7ae6e..77b67da2 100644 --- a/resources/views/native/partials/message-item.blade.php +++ b/resources/views/native/partials/message-item.blade.php @@ -5,34 +5,33 @@ emoji, yours outlined in your lane and a tap to take it back or add yours. A run ends in the time, or for the message the web gives a status, in that status. - Dragging it toward its edge slides it out of the way with the finger, partly off the screen, and React, Reply and, - for an org's maintainers, Pin grow in one after another where it was, as the web shows them on hover: someone else's - to the left, your own to the right. Let go past halfway, or with a flick, and it stays open; short of that it springs - back. The actions sit in a slot the bubble always keeps beside it, so nothing rewraps. Dragging it back, or a tap - anywhere in the thread, puts them away; a double tap toggles a heart. A pinned message keeps its pin beside it. A message still being pushed takes - none of them, and nothing reacts, replies or pins while the thread can't take a message. + Pulling it toward its edge replies to it: someone else's to the left and your own to the right. It follows the + finger that way only, partly off the screen, though a short bubble stops while some of it still shows. Reply slides + in from the other side of the thread as it grows, turns upright and fades in. At Conversation::ReplyPull the phone + buzzes lightly; let go there and the composer replies to it and takes the keyboard. It springs back either way. The + pull, the buzz and the spring back all run on the phone, and only the reply comes here. A double tap toggles a + heart. A pinned message keeps its pin beside it. A message still being pushed can't be pulled, and nothing replies + while the thread can't take a message. A long press on the bubble, the voice note or the images opens the message's menu (see App\Devnull\MessageMenu), - which lifts it out of the thread on iOS: Reply, React and Copy text, and Pin for an org's maintainers. The menu goes - on a pressable around the drawn bubble, so the lifted copy keeps the bubble's colour and padding. + which lifts it out of the thread on iOS: Reply, React and Copy text, and Pin or Unpin for an org's maintainers. The + menu goes on a pressable around the drawn bubble, so the lifted copy keeps the bubble's colour and padding. React's + quick reactions point at the bubble's outer end. --}} @php($key = $message->id > 0 ? $message->id : $message->clientId) @php($laneToken = \App\Devnull\Lane::token(($author ?? null)?->lane())) @php($replyAuthor = $message->replyTo === null ? null : ($message->replyTo['user_id'] === $viewer?->id ? 'You' : ($authors[$message->replyTo['user_id']]->name ?? 'Someone'))) @php($replyLane = $message->replyTo === null ? 'primary' : \App\Devnull\Lane::token(($authors[$message->replyTo['user_id']] ?? null)?->lane())) @php($replyable = ($canPush ?? false) && ! $message->isPending()) -@php($showsActions = $replyable && $actionsForId === $message->id) -{{-- The row only takes a tap while some message's actions show, so a tap beside a bubble draws no ripple otherwise. --}} -@if ($actionsForId !== null) - -@else +@php($pull = \App\NativeComponents\Conversation::ReplyPull) +@php($swipeHint = $replyable ? ($mine ? 'Swipe right to reply' : 'Swipe left to reply') : '') +@php($reactionsAnchor = 'absolute top-0 bottom-0 w-[44] '.($mine ? 'right-0' : 'left-0')) -@endif @if ($replyable) - + @endif {{-- A set width, not a max: a column that only hugs its bubble measures the text on one line and clips it. --}} - + @if ($isOrg && ! $mine && $startsRun && $author) @@ -49,15 +48,21 @@ @endif - {{-- An image-only message keeps its actions beside its images; otherwise they go beside the bubble. --}} + {{-- + An image-only message keeps its pin beside its images, and React's quick reactions point at them; otherwise + those go with the bubble. The bubble's row stops short of the far side, so a long message doesn't reach it. + --}} @if ($message->images !== [] && $message->body === '') - + @if ($mine) - @include('native.partials.message-actions') + @include('native.partials.message-pin') @endif @include('native.partials.message-images', ['photosMenu' => $menu]) @if (! $mine) - @include('native.partials.message-actions') + @include('native.partials.message-pin') + @endif + @if ($reactionsFor === $message->id && ! $reactsFromPinned) + @include('native.partials.quick-reactions', ['anchor' => $reactionsAnchor]) @endif @else @@ -67,10 +72,10 @@ @include('native.partials.message-voice') @if ($message->body !== '') - {{-- The bubble shrinks to wrap short of the slot for its actions, which keeps its width either way. --}} - + {{-- The bubble shrinks to wrap short of the far side, and of its pin when it has one. --}} + @if ($mine) - @include('native.partials.message-actions') + @include('native.partials.message-pin') @endif @if ($message->isPending()) @@ -79,14 +84,17 @@ @else - + @include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary', 'quoteRule' => $mine ? 'border-theme-on-'.$myColour : 'border-theme-lane-5', 'linkPrefixInk' => $mine ? 'text-theme-on-'.$myColour.'/70' : 'text-theme-ink-muted']) @endif @if (! $mine) - @include('native.partials.message-actions') + @include('native.partials.message-pin') + @endif + @if ($reactionsFor === $message->id && ! $reactsFromPinned) + @include('native.partials.quick-reactions', ['anchor' => $reactionsAnchor]) @endif @endif @@ -123,5 +131,20 @@ @if ($replyable) + {{-- + Reply, just past the far side of the thread, comes in with the finger as the message goes the other way: + growing from 40%, turning upright from a quarter turn and fading in, and in place once the pull reaches + ReplyPull. It's off the row, so it never lies over a bubble and follows the finger even where a short + bubble has stopped. + --}} + + + + + @endif diff --git a/resources/views/native/partials/message-pin.blade.php b/resources/views/native/partials/message-pin.blade.php index 1d803f4e..937ac9b1 100644 --- a/resources/views/native/partials/message-pin.blade.php +++ b/resources/views/native/partials/message-pin.blade.php @@ -1,14 +1,8 @@ {{-- - The pin beside a pinned message, filled as the web draws it. While the message's React and Reply show, an org's - maintainer gets Unpin in its place, in the primary colour. + The pin beside a pinned message, filled as the web draws it, on the side of its bubble nearest the middle of the + thread. Unpin is in the message's long-press menu. --}} -@if ($canPin && $showsActions) - - - - - -@else +@if (isset($pinnedIds[$message->id])) diff --git a/resources/views/native/partials/message-voice.blade.php b/resources/views/native/partials/message-voice.blade.php index ffd339a9..a17f4d9e 100644 --- a/resources/views/native/partials/message-voice.blade.php +++ b/resources/views/native/partials/message-voice.blade.php @@ -1,28 +1,32 @@ {{-- - A message's voice note, in a bubble of its own as the web draws it, with the message's actions beside it as a text - bubble has them (see partials/message-item). Yours is in your lane, staged in it faded until devnull has it, or + A message's voice note, in a bubble of its own as the web draws it, with the message's pin beside it as a text + bubble has it (see partials/message-item). Yours is in your lane, staged in it faded until devnull has it, or outlined in red once it turned the note down; everyone else's is on the wash. The player is partials/voice-note. - A long press opens the message's menu, as on a text bubble, without Copy text. + A long press opens the message's menu, as on a text bubble, without Copy text, and React's quick reactions point + at the bubble. --}} @if ($message->voice !== null) @php($voiceInk = $message->status === 'rejected' ? 'ink' : ($mine ? 'on-'.$myColour : 'ink')) - + @if ($mine) - @include('native.partials.message-actions') + @include('native.partials.message-pin') @endif @if ($message->isPending()) @include('native.partials.voice-note', ['voice' => $message->voice, 'voiceKey' => $message->voiceKey(), 'ref' => 'voice-'.$key, 'ink' => $voiceInk]) @else - + @include('native.partials.voice-note', ['voice' => $message->voice, 'voiceKey' => $message->voiceKey(), 'ref' => 'voice-'.$key, 'ink' => $voiceInk]) @endif @if (! $mine) - @include('native.partials.message-actions') + @include('native.partials.message-pin') + @endif + @if ($reactionsFor === $message->id && ! $reactsFromPinned) + @include('native.partials.quick-reactions', ['anchor' => $reactionsAnchor]) @endif @endif diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index a850fd0e..14d6bc75 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -61,12 +61,11 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ->assertSee('Today') ->assertSee('pulled 14:05'); - // Theirs at the start of the row with the slot for React and Reply right after the bubble, yours at the end with - // the slot right before it, in a thread that opens on its latest message. The bubble shrinks to wrap short of it. + // Theirs at the start of the row and yours at the end, each on its own, in a thread that opens on its latest message. expect(nodeByRef($screen, 'content-1')['layout']['justify_content'])->toBe(0) - ->and(childRefs($screen, 'content-1'))->toBe(['bubble-1', 'actions-1']) + ->and(childRefs($screen, 'content-1'))->toBe(['bubble-1']) ->and(nodeByRef($screen, 'content-2')['layout']['justify_content'])->toBe(2) - ->and(childRefs($screen, 'content-2'))->toBe(['actions-2', 'bubble-2']) + ->and(childRefs($screen, 'content-2'))->toBe(['bubble-2']) ->and(nodeByRef($screen, 'thread')['props']['scroll_anchor'] ?? null)->toBe('bottom'); }); @@ -205,175 +204,128 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = expect(array_map(fn (string $body): string => json_decode($body, true)['client_id'], sentTo('POST', 'remotes/@ada/messages')))->toBe([$clientId, $clientId]); }); -/** - * Whether a message rests slid aside with its actions grown in, rather than at home with them shrunk away. - */ -function showsActions(TestableComponent $screen, string $key): bool -{ - return (nodeByRef($screen, "content-{$key}")['props']['translate-x'] ?? 0) != 0; -} - -it('slides a message dragged toward its edge out of the way, growing React and Reply in where it was', function () { +it('pulls theirs left and yours right, toward their edges, with Reply coming in from the other side', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?']), remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'which one?']), ], ['read_through_id' => 2])); $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); - $pan = fn (string $key): int => nodeByRef($screen, "swipe-{$key}")['props']['pan-x-id']; - - // The area drags the value the row follows, between home and the room the actions take, toward the message's edge. - expect(nodeByRef($screen, 'swipe-1')['props'])->toMatchArray(['pan-x-initial' => 0.0]) - ->and(nodeByRef($screen, 'content-1')['props']['translate-x_sv'])->toBe('__sv:'.$pan('1')) - ->and(nodeByRef($screen, 'swipe-2')['props']['pan-x-initial'])->toEqual(0); - - // Reply sits nearest the bubble, after theirs and before yours, and grows in over the first half of the drag, React - // over the second. - expect(childRefs($screen, 'actions-1-buttons'))->toBe(['reply-1', 'react-1']) - ->and(childRefs($screen, 'actions-2-buttons'))->toBe(['react-2', 'reply-2']) - ->and(nodeByRef($screen, 'reply-1')['props']['scale_sv'])->toBe('__sv:'.$pan('1').'|interp:-34,0:1,0') - ->and(nodeByRef($screen, 'react-1')['props']['scale_sv'])->toBe('__sv:'.$pan('1').'|interp:-68,-34:1,0') - ->and(nodeByRef($screen, 'react-2')['props']['opacity_sv'])->toBe('__sv:'.$pan('2').'|interp:34,68:0,1'); - - // Closed, they're only drawn, so a tap beside the message can't land on them; open, they're buttons. - expect(nodeByRef($screen, 'reply-1')['type'])->toBe('stack'); - - // A tap no longer shows them, and neither does dragging the message away from its edge, or not far enough. - $screen->tap('bubble-1')->dragEnd('swipe-1', 200)->dragEnd('swipe-1', -20); - expect(showsActions($screen, '1'))->toBeFalse(); - - // Theirs goes left, partly off the screen, by the room its actions take. - $screen->dragEnd('swipe-1', -68); - expect(showsActions($screen, '1'))->toBeTrue() - ->and(nodeByRef($screen, 'content-1')['props']['translate-x'])->toEqual(-68) - ->and(nodeByRef($screen, 'reply-1')['type'])->toBe('pressable') - ->and(nodeByRef($screen, 'reply-2')['type'])->toBe('stack'); - - // Yours goes right by as much, and the actions move there. - $screen->dragEnd('swipe-2', 68); - expect(showsActions($screen, '1'))->toBeFalse()->and(showsActions($screen, '2'))->toBeTrue() - ->and(nodeByRef($screen, 'swipe-1')['props']['pan-x-initial'])->toEqual(0) - ->and(nodeByRef($screen, 'content-2')['props']['translate-x'])->toEqual(68); + $pan = fn (string $key): string => '__sv:'.nodeByRef($screen, "swipe-{$key}")['props']['pan-x-id']; + $pull = Conversation::ReplyPull; + + // The area pulls the message one way only, triggers at ReplyPull and springs it back on its own, so the message + // rests at home and follows the same value. + expect(nodeByRef($screen, 'swipe-1')['props'])->toMatchArray(['pan-x-initial' => 0.0, 'pan-x-trigger' => -(float) $pull]) + ->and(nodeByRef($screen, 'swipe-1')['props'])->not->toHaveKey('pan-x-snap') + ->and(nodeByRef($screen, 'swipe-2')['props']['pan-x-trigger'])->toEqual($pull) + ->and(nodeByRef($screen, 'pulled-1')['props']['translate-x_sv'])->toBe($pan('1')) + ->and(nodeByRef($screen, 'pulled-2')['props']['translate-x_sv'])->toBe($pan('2')); + + // Reply waits just past the far side of the row, off the bubbles, and follows the finger in, growing, turning + // upright and fading in, all the way by ReplyPull. Insets go top, right, bottom, left. + expect(nodeByRef($screen, 'swipe-reply-1')['layout'])->toMatchArray(['width' => $pull, 'position' => [-0.0, -$pull, -0.0, 0.0]]) + ->and(nodeByRef($screen, 'swipe-reply-2')['layout']['position'])->toEqual([-0.0, 0.0, -0.0, -$pull]) + ->and(nodeByRef($screen, 'swipe-reply-1')['props'])->toMatchArray([ + 'translate-x_sv' => $pan('1'), + 'opacity_sv' => $pan('1')."|interp:-{$pull},0:1,0", + 'scale_sv' => $pan('1')."|interp:-{$pull},0:1,0.4", + 'rotate_sv' => $pan('1')."|interp:-{$pull},0:0,90", + ]) + ->and(nodeByRef($screen, 'swipe-reply-2')['props'])->toMatchArray([ + 'translate-x_sv' => $pan('2'), + 'opacity_sv' => $pan('2')."|interp:0,{$pull}:0,1", + 'rotate_sv' => $pan('2')."|interp:0,{$pull}:-90,0", + ]); }); -it('slides a short message less, so some of it stays on the screen', function () { +it('stops a short message partway, so some of it stays on the screen while the pull goes on to reply', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'ok']), - remoteMessageJson(['id' => 2, 'body' => 'staging is green']), + remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'k']), ], ['read_through_id' => 2])); $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + $pan = fn (string $key): string => '__sv:'.nodeByRef($screen, "swipe-{$key}")['props']['pan-x-id']; - $screen->dragEnd('swipe-1', -200); - expect(nodeByRef($screen, 'content-1')['props']['translate-x'])->toEqual(-19); - - $screen->dragEnd('swipe-2', -200); - expect(nodeByRef($screen, 'content-2')['props']['translate-x'])->toEqual(-68); + expect(nodeByRef($screen, 'pulled-1')['props']['translate-x_sv'])->toBe($pan('1').'|clamp:-19,0') + ->and(nodeByRef($screen, 'pulled-2')['props']['translate-x_sv'])->toBe($pan('2').'|clamp:0,16') + ->and(nodeByRef($screen, 'swipe-1')['props']['pan-x-trigger'])->toEqual(-Conversation::ReplyPull); }); -it('puts the actions away with a swipe back or a tap anywhere in the thread', function (string $tap) { +it('replies to a message pulled far enough and let go, and raises the keyboard', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?']), remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'which one?']), ], ['read_through_id' => 2])); $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); - $rowTakesTaps = fn (): bool => (nodeByRef($screen, 'message-2')['on_press'] ?? 0) > 0; - - // Until some message's actions show, the rows take no tap, so a tap beside a bubble draws no ripple on Android. - expect($rowTakesTaps())->toBeFalse(); - - $screen->dragEnd('swipe-1', -200); - expect($rowTakesTaps())->toBeTrue(); - $screen->dragEnd('swipe-1', 200); - expect(showsActions($screen, '1'))->toBeFalse(); - - // On Android a swipe that ends inside the bubble also lands as a tap on it, just after: that one is the swipe's own. - $screen->dragEnd('swipe-1', -200)->tap($tap); - expect(showsActions($screen, '1'))->toBeTrue(); + expect(nodeByRef($screen, 'message-input')['props'])->not->toHaveKey('autofocus'); - $this->travel(1)->seconds(); - $screen->tap($tap); - expect(showsActions($screen, '1'))->toBeFalse(); -})->with(['its bubble' => 'bubble-1', 'another bubble' => 'bubble-2', 'beside another message' => 'message-2']); + $screen->trigger('swipe-1') + ->assertSee('Replying to Ada Lovelace') + ->assertSet('replyToId', 1); -it("shows an image-only message's actions beside its images", function () { - serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => '', 'body_segments' => [], 'images' => [['id' => 7, 'width' => 400, 'height' => 300, 'description' => null, 'url' => 'http://devnull.test/i/7.png']]])], ['read_through_id' => 1])); + expect(nodeByRef($screen, 'message-input')['props']['autofocus'] ?? null)->toBeTrue(); - $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->dragEnd('swipe-1', -200); + $screen->trigger('swipe-2')->assertSee('Replying to yourself'); + $screen->trigger('swipe-1')->set('draft', 'yes')->call('send'); - expect(childRefs($screen, 'content-1'))->toBe(['photos-1', 'actions-1']) - ->and(showsActions($screen, '1'))->toBeTrue(); + finishRefreshes($screen); - $this->travel(1)->seconds(); - $screen->tap('image-1-0'); - expect(showsActions($screen, '1'))->toBeFalse(); + expect(json_decode(sentTo('POST', 'remotes/@ada/messages')[0], true)['reply_to_id'])->toBe(1); }); -it("shows the web's quick reactions and More in a bubble pointing at React, and keeps the options up", function () { - serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); +it("shows the web's quick reactions and More from React in a message's menu, pointing at the end of its bubble", function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1]), + remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'which one?']), + ], ['read_through_id' => 2])); $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')) - ->dragEnd('swipe-1', -200) - ->tap('react-1') + ->tap('react-menu-1') ->assertSet('reactionsFor', 1) - ->assertSet('actionsForId', 1) + ->assertSet('emojiPickerFor', null) ->assertSet('sheetOpen', false); $bubble = nodeByRef($screen, 'reactions-bubble'); expect($bubble['type'])->toBe('popover') ->and($bubble['props']['visible'])->toBeTrue() - ->and(childRefs($screen, 'actions-1-buttons'))->toBe(['reply-1', 'react-1', 'reactions-bubble']) + ->and(childRefs($screen, 'content-1'))->toBe(['bubble-1', 'reactions-bubble']) ->and(childRefs($screen, 'quick-reactions'))->toBe(['quick-0', 'quick-1', 'quick-2', 'more-reactions']); - // A tap outside it closes it, and leaves the options up. - $screen->dismissSheet('reactions-bubble') - ->assertSet('reactionsFor', null) - ->assertSet('actionsForId', 1); + // A tap outside it closes it. + $screen->dismissSheet('reactions-bubble')->assertSet('reactionsFor', null); expect(rendersRef($screen, 'reactions-bubble'))->toBeFalse(); -}); - -it('reacts from the bubble, and puts the bubble and the options away', function () { - serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); - $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->dragEnd('swipe-1', -200)->tap('react-1'); + $screen->tap('react-menu-2'); - finishRefreshes($screen->tap('quick-2')) - ->assertSet('reactionsFor', null) - ->assertSet('actionsForId', null); - - expect(sentTo('POST', 'remotes/messages/1/reactions'))->toBe(['{"emoji":"❤️"}']) - ->and(rendersRef($screen, 'reactions-bubble'))->toBeFalse() - ->and(rendersRef($screen, 'chip-1-0'))->toBeTrue(); + expect(childRefs($screen, 'content-2'))->toBe(['bubble-2', 'reactions-bubble']); }); -it('replies to a message from its Reply button, and raises the keyboard', function () { - serveRemoteThread(threadJson([ - remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?']), - remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'which one?']), - ], ['read_through_id' => 2])); +it("points the quick reactions at an image-only message's images, and pulls it to reply like any other", function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => '', 'body_segments' => [], 'images' => [['id' => 7, 'width' => 400, 'height' => 300, 'description' => null, 'url' => 'http://devnull.test/i/7.png']]])], ['read_through_id' => 1])); - $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->tap('react-menu-1'); - expect(nodeByRef($screen, 'message-input')['props'])->not->toHaveKey('autofocus'); + expect(childRefs($screen, 'content-1'))->toBe(['photos-1', 'reactions-bubble']); - // A swipe no longer replies on its own: it brings up Reply. - $screen->dragEnd('swipe-1', -200)->assertSet('replyToId', null); - $screen->tap('reply-1') - ->assertSee('Replying to Ada Lovelace') - ->assertSet('actionsForId', null); + $screen->trigger('swipe-1')->assertSet('replyToId', 1); +}); - expect(nodeByRef($screen, 'message-input')['props']['autofocus'] ?? null)->toBeTrue(); +it('reacts from the bubble, and puts it away', function () { + serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); - $screen->dragEnd('swipe-2', 200)->tap('reply-2')->assertSee('Replying to yourself'); - $screen->dragEnd('swipe-1', -200)->tap('reply-1')->set('draft', 'yes')->call('send'); + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->tap('react-menu-1'); - finishRefreshes($screen); + finishRefreshes($screen->tap('quick-2'))->assertSet('reactionsFor', null); - expect(json_decode(sentTo('POST', 'remotes/@ada/messages')[0], true)['reply_to_id'])->toBe(1); + expect(sentTo('POST', 'remotes/messages/1/reactions'))->toBe(['{"emoji":"❤️"}']) + ->and(rendersRef($screen, 'reactions-bubble'))->toBeFalse() + ->and(rendersRef($screen, 'chip-1-0'))->toBeTrue(); }); it('takes no swipe on a message still being pushed, or while the thread cannot take a message', function () { @@ -431,7 +383,7 @@ function pickerShows(TestableComponent $screen): bool it("opens the phone's emoji picker from More in the bubble, and reacts with the emoji picked", function () { serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); - $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->dragEnd('swipe-1', -200)->tap('react-1'); + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->tap('react-menu-1'); expect(pickerShows($screen))->toBeFalse(); @@ -791,7 +743,7 @@ function pulledAvatars(TestableComponent $screen, int $id): array // The scroll view adds the home indicator itself while the keyboard is down, so it isn't counted here. expect($clearance())->toEqual(Conversation::ComposerBarHeight + 8); - $screen->dragEnd('swipe-1', -200)->tap('reply-1'); + $screen->trigger('swipe-1'); expect($clearance())->toEqual(Conversation::ComposerBarHeight + Conversation::ReplyingBarHeight + 8); }); @@ -876,34 +828,6 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul }); } -it("gives an org's maintainers Pin beside a message, nearest the bubble, and keeps room for it in any org", function () { - $messages = [ - orgMessageJson(['id' => 1, 'body' => 'standup in 10?']), - orgMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'on my way']), - ]; - serveOrgThread(orgThreadJson($messages)); - - $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')); - - expect(childRefs($screen, 'actions-1-buttons'))->toBe(['pin-1', 'reply-1', 'react-1']) - ->and(childRefs($screen, 'actions-2-buttons'))->toBe(['react-2', 'reply-2', 'pin-2']) - ->and(nodeByRef($screen, 'actions-1')['layout']['width'])->toEqual(98); - - // A contributor gets no Pin, but the slot keeps its room, so a pin arriving never rewraps a bubble. - serveOrgThread(orgThreadJson($messages, ['role' => 'contributor'])); - $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')); - - expect(childRefs($screen, 'actions-1-buttons'))->toBe(['reply-1', 'react-1']) - ->and(nodeByRef($screen, 'actions-1')['layout']['width'])->toEqual(98); - - // Nothing pins with one person. - serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); - $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); - - expect(childRefs($screen, 'actions-1-buttons'))->toBe(['reply-1', 'react-1']) - ->and(nodeByRef($screen, 'actions-1')['layout']['width'])->toEqual(64); -}); - it('asks before pinning for the whole org, then pins at once and takes the pinned messages devnull answers with', function () { serveOrgThread( orgThreadJson([orgMessageJson(['id' => 1, 'body' => 'standup in 10?'])]), @@ -912,11 +836,9 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul holdRefreshes(); $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')) - ->dragEnd('swipe-1', -200) - ->tap('pin-1') + ->tap('pin-menu-1') ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Pin for the whole org?' - && str_contains($params['message'], 'including people added without Share history')) - ->assertSet('actionsForId', null); + && str_contains($params['message'], 'including people added without Share history')); // Cancel leaves it be. $screen->emitNative(ButtonPressed::class, ['index' => 0, 'label' => 'Cancel', 'id' => Conversation::PinAlert]); @@ -924,13 +846,12 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul ->and(sentTo('PUT', 'remotes/messages/1/pin'))->toBe([]); // Pin message pins it straight away, with its pin beside it, and takes devnull's list once it answers. - $screen->dragEnd('swipe-1', -200)->tap('pin-1') + $screen->tap('pin-menu-1') ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => Conversation::PinMessage, 'id' => Conversation::PinAlert]) ->assertSee('pinned · 1'); - expect(rendersRef($screen, 'pinned-1'))->toBeTrue() - ->and(childRefs($screen, 'actions-1'))->toBe(['pinned-1', 'actions-1-buttons']) - ->and(childRefs($screen, 'actions-1-buttons'))->toBe(['reply-1', 'react-1']); + expect(childRefs($screen, 'content-1'))->toBe(['bubble-1', 'pinned-1']) + ->and(array_column(menuItems($screen, 'bubble-1'), 0))->toContain('unpin-menu-1'); finishRefreshes($screen)->assertSee('pinned · 2'); @@ -944,40 +865,28 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul ])); $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')) - ->dragEnd('swipe-1', -200) - ->tap('pin-1') + ->tap('pin-menu-1') ->emitNative(ButtonPressed::class, ['index' => 2, 'label' => Conversation::PinWithoutAsking, 'id' => Conversation::PinAlert]); - finishRefreshes($screen->dragEnd('swipe-2', -200)->tap('pin-2')); + finishRefreshes($screen->tap('pin-menu-2')); expect($this->bridge->callsTo('Dialog.Alert'))->toHaveCount(1) ->and(sentTo('PUT', 'remotes/messages/1/pin'))->toHaveCount(1) ->and(sentTo('PUT', 'remotes/messages/2/pin'))->toHaveCount(1); }); -it('marks a pinned message with a pin beside it, which only a maintainer can press, and only while its actions show', function () { - $thread = fn (string $role): array => orgThreadJson([orgMessageJson(['id' => 1, 'body' => 'standup in 10?'])], [ - 'role' => $role, - 'pinned' => [orgMessageJson(['id' => 1, 'body' => 'standup in 10?'])], - ]); - serveOrgThread($thread('contributor')); - - $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1'))->dragEnd('swipe-1', -200); +it('marks a pinned message with a pin on the side of its bubble nearest the middle, for everyone in the org', function () { + $pinned = [orgMessageJson(['id' => 1, 'body' => 'standup in 10?']), orgMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'on my way'])]; + serveOrgThread(orgThreadJson($pinned, ['role' => 'contributor', 'pinned' => $pinned])); - expect(nodeByRef($screen, 'pinned-1')['type'])->toBe('stack') - ->and(rendersRef($screen, 'unpin-1'))->toBeFalse(); - - serveOrgThread($thread('maintainer')); $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')); - expect(rendersRef($screen, 'pinned-1'))->toBeTrue()->and(rendersRef($screen, 'unpin-1'))->toBeFalse(); - - $screen->dragEnd('swipe-1', -200); - - expect(rendersRef($screen, 'unpin-1'))->toBeTrue()->and(rendersRef($screen, 'pinned-1'))->toBeFalse(); + expect(childRefs($screen, 'content-1'))->toBe(['bubble-1', 'pinned-1']) + ->and(childRefs($screen, 'content-2'))->toBe(['pinned-2', 'bubble-2']) + ->and(nodeByRef($screen, 'pinned-1')['type'])->toBe('stack'); }); -it('unpins at once, beside the message or in the pinned sheet, which closes once nothing is left in it', function () { +it("unpins at once, from the message's menu or in the pinned sheet, which closes once nothing is left in it", function () { serveOrgThread(orgThreadJson([ orgMessageJson(['id' => 1, 'body' => 'standup in 10?']), orgMessageJson(['id' => 2, 'body' => 'release checklist']), @@ -986,8 +895,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')) ->assertSee('pinned · 2') - ->dragEnd('swipe-1', -200) - ->tap('unpin-1') + ->tap('unpin-menu-1') ->assertSee('pinned · 1'); expect(rendersRef($screen, 'pinned-1'))->toBeFalse() @@ -1007,7 +915,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul ); app(Account::class)->rememberSkipsPinWarning(); - $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')->dragEnd('swipe-1', -200)->tap('pin-1')); + $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')->tap('pin-menu-1')); expect(rendersRef($screen, 'pinned-bar'))->toBeFalse() ->and(array_column(array_column($this->bridge->callsTo('ToastKit.Show'), 'params'), 'message'))->toBe([$toast]); @@ -1027,7 +935,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul pins: fn () => Http::response(['message' => 'Server Error'], 500), ); - $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')->dragEnd('swipe-1', -200)->tap('unpin-1')); + $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')->tap('unpin-menu-1')); expect(array_map(fn ($message): int => $message->id, $screen->instance()->pinned))->toBe([2, 1, 3]) ->and(array_column(array_column($this->bridge->callsTo('ToastKit.Show'), 'params'), 'message'))->toBe(['Could not unpin. Try again.']); @@ -1068,7 +976,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul $screen = finishRefreshes(Native::visit('/remotes/orgs/org-1')); // The poll goes first, but answers while the pin is still out, so the pin's own answer wins. - finishRefreshes($screen->call('poll')->dragEnd('swipe-1', -200)->tap('pin-1'))->assertSee('pinned · 1'); + finishRefreshes($screen->call('poll')->tap('pin-menu-1'))->assertSee('pinned · 1'); expect(array_map(fn ($message): int => $message->id, $screen->instance()->pinned))->toBe([1]); @@ -1214,7 +1122,6 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul ['copy-menu-1', 'Copy text', 'custom:copy'], ]) ->and(array_column(menuItems($screen, 'bubble-2'), 0))->toBe(['reply-menu-2', 'react-menu-2', 'copy-menu-2']) - ->and(nodeByRef($screen, 'bubble-1')['on_press'] ?? 0)->toBeGreaterThan(0) ->and(nodeByRef($screen, 'bubble-1')['props']['on_double_tap'] ?? 0)->toBeGreaterThan(0); }); @@ -1280,7 +1187,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul expect(array_column(array_column($this->bridge->callsTo('ToastKit.Show'), 'params'), 'message'))->toBe(['Could not copy the text. Try again.']); }); -it('replies from the menu as from the Reply beside the message, and raises the keyboard', function () { +it('replies from the menu as from a pull, and raises the keyboard', function () { serveRemoteThread(threadJson([remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?'])], ['read_through_id' => 1])); $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')) @@ -1290,29 +1197,6 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul expect(nodeByRef($screen, 'message-input')['props']['autofocus'] ?? null)->toBeTrue(); }); -it("reacts from the menu with the phone's emoji picker, or the quick reactions when the message's options show", function () { - serveRemoteThread(threadJson([ - remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?']), - remoteMessageJson(['id' => 2, 'body' => 'staging is green']), - ], ['read_through_id' => 2])); - - $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->tap('react-menu-1'); - - expect(pickerShows($screen))->toBeTrue() - ->and(rendersRef($screen, 'reactions-bubble'))->toBeFalse(); - - finishRefreshes($screen->select('emoji-picker', '🎉')); - - expect(sentTo('POST', 'remotes/messages/1/reactions'))->toBe(['{"emoji":"🎉"}']); - - // Slid open, the message has React beside it for the bubble to point at. - $screen->dragEnd('swipe-2', -200)->tap('react-menu-2') - ->assertSet('reactionsFor', 2) - ->assertSet('emojiPickerFor', null); - - expect(rendersRef($screen, 'reactions-bubble'))->toBeTrue(); -}); - it("adds Pin message to the menu for an org's maintainers, and Unpin message on a pinned one", function () { serveOrgThread(orgThreadJson([ orgMessageJson(['id' => 1, 'body' => 'standup in 10?']), diff --git a/tests/Feature/Screens/RemotesComposerTest.php b/tests/Feature/Screens/RemotesComposerTest.php index 731d9788..b06a389a 100644 --- a/tests/Feature/Screens/RemotesComposerTest.php +++ b/tests/Feature/Screens/RemotesComposerTest.php @@ -230,7 +230,7 @@ function threadWithAda(string $platform = 'ios'): TestableComponent expect(nodeByRef($screen, 'message-input')['props']['keep_focus_on_submit'] ?? false)->toBeTrue(); // Replying while you're typing leaves the field as it is, so the keyboard doesn't drop and come back. - $screen->dragEnd('swipe-1', -200)->tap('reply-1')->assertSee('Replying to Ada Lovelace'); + $screen->trigger('swipe-1')->assertSee('Replying to Ada Lovelace'); // The strip fills a slot the bar always has, so the field's row keeps its place among the bar's rows, which // Android needs as well as the same id to keep the field. diff --git a/tests/Feature/Screens/RemotesVoiceNotesTest.php b/tests/Feature/Screens/RemotesVoiceNotesTest.php index 4449adef..7dee858e 100644 --- a/tests/Feature/Screens/RemotesVoiceNotesTest.php +++ b/tests/Feature/Screens/RemotesVoiceNotesTest.php @@ -261,7 +261,7 @@ function playedBars(TestableComponent $screen, string $ref): int it('pushes straight from the recording, as soon as the microphone hands it over', function () { serveVoiceThread([remoteMessageJson(['id' => 1, 'body' => 'say it'])]); - $screen = voiceThread()->dragEnd('swipe-1', -200)->tap('reply-1')->tap('record-voice-note')->tap('push'); + $screen = voiceThread()->trigger('swipe-1')->tap('record-voice-note')->tap('push'); $screen->assertNativeCalled('Microphone.Stop')->assertSet('pending', []); finishRefreshes(voiceRecorded($screen, testRecording()))->assertSet('pending', [])->assertSet('recorder', null); @@ -493,7 +493,7 @@ function playedBars(TestableComponent $screen, string $ref): int expect(nodeByRef($screen, 'reply-ghost-3')['props']['a11y_label'])->toBe('Replying to Ada Lovelace: Voice note'); - $screen->dragEnd('swipe-2', -200)->tap('reply-2')->assertSee('Voice note'); + $screen->trigger('swipe-2')->assertSee('Voice note'); }); it('opens the message menu on a long press on a voice note, without Copy text, and replies from it', function () {