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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/Devnull/ReactionEmoji.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace App\Devnull;

/**
* The emoji devnull takes as a reaction, and what to send for one the phone's own picker hands over.
*
* Devnull only takes the emoji in its shortcode set (resources/emoji-shortcodes.json, a copy of the web's
* resources/js/lib/emoji-shortcodes.json), in their fully-qualified form and without skin tones. The phone's picker
* knows more: skin tones, a missing or extra U+FE0F, and emoji newer than the set. A skin tone comes off and the
* variation selector follows devnull's, so 👍🏽 reacts as 👍 and ❤ as ❤️. One devnull doesn't know at all
* (🐦‍🔥, 🫩) has no match, and the app says so instead of sending it.
*/
final class ReactionEmoji
{
/**
* Devnull's emoji by their key (see key()), loaded once.
*
* @var array<string, string>|null
*/
private static ?array $byKey = null;

/**
* What devnull takes for the emoji: itself, or the same emoji without its skin tone or with devnull's variation
* selector. Null when devnull has nothing like it.
*/
public static function forDevnull(string $emoji): ?string
{
return self::byKey()[self::key($emoji)] ?? null;
}

/**
* The emoji with no skin tone and no variation selector, which no two of devnull's share.
*/
private static function key(string $emoji): string
{
return (string) preg_replace('/[\x{FE0F}\x{1F3FB}-\x{1F3FF}]/u', '', trim($emoji));
}

/**
* @return array<string, string>
*/
private static function byKey(): array
{
if (self::$byKey !== null) {
return self::$byKey;
}

/** @var array<string, string> $shortcodes */
$shortcodes = json_decode((string) file_get_contents(resource_path('emoji-shortcodes.json')), true);

$byKey = [];

foreach ($shortcodes as $emoji) {
$byKey[self::key($emoji)] = $emoji;
}

return self::$byKey = $byKey;
}
}
132 changes: 96 additions & 36 deletions app/NativeComponents/Conversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use App\Devnull\Exceptions\ApiException;
use App\Devnull\MuteDuration;
use App\Devnull\Outbox;
use App\Devnull\ReactionEmoji;
use App\Devnull\ResponseCache;
use App\Devnull\SiteLink;
use App\Devnull\Toasts;
Expand Down Expand Up @@ -58,7 +59,9 @@
* 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.
* 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.
*
* 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
Expand Down Expand Up @@ -95,18 +98,6 @@ class Conversation extends NativeComponent
*/
public const array QuickReactions = ['😂', '✅', '❤️'];

/**
* The rest the sheet offers, every one of them in devnull's shortcode set.
*/
public const array MoreReactions = ['👍', '👀', '🔥', '🎉', '🚀', '💯', '🙏', '🤔', '👏', '😅', '😮', '😢', '🙌', '💀', '✨', '🤝'];

/**
* What the sheet shows: React's reactions for a message, or the org's pinned messages.
*/
public const string ReactionsSheet = 'reactions';

public const string PinnedSheet = 'pinned';

/**
* 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.
Expand Down Expand Up @@ -245,23 +236,26 @@ class Conversation extends NativeComponent
private array $pans = [];

/**
* What the sheet shows, which stays as it was while the sheet slides away.
* Whether the sheet of the org's pinned messages is up.
*/
public string $sheet = self::ReactionsSheet;
public bool $sheetOpen = false;

/**
* The message React opened the reactions sheet for.
* The message whose React shows the web's quick reactions in a bubble beside it.
*/
public ?int $sheetMessageId = null;

public bool $sheetOpen = false;
public ?int $reactionsFor = null;

/**
* React in the pinned sheet shows the reactions in its place, and reacting goes back to the pinned messages, as the
* web keeps its panel open behind the reactions.
* React was pressed in the pinned sheet, so the bubble shows there, and the sheet comes back after the emoji picker
* has made way for it.
*/
public bool $reactsFromPinned = false;

/**
* The message the phone's emoji picker is up for, if it is.
*/
public ?int $emojiPickerFor = null;

/**
* The org's pinned messages, latest pinned first, which everyone in it sees whatever history they were given.
*
Expand Down Expand Up @@ -540,19 +534,26 @@ public function hideActions(): void
}

/**
* React: the web's reactions for the message, in a sheet. From the pinned sheet, they take its place.
* 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.
*/
public function openReactions(int $id): void
{
if ($this->findMessage($id)?->isEvent() !== false) {
return;
}

$this->reactsFromPinned = $this->sheetOpen && $this->sheet === self::PinnedSheet;
$this->actionsForId = null;
$this->sheet = self::ReactionsSheet;
$this->sheetMessageId = $id;
$this->sheetOpen = true;
$this->reactsFromPinned = $this->sheetOpen;
$this->reactionsFor = $id;
}

/**
* A tap outside the bubble closes it.
*/
public function reactionsClosed(): void
{
$this->reactionsFor = null;
$this->reactsFromPinned = false;
}

/**
Expand All @@ -565,21 +566,82 @@ public function openPinned(): void
}

$this->actionsForId = null;
$this->reactionsFor = null;
$this->reactsFromPinned = false;
$this->sheet = self::PinnedSheet;
$this->sheetOpen = true;
}

public function messageSheetClosed(): void
{
$this->sheetOpen = false;

// The pinned sheet making way for the emoji picker keeps where React came from.
if ($this->emojiPickerFor === null) {
$this->reactionsFor = null;
$this->reactsFromPinned = false;
}
}

/**
* More reactions: the phone's own emoji picker, with its search, recents and skin tones, for any message. The
* bubble closes and the pinned sheet makes way for it.
*/
public function openEmojiPicker(int $id): void
{
if ($this->findMessage($id)?->isEvent() !== false) {
return;
}

$this->actionsForId = null;
$this->reactionsFor = null;
$this->sheetOpen = false;
$this->emojiPickerFor = $id;
}

/**
* Reacts with the emoji picked, as devnull takes it (see ReactionEmoji), and goes back to the pinned messages if
* React came from there. One devnull doesn't know isn't sent: a toast says so and the picker stays up for another.
*/
public function emojiPicked(string $emoji): void
{
$id = $this->emojiPickerFor;

if ($id === null) {
return;
}

$reaction = ReactionEmoji::forDevnull($emoji);

if ($reaction === null) {
Toasts::failed("devnull doesn't take {$emoji} as a reaction yet. Pick another.");

return;
}

$this->emojiPickerFor = null;
$this->sheetOpen = $this->reactsFromPinned;
$this->reactsFromPinned = false;
$this->toggleReaction($id, $reaction);
}

/**
* Closing the picker without a pick goes back to the pinned messages if React came from there.
*/
public function emojiPickerClosed(): void
{
$this->emojiPickerFor = null;

if ($this->reactsFromPinned) {
$this->sheetOpen = true;
$this->reactsFromPinned = false;
}
}

public function reply(int $id): void
{
$this->replyToId = $id;
$this->actionsForId = null;
$this->reactionsFor = null;
$this->sheetOpen = false;
$this->reactsFromPinned = false;
$this->composerKey++;
Expand Down Expand Up @@ -661,7 +723,7 @@ public function unpinMessage(int $id): void
$before = $this->pinned;
$this->pinned = array_values(array_filter($this->pinned, fn (Message $pinned): bool => $pinned->id !== $id));

if ($this->pinned === [] && $this->sheet === self::PinnedSheet) {
if ($this->pinned === []) {
$this->sheetOpen = false;
}

Expand All @@ -683,17 +745,17 @@ 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 reactions sheet, the sheet closes, or goes back to the pinned messages it was opened from.
* the bubble, the bubble closes and the message's options go away.
*/
public function toggleReaction(int $id, string $emoji): void
{
$message = $this->findMessage($id);
$me = $this->me();

if ($this->sheet === self::ReactionsSheet) {
$this->sheet = $this->reactsFromPinned ? self::PinnedSheet : self::ReactionsSheet;
$this->sheetOpen = $this->sheetOpen && $this->reactsFromPinned;
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) {
Expand Down Expand Up @@ -924,7 +986,7 @@ private function takePinned(array $pinned, int $version): void
}, $pinned);
$this->pinnedVersion = $version;

if ($this->pinned === [] && $this->sheet === self::PinnedSheet) {
if ($this->pinned === []) {
$this->sheetOpen = false;
}
}
Expand Down Expand Up @@ -1380,9 +1442,7 @@ public function render(): View
'items' => $this->items($viewer),
'composerClearance' => self::composerClearance($this->draft, $this->replyToId !== null),
'replyTo' => $this->findMessage($this->replyToId),
'sheetMessage' => $this->findMessage($this->sheetMessageId),
'quickReactions' => $this->quickReactions(),
'moreReactions' => self::MoreReactions,
'canPush' => $thread !== null && $thread->access === Thread::Open,
'canPin' => $this->canPin(),
'actionsWidth' => $this->actionsWidth(),
Expand Down
Loading
Loading