From f32ea03bc0230164200c50d1e5228308f4d95d75 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 3 Nov 2026 15:26:31 +0000 Subject: [PATCH] Reply to a Remotes message by pulling it toward its edge The swipe on a message used to open a slot of React, Reply and Pin. Now it only replies. Someone else's message pulls left and yours right, as the swipe went before. Reply slides in from the other side of the thread, growing, turning upright and fading in with the finger. Past 64 points the phone buzzes lightly, and letting go there sets the composer replying to the message and raises the keyboard. The message springs back either way. The pull, the buzz and the spring back run natively through the gesture area's new trigger-x and @trigger (NativePHP/mobile-air and NativePHP/mobile-ui), so only the reply comes to PHP. A short bubble only follows the pull as far as keeps some of it on screen, as the slot did. The slot, its show and hide state, the tap-to-hide handling and the swipe tap window are gone. Everything the slot offered is in the long-press menu already: Reply, React, and Pin or Unpin for an org's maintainers. React from the menu now opens the quick reactions, pointing at the message, rather than going straight to the phone's emoji picker. A pinned message keeps its pin beside it. Bubbles stop 64 points short of the far side instead of leaving room for the slot. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/MessageMenu.php | 2 +- app/NativeComponents/Conversation.php | 157 ++------- .../native/partials/message-actions.blade.php | 51 --- .../native/partials/message-images.blade.php | 5 +- .../native/partials/message-item.blade.php | 73 +++-- .../native/partials/message-pin.blade.php | 12 +- .../native/partials/message-voice.blade.php | 18 +- tests/Feature/Screens/ConversationTest.php | 302 ++++++------------ tests/Feature/Screens/RemotesComposerTest.php | 2 +- .../Feature/Screens/RemotesVoiceNotesTest.php | 4 +- 10 files changed, 196 insertions(+), 430 deletions(-) delete mode 100644 resources/views/native/partials/message-actions.blade.php 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 ddde62a5..5a2f35a1 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) { @@ -1826,7 +1751,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, @@ -1838,45 +1762,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)); } /** @@ -1981,8 +1894,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 acf23038..5765369c 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']) @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 @@ -120,5 +128,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 6f206193..a308bdab 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'); - - finishRefreshes($screen->tap('quick-2')) - ->assertSet('reactionsFor', null) - ->assertSet('actionsForId', null); + $screen->tap('react-menu-2'); - 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(); @@ -668,7 +620,7 @@ function pickerShows(TestableComponent $screen): bool // 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); }); @@ -753,34 +705,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?'])]), @@ -789,11 +713,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]); @@ -801,13 +723,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'); @@ -821,40 +742,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); - - expect(nodeByRef($screen, 'pinned-1')['type'])->toBe('stack') - ->and(rendersRef($screen, 'unpin-1'))->toBeFalse(); +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])); - 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']), @@ -863,8 +772,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() @@ -884,7 +792,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]); @@ -904,7 +812,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.']); @@ -945,7 +853,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]); @@ -1051,7 +959,7 @@ function serveOrgThread(array $thread, ?array $poll = null, ?Closure $pins = nul expect(findNode(nodeByRef($screen, 'pin-1'), fn (array $node): bool => ($node['props']['max_lines'] ?? null) === 3))->toBeNull(); }); -it('opens Reply, React and Copy text on a long press on a message, yours or theirs, and keeps its tap and double tap', function () { +it('opens Reply, React and Copy text on a long press on a message, yours or theirs, and keeps its double tap', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'did you see the PR?']), remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => 'which one?']), @@ -1066,7 +974,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); }); @@ -1132,7 +1039,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')) @@ -1142,29 +1049,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 () {