Connexion
FR
apps/caramail-chat / src / Form / ChatForm.php · commit 6eebcdb
<?phpnamespace App\Form;use App\Chat\ChatClient;use App\Chat\Member;use App\Chat\Styled;use App\Gui\Awt;use App\Gui\AwtButton;use App\Gui\AwtCheck;use App\Gui\AwtChoice;use App\Gui\AwtRadio;use App\Gui\AwtText;use App\Gui\ChatList;use App\Gui\Etched;use App\Gui\SmileyGrid;use App\Gui\TabBar;use App\Net\FakeLink;use App\Net\Link;use App\Net\TcpLink;use App\Net\WsLink;use JumPHP\Gui\Container;use JumPHP\Gui\Form;use JumPHP\Gui\Label;use JumPHP\Util\Lang;/** * The chat window of the 2002 applet (net.spray.chat.normal.b): the tabs Salons, Connecté(e)s, Options, Ami(e)s, the * room and one per private talk, each with its page as the applet laid it out. On a phone (xs) the pages stack and * the members of the room open with a button. * * Params: server ("wss://host/path" or "host:port"), tempid, admin, room; or link (a Link: the tests, the demo); demo. * * @property TabBar $tabs * @property Container $roomPane * @property Container $roomMain * @property ChatList $talk * @property Label $topic * @property AwtCheck $privateMsg * @property AwtText $line * @property AwtCheck $noPrivate * @property AwtChoice $color * @property AwtChoice $size * @property TabBar $roomSwitch * @property Etched $talkBox * @property Container $lineRow * @property Container $optsRow * @property \JumPHP\Gui\Widget $optsSpacer * @property Container $pvLineRow * @property Container $pvOptsRow * @property \JumPHP\Gui\Widget $pvSpacer * @property AwtButton $send * @property Container $roomSide * @property TabBar $sideTabs * @property Etched $membersBox * @property ChatList $members * @property Etched $smileysBox * @property SmileyGrid $roomSmileys * @property Label $membersCount * @property AwtRadio $sortNick * @property AwtRadio $sortSex * @property AwtButton $ignoreBtn * @property AwtButton $privateBtn * @property AwtButton $ejectBtn * @property AwtButton $callModo * @property Container $privatePane * @property Etched $pvBox * @property ChatList $pvTalk * @property Label $pvTopic * @property AwtText $pvLine * @property AwtChoice $pvColor * @property AwtChoice $pvSize * @property AwtButton $closeIgnore * @property AwtButton $closePrivate * @property AwtButton $pvSend * @property Label $pvWho * @property Label $pvAge * @property Label $pvSex * @property Label $pvCity * @property AwtCheck $pvTime * @property AwtButton $pvFriend * @property SmileyGrid $pvSmileys * @property Container $roomsPane * @property AwtButton $permanentRooms * @property AwtButton $publicRooms * @property ChatList $rooms * @property Label $roomsStatus * @property AwtButton $joinRoom * @property AwtRadio $newPublic * @property AwtRadio $newPrivate * @property AwtText $newRoom * @property AwtButton $createRoom * @property AwtText $privateRoom * @property AwtButton $joinPrivate * @property Container $searchPane * @property ChatList $found * @property Label $searchStatus * @property AwtButton $foundPrivate * @property AwtButton $foundIgnore * @property AwtButton $foundJoin * @property Container $searchSide * @property Label $connected * @property AwtChoice $searchBy * @property AwtText $searchText * @property AwtChoice $searchAge * @property AwtChoice $searchSex * @property AwtButton $searchGo * @property Container $optionsPane * @property ChatList $info * @property \App\Gui\HsbPicker $picker * @property AwtButton $apply * @property AwtCheck $times * @property AwtChoice $language * @property AwtButton $defaults * @property AwtButton $help * @property Label $status * @property AwtButton $quit * @property Container $friendsPane * @property ChatList $friends * @property AwtButton $friendTalk * @property AwtButton $friendRemove * @property Label $mottoNow * @property AwtText $motto * @property AwtCheck $busy * @property AwtButton $mottoBtn */final class ChatForm extends Form{    public const ROOMS = 'rooms';    public const SEARCH = 'search';    public const INFO = 'info';    public const FRIENDS = 'friends';    public const ROOM = 'room';    public const PRIVATE = 'pv:';    public const MAX_PRIVATES = 20;    public const DEFAULT_SERVER = 'wss://caramail.jumphp.net/ws';    /** The size choices (Speak, Whisper, Shout, Sing) => the font codes 01, 00, 02, 03 (03: the wave). */    public const SIZE_CODES = [1, 0, 2, 3];    /** The age bands of the search (the server's), per item of searchAge. */    public const AGE_BANDS = [-2, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, -1];    /** The sex filter per item of searchSex: any, woman (0), man (1), don't know (-1: the third category). */    public const SEX_FILTERS = [-2, 0, 1, -1];    /** The help of the chat (the site of the app, its page aide.html). */    public const HELP_URL = 'https://jumphp.net/caramail-chat/aide.html';    /** The languages of the app, by their own names (the Options' choice). */    public const LANGUAGES = ['fr' => 'Français', 'en' => 'English', 'es' => 'Español', 'de' => 'Deutsch', 'it' => 'Italiano',        'pt' => 'Português', 'nl' => 'Nederlands', 'pl' => 'Polski', 'ru' => 'Русский', 'uk' => 'Українська', 'tr' => 'Türkçe',        'ja' => '日本語', 'zh' => '中文', 'ko' => '한국어'];    /** @var array<string, list<string>> the drop-down lists' items as written (English keys), to translate them again */    private array $itemKeys = [];    private const PANES = ['roomPane', 'privatePane', 'roomsPane', 'searchPane', 'optionsPane', 'friendsPane'];    public ?ChatClient $chat = null;    public bool $admin = false;    /** the demo shows this tab at its end (the screenshots of the site) */    public string $demoTab = '';    /** @var array<string, list<array>> the lines of each private talk */    private array $pages = [];    /** @var array<string, string> private tab key => nickname */    private array $privateNames = [];    /** @var array<string, bool> private talks whose correspondent left */    private array $privateGone = [];    /** @var array<string, bool> private talks with the time shown */    private array $privateTimes = [];    private string $current = self::INFO;    private ?Member $chosen = null;    private ?Member $foundChosen = null;    /** @var list<Member> the room's members, as the server gave them */    private array $roomMembers = [];    /** the member a click on a line of the room chose: "Message privé" writes to him */    private string $replyId = '';    private string $replyNick = '';    private bool $ended = false;    private float $countEvery = 0;    private ?Demo $demo = null;    private ?Link $link = null;    private string $breakpointSeen = '';    /** a phone: what the room shows, the talk ('dialogue'), the members or the smileys (the bar roomSwitch) */    private string $roomView = 'dialogue';    /** the tallest the window was at its width: smaller now = the browser shrank the page for the keyboard */    private array $tallest = [];    private bool $advised = false;    public const ADVICE = 'Caramail advises you never to give anyone your password, your phone number, your address, or even your first and last names.';    /** @var list<array{nickname: string, id: string}> */    private array $friendList = [];    private float $fontScale = 0;    protected function loaded(array $params = []): void    {        Awt::theme($this);        $this->fontScale = 0;        $this->pages = [];        $this->privateNames = $this->privateGone = $this->privateTimes = [];        $this->chosen = $this->foundChosen = null;        $this->roomMembers = [];        $this->replyId = $this->replyNick = '';        $this->ended = false;        $this->roomView = 'dialogue';        $this->advised = false;        $this->breakpointSeen = '';        $this->admin = (bool)($params['admin'] ?? false);        foreach (['talk', 'members', 'found', 'rooms', 'info', 'pvTalk', 'friends'] as $list) {            $widget = $this->widget($list);            if ($widget instanceof ChatList) {                $widget->clear();            }        }        // the language: the one chosen in Options, else the browser's (the engine's)        $chosen = (string)$this->engine->options->get('lang', '');        if (isset(self::LANGUAGES[$chosen])) {            $this->engine->lang->setLang($chosen);        }        $this->itemKeys = [];        foreach (['color', 'size', 'pvColor', 'pvSize', 'searchBy', 'searchAge', 'searchSex'] as $id) {            $combo = $this->widget($id);            if ($combo instanceof AwtChoice) {                $this->itemKeys[$id] = array_map('strval', (array)$combo->props['items']);            }        }        $this->language->items = array_values(self::LANGUAGES);        $current = substr($this->engine->lang->getLang(), 0, 2);        $this->language->selected = (int)array_search(isset(self::LANGUAGES[$current]) ? $current : 'en', array_keys(self::LANGUAGES), true);        $this->translateItems();        $face = self::faceOption((string)$this->engine->options->get('face', Awt::FACE));        $this->picker->selected = $face;        $this->applyFace($face);        $this->times->checked = (bool)$this->engine->options->get('times', false);        $this->friendList = (array)$this->engine->storage->readJson('friends.json', []);        $this->fillFriends();        foreach ([[self::ROOMS, 'Rooms'], [self::SEARCH, 'Connected'], [self::INFO, 'Options'], [self::FRIENDS, 'Friends']] as [$key, $label]) {            $this->tabs->add($key, Lang::t($label));        }        $this->sideTabs->add('members', Lang::t('Members'));        $this->sideTabs->add('smileys', Lang::t('Smileys'));        foreach (['dialogue' => 'Dialogue', 'members' => 'Members', 'smileys' => 'Smileys'] as $key => $label) {            $this->roomSwitch->has($key) ? $this->roomSwitch->rename($key, Lang::t($label)) : $this->roomSwitch->add($key, Lang::t($label));        }        $this->roomSwitch->current = 'dialogue';        $this->show(self::INFO);        $link = $params['link'] ?? null;        if (!$link instanceof Link) {            $server = trim((string)($params['server'] ?? self::DEFAULT_SERVER));            if (!empty($params['demo'])) {                $link = new FakeLink();            } elseif (WsLink::isUrl($server)) {                $link = new WsLink($server);            } else {                [$host, $port] = array_pad(explode(':', $server, 2), 2, '4500');                $link = new TcpLink($this->engine->tcp(), $host, (int)$port);            }            if (empty($params['demo'])) {                $this->say(self::INFO, Lang::t('Connecting to {host}…', ['host' => $server]));            }        }        $this->link = $link;        $this->chat = new ChatClient($link, (string)($params['tempid'] ?? ''), $this->admin);        $this->chat->wantedRoom = (string)($params['room'] ?? '');        $this->wire($this->chat);        $this->demo = (!empty($params['demo']) && $link instanceof FakeLink) ? new Demo($link) : null;        $this->demoTab = (string)($params['tab'] ?? '');        $this->status->text = '';        $this->ejectBtn->visible = $this->admin;        $this->refreshButtons();    }    // ---- the server's events --------------------------------------------------------------------------------    private function wire(ChatClient $c): void    {        $c->on('status', function (string $what, string $detail = ''): void {            match ($what) {                'connected' => $this->status->text = Lang::t('Connected'),                'banner' => null,          // the applet ignored the server's 100                'closed' => $this->lost(),                default => null,            };        });        $c->on('error', fn (string $message) => $this->say(self::INFO, Lang::t('Error: {message}', ['message' => $message]), 2));        $c->on('logged', function (string $id, string $nickname): void {            $this->status->text = Lang::t('Connected as {nickname}', ['nickname' => $nickname]);        });        $c->on('room', function (string $name, string $creator, int $type): void {            $label = self::roomLabel($name);            $this->tabs->has(self::ROOM) ? $this->tabs->rename(self::ROOM, $label) : $this->tabs->add(self::ROOM, $label);            $this->talk->clear();            $this->members->clear();            $this->roomMembers = [];            $this->chosen = null;            $this->setReply('', '');            $this->topic->text = Lang::t('You are talking in the room {room}', ['room' => $label])                . ($creator !== '' ? ' ' . Lang::t('Created by {creator}', ['creator' => $creator]) : '');            $this->refreshButtons();            $this->show(self::ROOM);            $this->chat?->listRooms(true);        });        $c->on('say', function (string $id, string $nickname, string $coded): void {            if (!$this->chat?->isIgnored($nickname, $id)) {                $this->addTalk(self::ROOM, $this->lineOf($id, $nickname, $coded, false, (bool)$this->times->checked));            }        });        $c->on('private', function (string $id, string $nickname, string $coded, bool $mine): void {            if (!$mine && $this->chat?->isIgnored($nickname, $id)) {                return;            }            $key = $this->openPrivate($id, $nickname, false);            if ($key !== null && $this->chat !== null) {                $time = !empty($this->privateTimes[$key]);                $this->addTalk($key, $mine ? $this->lineOf($this->chat->id, $this->chat->nickname, $coded, true, $time) : $this->lineOf($id, $nickname, $coded, false, $time));            }        });        $c->on('gone', function (string $id): void {            $key = self::PRIVATE . $id;            if (isset($this->privateNames[$key])) {                $this->privateGone[$key] = true;                $this->addTalk($key, ['text' => '0102' . Lang::t('{nickname} is disconnected', ['nickname' => $this->privateNames[$key]])]);            }        });        $c->on('refused', function (string $id, string $nickname): void {            $key = self::PRIVATE . $id;            $this->addTalk(isset($this->privateNames[$key]) ? $key : self::INFO, ['text' => '0102' . Lang::t('{nickname} no longer wants to receive private messages', ['nickname' => $nickname])]);        });        $c->on('members', function (array $list): void {            $this->roomMembers = $list;            $this->fillMembers();        });        $c->on('rooms', fn (string $code, array $rooms) => $this->fillRooms($code, $rooms));        $c->on('found', fn (string $code, array $found) => $this->fillFound($found));        $c->on('count', function (int $n): void {            $this->connected->text = $n === 0 ? Lang::t('Nobody connected!') : ($n === 1 ? Lang::t('1 person is connected') : Lang::t('{n} people are connected', ['n' => $n]));        });        $c->on('profile', function (Member $m): void {            $this->refreshPrivateInfo();            // the advice of the applet to the minors (bU[111]), once, when the server told my age            if ($m->id === ($this->chat->id ?? '') && $m->age > 0 && $m->age < 18 && !$this->advised) {                $this->advised = true;                $this->say(self::INFO, Lang::t(self::ADVICE), 2);            }        });        $c->on('info', fn (string $coded) => $this->addTalk(self::INFO, ['text' => Styled::spread($coded)]));        $c->on('ejection', function (string $victim, string $by): void {            $text = $by === '' ? Lang::t('{victim} has been ejected', ['victim' => $victim]) : Lang::t('{victim} has been ejected by {by}', ['victim' => $victim, 'by' => $by]);            $this->addTalk(self::INFO, ['text' => '0102' . $text]);        });        $c->on('motto', fn (string $motto) => $this->mottoNow->text = $motto);        $c->on('flags', function (int $flags): void {            $this->noPrivate->checked = ($flags & 2) !== 0;            $this->busy->checked = ($flags & 64) !== 0;        });        $c->on('ignores', fn () => $this->fillMembers());        $c->on('notice', function (string $kind, string ...$more): void {            $text = match ($kind) {                'banned' => Lang::t('You have been banned from the room: {room}', ['room' => self::roomLabel($more[0] ?? '')]),                'alert' => Lang::t('{n} people signal a problem in the room {room}', ['n' => $more[0] ?? '1', 'room' => self::roomLabel($more[1] ?? '')]),                'called' => Lang::t('A moderator has been warned'),                'nomodo' => Lang::t('No moderator can be reached for the moment.'),                'welcome' => Lang::t('Welcome to the room {room}', ['room' => self::roomLabel($more[0] ?? '')]),                'arrived' => Lang::t('{nickname} has arrived in the room', ['nickname' => $more[0] ?? '']),                'left' => Lang::t('{nickname} has left the room', ['nickname' => $more[0] ?? '']),                'quit' => Lang::t('{nickname} has left the live chat', ['nickname' => $more[0] ?? '']),                default => trim(($more[0] ?? '')),            };            if ($text !== '') {                $this->addTalk(self::INFO, ['text' => '0102' . $text]);            }        });        $c->on('ended', function (string $why, string $who): void {            $this->ended = true;            $text = match ($why) {                'ejected' => Lang::t('you have been ejected.'),                'ejected-by' => Lang::t('you have been ejected by {nickname}', ['nickname' => $who]),                '12' => Lang::t('The live chat is overloaded for the moment! Please try again later!'),                '13' => Lang::t('The administrator of the live chat refused you access.'),                '14' => Lang::t('You have reached the maximum number of simultaneous connections.'),                default => Lang::t('The connection settings are wrong.'),            };            $this->say(self::INFO, $text, 2);            $this->show(self::INFO);        });    }    private function lost(): void    {        $this->status->text = Lang::t('Disconnected');        if (!$this->ended) {            $this->say(self::INFO, Lang::t('The connection with the server has been interrupted'), 2);            $this->show(self::INFO);        }        $this->ended = true;        $this->send->enabled = false;        $this->pvSend->enabled = false;    }    // ---- lines ----------------------------------------------------------------------------------------------    /** The colour of a nickname, the applet's categories: woman pink, man blue, moderators green / red, busy orange. */    public static function nickColor(?Member $m): int    {        if ($m === null) {            return 5;        }        return match (true) {            $m->admin === 2 => 2,            $m->admin === 1 => 8,            ($m->state & 64) !== 0 => 4,            $m->sex === 0 => 3,            $m->sex === 1 => 1,            default => 10,        };    }    /** The tooltip colour of a category (b.if() of the applet). */    private static function tipColor(?Member $m): int    {        return match (self::nickColor($m)) {            1 => 13, 2 => 16, 3 => 14, 4 => 19, 5 => 18, 8 => 17, default => 15,        };    }    /** The tooltip of a member: "age, F / H, city" as the applet's. */    public static function tip(?Member $m): string    {        if ($m === null) {            return '';        }        if ($m->isAdmin()) {            return $m->admin === 2 ? Lang::t('Super Moderator') : Lang::t('Moderator');        }        $bits = [];        if ($m->age > 0) {            $bits[] = (string)$m->age;        }        $bits[] = match ($m->sex) {            0 => Lang::t('F'), 1 => Lang::t('M'), default => 'X',        };        if ($m->city !== '') {            $bits[] = $m->city;        }        return implode(', ', $bits);    }    /** A line of a talk: "-1CC" nickname "> ", then the coded sentence (the wave and the rainbow spread here, once). */    private function lineOf(string $id, string $nickname, string $coded, bool $mine, bool $time): array    {        $member = $this->chat->known[$id] ?? null;        $me = $this->chat->nickname ?? '';        // "#" before a nickname: somebody of another site than mine (another "provenance", the applet's mark)        $mark = self::origin($id) !== self::origin($this->chat->id ?? '') ? '#' : '';        $bold = $mine || ($me !== '' && $nickname !== $me && (str_contains($coded, $me . ' ') || str_contains($coded, $me . '>')));        return [            'text' => sprintf('-1%02d', self::nickColor($member)) . $mark . $nickname . '> ' . ($time ? "\r0100(" . date('H:i') . ') ' : '') . "\r" . Styled::spread($coded),            'bold' => $bold,            'tip' => self::tip($member),            'tipColor' => self::tipColor($member),            'data' => ['id' => $id, 'nickname' => $nickname],        ];    }    /** A line of the applet's system messages: "0102" (12 pt, red), as every line of the Options' information list. */    private function say(string $tab, string $text, int $color = 2): void    {        $this->addTalk($tab, ['text' => sprintf('01%02d', $tab === self::INFO ? 2 : $color) . $text]);    }    private function addTalk(string $tab, array $line): void    {        if ($tab === self::INFO) {            $this->info->add($line);        } elseif ($tab === self::ROOM) {            $this->talk->add($line);        } else {            $this->pages[$tab][] = $line;            $this->pages[$tab] = array_slice($this->pages[$tab], -60);            if ($tab === $this->current) {                $this->pvTalk->add($line);            }        }        if ($tab !== $this->current && ($tab !== self::ROOM || ($line['bold'] ?? false))) {            $this->tabs->notify($tab);        }    }    /** The provenance of an id: its last field ("n:n:n:origin"). */    public static function origin(string $id): string    {        $parts = explode(':', $id);        return (string)end($parts);    }    public static function roomLabel(string $name): string    {        return preg_replace_callback('/#(\d+)$/', static fn ($m) => $m[1] === '0' ? '' : ' ' . $m[1], $name) ?? $name;    }    // ---- tabs and pages -------------------------------------------------------------------------------------    public function onTabsChange(TabBar $sender, string $key): void    {        $this->show($key);    }    public function show(string $key): void    {        $this->current = $key;        if ($this->tabs->current !== $key) {            $this->tabs->choose($key);            return;   // choose() comes back here through onTabsChange        }        $private = str_starts_with($key, self::PRIVATE);        $pane = match (true) {            $key === self::ROOM => 'roomPane',            $private => 'privatePane',            $key === self::ROOMS => 'roomsPane',            $key === self::SEARCH => 'searchPane',            $key === self::FRIENDS => 'friendsPane',            default => 'optionsPane',        };        foreach (self::PANES as $id) {            $widget = $this->widget($id);            if ($widget !== null) {                $widget->visible = $id === $pane;            }        }        if ($private) {            $this->pvTalk->setItems($this->pages[$key] ?? []);            $this->pvTalk->toBottom();            $name = $this->privateNames[$key] ?? '';            $this->pvTopic->text = Lang::t('Private dialogue with {nickname}', ['nickname' => $name]);            $this->pvTime->checked = !empty($this->privateTimes[$key]);            $this->refreshPrivateInfo();            $this->autoFocus($this->pvLine);        } elseif ($key === self::ROOM) {            $this->talk->toBottom();            $this->autoFocus($this->line);        } elseif ($key === self::SEARCH) {            $this->chat?->askCount();            $this->autoFocus($this->searchText);        } elseif ($key === self::ROOMS && $this->rooms->count() === 0) {            $this->chat?->listRooms(true);        }    }    public function onSideTabsChange(TabBar $sender, string $key): void    {        $this->membersBox->visible = $key === 'members';        $this->smileysBox->visible = $key === 'smileys';    }    /**     * The field of a page, focused when the page shows: on a computer at once (the applet did); not on a phone, where a     * focus opens the on-screen keyboard over half of the screen: there a tap on the field does it.     */    private function autoFocus(\JumPHP\Gui\Widget $field): void    {        if ($this->breakpoint() !== 'xs') {            $this->setFocus($field);        }    }    /** A phone: the talk, the members or the smileys of the room, one at a time. */    public function onRoomSwitchChange(TabBar $sender, string $key): void    {        $this->roomView = in_array($key, ['dialogue', 'members', 'smileys'], true) ? $key : 'dialogue';        if ($this->roomView !== 'dialogue') {            $this->onSideTabsChange($this->sideTabs, $this->roomView);            $this->sideTabs->current = $this->roomView;        }        $this->breakpointSeen = '';    }    /** Back to the talk of the room (a phone), its line focused when asked. */    private function showTalk(bool $focus): void    {        if ($this->roomView !== 'dialogue') {            $this->roomView = 'dialogue';            $this->roomSwitch->current = 'dialogue';            $this->breakpointSeen = '';        }        if ($focus) {            $this->setFocus($this->line);        }    }    /**     * The pages follow the screen: side by side on a computer, one under the other on a phone. On a phone the room     * shows one thing at a time (roomSwitch), the line to type sits with its "Envoyer" right under the talk, and while     * one types (the on-screen keyboard is open) the talk keeps the upper part of the screen so that the line stays     * above the keyboard, whether the browser shrinks the page for it or lays the keyboard over it.     */    private function adapt(): void    {        $bp = $this->breakpoint();        $phone = $bp === 'xs';        $w = (int)$this->width();        $h = (int)$this->height();        $this->tallest[$w] = max($this->tallest[$w] ?? 0, $h);        $shrunk = $h < 0.75 * $this->tallest[$w];        $focus = $this->focused();        $typing = $phone && !$shrunk && ($focus === $this->line || $focus === $this->pvLine);        $key = $bp . '|' . $this->roomView . '|' . ($typing ? 't' : '') . '|' . $w . 'x' . $h;        if ($key === $this->breakpointSeen) {            return;        }        $this->breakpointSeen = $key;        foreach (self::PANES as $id) {            $pane = $this->widget($id);            if (!$pane instanceof Container || $id === 'roomPane') {                continue;            }            $pane->props['layout'] = $phone ? 'column' : 'row';            $pane->props['padding'] = $phone ? 4 : 6;            foreach (array_slice($pane->children, 1) as $side) {                $side->props['width'] = $phone ? 'fill' : 236;                $side->props['height'] = $phone ? 'auto' : 'fill';            }        }        // the room: side by side (the applet), or one view at a time under the bar of a phone        $this->roomPane->props['layout'] = $phone ? 'column' : 'row';        $this->roomPane->props['padding'] = $phone ? 4 : 6;        $this->roomPane->props['gap'] = $phone ? 4 : 8;        $this->roomSwitch->visible = $phone;        $this->sideTabs->visible = !$phone;        $this->roomMain->visible = !$phone || $this->roomView === 'dialogue';        $this->roomSide->visible = !$phone || $this->roomView !== 'dialogue';        $this->roomSide->props['width'] = $phone ? 'fill' : 236;        $this->roomSide->props['grow'] = $phone ? 1 : 0;        if ($phone && $this->roomView !== 'dialogue') {            $this->onSideTabsChange($this->sideTabs, $this->roomView);        }        // the line and its button: [line][Envoyer] then the options on a phone; the applet's two rows on a computer        $this->lineRow->clear();        $this->optsRow->clear();        if ($phone) {            $this->lineRow->add($this->line, $this->send);            $this->optsRow->add($this->privateMsg, $this->color, $this->size, $this->noPrivate);        } else {            $this->lineRow->add($this->privateMsg, $this->line);            $this->optsRow->add($this->noPrivate, $this->color, $this->size, $this->optsSpacer, $this->send);        }        $this->pvLineRow->clear();        $this->pvOptsRow->clear();        $this->pvLineRow->add($this->pvLine);        if ($phone) {            $this->pvLineRow->add($this->pvSend);            $this->pvOptsRow->add($this->pvColor, $this->pvSize, $this->pvSpacer, $this->closeIgnore, $this->closePrivate);        } else {            $this->pvOptsRow->add($this->pvColor, $this->pvSize, $this->pvSpacer, $this->closeIgnore, $this->closePrivate, $this->pvSend);        }        // typing on a phone: the talk takes the upper part only, the line right under it, the rest is the keyboard's        $talkHeight = $typing ? max(120, (int)round($h / max(0.01, $this->theme->scale) * 0.36)) : 'fill';        $this->talkBox->props['height'] = $talkHeight;        $this->pvBox->props['height'] = $talkHeight;        $this->topic->visible = !$typing;        $this->pvTopic->visible = !$typing;        $this->optsRow->visible = !$typing;        $this->pvOptsRow->visible = !$typing;        if ($typing) {            $this->talk->toBottom();            $this->pvTalk->toBottom();        }        // a phone keeps the talk of a private conversation: its information panel goes away        $this->privatePane->children[1]->visible = !$phone;        $this->invalidate();    }    /** The tab of a private talk with $id (made when missing, at most 20). */    public function openPrivate(string $id, string $nickname, bool $choose = true): ?string    {        $key = self::PRIVATE . $id;        if (!isset($this->privateNames[$key])) {            if (count($this->privateNames) >= self::MAX_PRIVATES) {                $this->say(self::INFO, Lang::t('Too many private talks open.'), 2);                return null;            }            $this->privateNames[$key] = $nickname;            $this->pages[$key] = [];            $this->privateTimes[$key] = (bool)$this->times->checked;   // "Toujours afficher l'heure en pv"            $this->tabs->add($key, $nickname);            $known = $this->chat->known[$id] ?? null;            if ($known === null || ($known->age === 0 && !$known->isAdmin())) {                $this->chat?->whois($id);            }        }        unset($this->privateGone[$key]);        if ($choose) {            $this->show($key);        } else {            $this->tabs->notify($key);        }        return $key;    }    private function closePrivate(string $key, bool $ignore = false): void    {        if (!isset($this->privateNames[$key])) {            return;        }        $id = substr($key, strlen(self::PRIVATE));        $m = $this->chat->known[$id] ?? null;        if ($ignore && $m !== null) {            $this->chat?->ignore($m);        }        unset($this->privateNames[$key], $this->pages[$key], $this->privateGone[$key], $this->privateTimes[$key]);        $this->tabs->remove($key);        if ($this->current === $key) {            $this->show($this->tabs->has(self::ROOM) ? self::ROOM : self::INFO);        }    }    private function refreshPrivateInfo(): void    {        if (!str_starts_with($this->current, self::PRIVATE)) {            return;        }        $id = substr($this->current, strlen(self::PRIVATE));        $m = $this->chat->known[$id] ?? null;        $this->pvWho->text = $this->privateNames[$this->current] ?? '';        $this->pvAge->text = $m !== null && $m->age > 0 ? Lang::t('Age: {age}', ['age' => $m->age]) : '';        $this->pvSex->text = $m === null ? '' : ($m->isAdmin() ? Lang::t('moderator') : ($m->sex === 0 ? Lang::t('woman') : ($m->sex === 1 ? Lang::t('man') : '')));        $this->pvCity->text = $m !== null && $m->city !== '' ? Lang::t('City: {city}', ['city' => $m->city]) : '';    }    public function onClosePrivateClick(mixed $sender = null): void    {        $this->closePrivate($this->current);    }    public function onCloseIgnoreClick(mixed $sender = null): void    {        $this->closePrivate($this->current, true);    }    public function onPvTimeChange(mixed $sender = null, mixed $checked = null): void    {        $this->privateTimes[$this->current] = (bool)$this->pvTime->checked;    }    public function onPvFriendClick(mixed $sender = null): void    {        $id = substr($this->current, strlen(self::PRIVATE));        $name = $this->privateNames[$this->current] ?? '';        if ($name === '') {            return;        }        $this->friendList = array_values(array_filter($this->friendList, static fn ($f) => mb_strtolower($f['nickname']) !== mb_strtolower($name)));        $this->friendList[] = ['nickname' => $name, 'id' => $id];        $this->engine->storage->writeJson('friends.json', $this->friendList);        $this->fillFriends();        $this->say(self::INFO, Lang::t('{nickname} is now in your friends.', ['nickname' => $name]));    }    // ---- typing ---------------------------------------------------------------------------------------------    /** The sentence as the applet sent it: trimmed, 192 characters at most, "FFCC" before. */    public function coded(string $text, ?AwtChoice $size = null, ?AwtChoice $color = null): string    {        $code = self::SIZE_CODES[(int)($size ?? $this->size)->selected] ?? 1;        return sprintf('%02d%02d', $code, max(0, min(11, (int)($color ?? $this->color)->selected))) . mb_substr(Styled::strip(trim($text)), 0, 192);    }    public function onSendClick(mixed $sender = null): void    {        $text = trim($this->line->text);        if ($text === '' || $this->chat === null || !$this->chat->connected) {            return;        }        $this->line->text = '';        // "Message privé" checked: to the member clicked in the room        if ($this->privateMsg->checked && $this->replyId !== '') {            $this->chat->whisper($this->replyId, $this->coded($text));            $this->openPrivate($this->replyId, $this->replyNick, false);            $this->setReply('', '');            return;        }        // "/nickname[:N] text": a private message typed in the room        if (preg_match('/^\/([^\s:]+)(?::(\d+))?\s+(.+)$/u', $text, $m)) {            $this->chat->whisper($m[1] . ':' . ($m[2] !== '' ? $m[2] : '0'), $this->coded($m[3]));            return;        }        $this->chat->say($this->coded($text));        $this->setReply('', '');        $this->talk->toBottom();    }    public function onPvSendClick(mixed $sender = null): void    {        $text = trim($this->pvLine->text);        if ($text === '' || $this->chat === null || !$this->chat->connected || !str_starts_with($this->current, self::PRIVATE)) {            return;        }        $this->pvLine->text = '';        if (!empty($this->privateGone[$this->current])) {            $this->addTalk($this->current, ['text' => '0102' . Lang::t('{nickname} is disconnected', ['nickname' => $this->privateNames[$this->current] ?? ''])]);            return;        }        $this->chat->whisper(substr($this->current, strlen(self::PRIVATE)), $this->coded($text, $this->pvSize, $this->pvColor));    }    /** A smiley chosen in a Smileys panel goes at the end of the line being typed. */    public function onSmileyPicked(SmileyGrid $sender, string $code): void    {        $field = str_starts_with($this->current, self::PRIVATE) ? $this->pvLine : $this->line;        $field->text = rtrim($field->text) . ($field->text === '' ? '' : ' ') . $code . ' ';        if ($field === $this->line && $this->breakpoint() === 'xs') {            $this->showTalk(false);     // a phone: back to the talk, the smiley in the line        }        $this->setFocus($field);    }    /** A click on a line of the room: "nickname> " in front of the line typed, "Message privé" possible. */    public function onTalkClick(mixed $sender = null): void    {        $item = $this->talk->items()[$this->talk->hover] ?? null;        $data = $item['data'] ?? null;        if (!is_array($data) || $data['id'] === ($this->chat->id ?? '') || str_starts_with($data['id'], '*')) {            return;        }        $this->setReply($data['id'], $data['nickname']);        $text = preg_replace('/^\S+> /u', '', $this->line->text) ?? '';        $this->line->text = $data['nickname'] . '> ' . $text;        $this->setFocus($this->line);    }    private function setReply(string $id, string $nickname): void    {        $this->replyId = $id;        $this->replyNick = $nickname;        $known = $id !== '' ? ($this->chat->known[$id] ?? null) : null;        $state = $known === null ? 0 : $known->state;        $this->privateMsg->enabled = $id !== '' && (($state & 4) === 0 || $this->admin);        if ($id === '') {            $this->privateMsg->checked = false;        }    }    public function onPrivateMsgChange(mixed $sender = null, mixed $checked = null): void    {        if ($this->privateMsg->checked && $this->replyNick !== '') {            $this->line->text = preg_replace('/^' . preg_quote($this->replyNick, '/') . '> /u', '', $this->line->text) ?? '';            $this->topic->text = Lang::t('You are writing a private message to {nickname}', ['nickname' => $this->replyNick]);        } elseif ($this->chat !== null) {            $this->topic->text = Lang::t('You are talking in the room {room}', ['room' => self::roomLabel($this->chat->roomName)]);        }    }    /** A double click on a line of the room: a private talk with its author. */    public function onTalkActivate(ChatList $sender, int $index, array $item): void    {        $data = $item['data'] ?? null;        if (is_array($data) && $data['id'] !== ($this->chat->id ?? '')) {            $this->openPrivate($data['id'], $data['nickname']);        }    }    // ---- members of the room --------------------------------------------------------------------------------    private function fillMembers(): void    {        $list = $this->roomMembers;        $byNick = (bool)$this->sortNick->checked;        $rank = static fn (Member $m) => $m->admin === 2 ? 0 : ($m->admin === 1 ? 1 : 2);        // the applet's sort "41": the moderators first, then by sex ("Sexe"), then by nickname; "1": by nickname        usort($list, static fn (Member $a, Member $b) => $byNick            ? strcasecmp($a->nickname, $b->nickname)            : [$rank($a), $a->sex, mb_strtolower($a->nickname)] <=> [$rank($b), $b->sex, mb_strtolower($b->nickname)]);        $items = [];        $women = $men = 0;        foreach ($list as $m) {            $women += $m->sex === 0 ? 1 : 0;            $men += $m->sex === 1 ? 1 : 0;            $ignored = $this->chat?->isIgnored($m->nickname, $m->id) ?? false;            $items[] = [                'text' => sprintf('-1%02d', $ignored ? 5 : self::nickColor($m)) . (self::origin($m->id) !== self::origin($this->chat->id ?? '') ? '#' : '') . $m->nickname,                'tip' => self::tip($m),                'tipColor' => self::tipColor($m),                'data' => $m,            ];        }        $this->members->setItems($items);        $this->members->selected = -1;        foreach ($items as $i => $item) {            if ($this->chosen !== null && $item['data']->id === $this->chosen->id) {                $this->members->selected = $i;            }        }        if ($this->members->selected < 0) {            $this->chosen = null;        }        $this->membersCount->text = Lang::t('{n} people - {w} F - {m} M', ['n' => count($list), 'w' => $women, 'm' => $men]);        $this->refreshButtons();    }    public function onSortChange(mixed $sender = null, mixed $checked = null): void    {        $this->fillMembers();    }    public function onMembersChange(ChatList $sender, int $index, array $item): void    {        $this->chosen = $item['data'] ?? null;        if ($this->chosen !== null && $this->chosen->age === 0 && !$this->chosen->isAdmin()) {            $this->chat?->whois($this->chosen->id);        }        $this->refreshButtons();    }    public function onMembersActivate(ChatList $sender, int $index, array $item): void    {        $m = $item['data'] ?? null;        if ($m instanceof Member && $m->id !== ($this->chat->id ?? '')) {            $this->openPrivate($m->id, $m->nickname);        }    }    private function refreshButtons(): void    {        $other = $this->chosen !== null && $this->chosen->id !== ($this->chat->id ?? '');        $this->privateBtn->enabled = $other && (($this->chosen->state & 4) === 0 || $this->admin);        $this->ignoreBtn->enabled = $other;        $this->ignoreBtn->text = $other && $this->chat?->isIgnored($this->chosen->nickname, $this->chosen->id) ? Lang::t('Allow') : Lang::t('Ignore');        $this->ejectBtn->visible = $this->admin;        $this->ejectBtn->enabled = $other && $this->admin;        $this->callModo->visible = !$this->admin;    }    public function onPrivateClick(mixed $sender = null): void    {        if ($this->chosen !== null) {            $this->openPrivate($this->chosen->id, $this->chosen->nickname);        }    }    public function onIgnoreClick(mixed $sender = null): void    {        if ($this->chosen !== null && $this->chat !== null) {            $this->chat->ignore($this->chosen, !$this->chat->isIgnored($this->chosen->nickname, $this->chosen->id));        }    }    public function onEjectClick(mixed $sender = null): void    {        if ($this->chosen !== null && $this->admin) {            $this->chat?->eject($this->chosen);        }    }    public function onCallModoClick(mixed $sender = null): void    {        $this->chat?->callModerator();    }    // ---- rooms ----------------------------------------------------------------------------------------------    public function onPermanentRoomsClick(mixed $sender = null): void    {        $this->roomsStatus->text = Lang::t('Loading…');        $this->chat?->listRooms(true);    }    public function onPublicRoomsClick(mixed $sender = null): void    {        $this->roomsStatus->text = Lang::t('Loading…');        $this->chat?->listRooms(false);    }    /** @param list<array{name: string, creator: string, count: int}> $rooms */    private function fillRooms(string $code, array $rooms): void    {        usort($rooms, static fn ($a, $b) => strcasecmp($a['name'], $b['name']));        $items = [];        foreach ($rooms as $room) {            $items[] = [                'text' => '0100' . self::roomLabel($room['name']) . " (\r0102" . $room['count'] . "\r0100)",                'tip' => $room['creator'] !== '' ? Lang::t('Created by {creator}', ['creator' => $room['creator']]) : '',                'data' => $room['name'],            ];        }        $this->rooms->setItems($items);        $this->rooms->selected = -1;        $this->joinRoom->enabled = false;        $n = count($rooms);        $this->roomsStatus->text = $code === '94'            ? ($n < 2 ? Lang::t('{n} permanent room', ['n' => $n]) : Lang::t('{n} permanent rooms', ['n' => $n]))            : ($n < 2 ? Lang::t('{n} public room', ['n' => $n]) : Lang::t('{n} public rooms', ['n' => $n]));    }    public function onRoomsChange(ChatList $sender, int $index, array $item): void    {        $this->joinRoom->enabled = true;    }    public function onRoomsActivate(ChatList $sender, int $index, array $item): void    {        $this->chat?->send('40', (string)$item['data'], '0');    }    public function onJoinRoomClick(mixed $sender = null): void    {        $item = $this->rooms->selectedItem();        if ($item !== null) {            $this->chat?->send('40', (string)$item['data'], '0');        }    }    public function onCreateRoomClick(mixed $sender = null): void    {        $name = trim($this->newRoom->text);        if ($name !== '' && $this->chat !== null) {            $this->chat->send('40', Styled::strip($name), $this->newPrivate->checked ? '1' : '0');            $this->newRoom->text = '';        }    }    public function onJoinPrivateClick(mixed $sender = null): void    {        $name = trim($this->privateRoom->text);        if ($name !== '' && $this->chat !== null) {            $this->chat->send('40', Styled::strip($name), '1');            $this->privateRoom->text = '';        }    }    // ---- connected people (search) --------------------------------------------------------------------------    public function onSearchClick(mixed $sender = null): void    {        $text = mb_strtolower(trim($this->searchText->text));        if ($text !== '' && (mb_strlen($text) < 2 || !preg_match('/^[\p{L}\p{N}._-]+$/u', $text))) {            $this->searchStatus->text = Lang::t('2 letters or digits at least');            return;        }        $this->searchStatus->text = Lang::t('Search in progress ...');        $this->found->clear();        $this->foundChosen = null;        $this->foundPrivate->enabled = $this->foundJoin->enabled = $this->foundIgnore->enabled = false;        $this->chat?->search(mb_substr($text, 0, 20), self::AGE_BANDS[(int)$this->searchAge->selected] ?? -2,            self::SEX_FILTERS[(int)$this->searchSex->selected] ?? -2, (int)$this->searchBy->selected === 1);    }    /** @param list<Member> $found */    private function fillFound(array $found): void    {        usort($found, static fn (Member $a, Member $b) => [$a->sex, mb_strtolower($a->nickname)] <=> [$b->sex, mb_strtolower($b->nickname)]);        $items = [];        foreach ($found as $m) {            $where = $m->where === '*' ? "\r0102" . Lang::t('(in private)') : ($m->where !== '' ? "\r0100(" . Lang::t('room {room}', ['room' => self::roomLabel($m->where)]) . ')' : '');            $items[] = [                'text' => sprintf('-1%02d', self::nickColor($m)) . $m->nickname . ' - ' . $where,                'tip' => self::tip($m),                'tipColor' => self::tipColor($m),                'data' => $m,            ];        }        $this->found->setItems($items);        $this->found->selected = -1;        $n = count($found);        $this->searchStatus->text = $n === 0 ? Lang::t('Nobody matches the criteria') : ($n === 1 ? Lang::t('1 person matches the criteria') : Lang::t('{n} people match the criteria', ['n' => $n]));        if ($this->current !== self::SEARCH) {            $this->tabs->notify(self::SEARCH);        }    }    public function onFoundChange(ChatList $sender, int $index, array $item): void    {        $this->foundChosen = $item['data'] ?? null;        $me = $this->foundChosen?->id === ($this->chat->id ?? '');        $this->foundPrivate->enabled = $this->foundChosen !== null && !$me && ($this->foundChosen->state === 0 || $this->admin);        $this->foundIgnore->enabled = $this->foundChosen !== null && !$me;        $this->foundJoin->enabled = $this->foundChosen !== null && $this->foundChosen->where !== '' && $this->foundChosen->where !== '*';    }    public function onFoundActivate(ChatList $sender, int $index, array $item): void    {        $m = $item['data'] ?? null;        if ($m instanceof Member && $m->id !== ($this->chat->id ?? '')) {            $this->openPrivate($m->id, $m->nickname);        }    }    public function onFoundPrivateClick(mixed $sender = null): void    {        if ($this->foundChosen !== null) {            $this->openPrivate($this->foundChosen->id, $this->foundChosen->nickname);        }    }    public function onFoundIgnoreClick(mixed $sender = null): void    {        if ($this->foundChosen !== null && $this->chat !== null) {            $this->chat->ignore($this->foundChosen, !$this->chat->isIgnored($this->foundChosen->nickname, $this->foundChosen->id));        }    }    public function onFoundJoinClick(mixed $sender = null): void    {        if ($this->foundChosen !== null && $this->foundChosen->where !== '' && $this->foundChosen->where !== '*') {            $this->chat?->send('40', $this->foundChosen->where, '0');        }    }    // ---- options and friends --------------------------------------------------------------------------------    /** "Valider": the colour chosen in the picker becomes the colour of the whole window (the applet's setBackground). */    public function onApplyClick(mixed $sender = null): void    {        $face = self::faceOption($this->picker->selected);        $this->engine->options->set('face', $face);        $this->applyFace($face);    }    /** A '#rrggbb' kept in the options, the applet's grey when it is not one. */    private static function faceOption(string $face): string    {        return preg_match('/^#[0-9a-f]{6}$/i', $face) ? strtolower($face) : Awt::FACE;    }    private function applyFace(string $face): void    {        Awt::$face = $face;        $this->theme->colors['background'] = Awt::$face;        $this->theme->colors['track'] = Awt::$face;        if ($this->root !== null) {            $this->root->props['background'] = Awt::$face;        }    }    /** The items of the drop-down lists are not translated by the engine (only the widgets' own texts). */    private function translateItems(): void    {        foreach ($this->itemKeys as $id => $keys) {            $combo = $this->widget($id);            if ($combo instanceof AwtChoice) {                $combo->items = array_map(static fn (string $item) => Lang::t($item), $keys);            }        }    }    /** Another language: kept in the options, the texts translated again at once (the connection stays). */    public function onLanguageChange(mixed $sender = null, mixed ...$args): void    {        $code = array_keys(self::LANGUAGES)[(int)$this->language->selected] ?? 'fr';        $this->engine->options->set('lang', $code);        $this->engine->lang->setLang($code);        $this->translateItems();        foreach ([[self::ROOMS, 'Rooms'], [self::SEARCH, 'Connected'], [self::INFO, 'Options'], [self::FRIENDS, 'Friends']] as [$key, $label]) {            $this->tabs->rename($key, Lang::t($label));        }        $this->sideTabs->rename('members', Lang::t('Members'));        $this->sideTabs->rename('smileys', Lang::t('Smileys'));        foreach (['dialogue' => 'Dialogue', 'members' => 'Members', 'smileys' => 'Smileys'] as $key => $label) {            $this->roomSwitch->rename($key, Lang::t($label));        }        $this->fillMembers();        $this->fillFriends();        if ($this->chat !== null && $this->chat->roomName !== '') {            $this->topic->text = Lang::t('You are talking in the room {room}', ['room' => self::roomLabel($this->chat->roomName)]);        }        $this->show($this->current);        $this->fontScale = 0;        // the fonts again (Japanese, Chinese and Korean have their own)        $this->invalidate();    }    /** "Configuration par défaut": the grey of the applet, no time in the private talks. */    public function onDefaultsClick(mixed $sender = null): void    {        $this->picker->selected = Awt::FACE;        $this->onApplyClick();        $this->times->checked = false;        $this->onTimesChange();    }    /** "Aide": the help of the chat (the original help of 1999, on the app's site). */    public function onHelpClick(mixed $sender = null): void    {        if (\JumPHP\Kernel\Platform::isWasm()) {            $this->engine->bridge->send('open', ['url' => self::HELP_URL]);        } elseif (\JumPHP\Native\Native::supports('openUrl')) {            \JumPHP\Native\Shell::openUrl(self::HELP_URL);        }    }    public function onTimesChange(mixed $sender = null, mixed $checked = null): void    {        $this->engine->options->set('times', (bool)$this->times->checked);    }    public function onFlagsChange(mixed $sender = null, mixed $checked = null): void    {        $this->chat?->setFlags(($this->noPrivate->checked ? 2 : 0) | ($this->busy->checked ? 64 : 0));    }    public function onMottoClick(mixed $sender = null): void    {        $this->chat?->setMotto($this->motto->text);        $this->motto->text = '';    }    private function fillFriends(): void    {        $items = [];        foreach ($this->friendList as $f) {            $items[] = ['text' => '-101' . $f['nickname'], 'data' => $f];        }        if ($items === []) {            $items[] = ['text' => '0105' . Lang::t('Your list is empty: "Add to my friends" in a private talk.')];        }        $this->friends->setItems($items);        $this->friendTalk->enabled = false;        $this->friendRemove->enabled = false;    }    public function onFriendsChange(ChatList $sender, int $index, array $item): void    {        $this->friendTalk->enabled = isset($item['data']);        $this->friendRemove->enabled = isset($item['data']);    }    public function onFriendsActivate(ChatList $sender, int $index, array $item): void    {        $this->onFriendTalkClick();    }    public function onFriendTalkClick(mixed $sender = null): void    {        $f = $this->friends->selectedItem()['data'] ?? null;        if (is_array($f)) {            // a friend's id changes at each visit: the server finds him by "nickname:origin"            $this->openPrivate($f['nickname'] . ':0', $f['nickname']);        }    }    public function onFriendRemoveClick(mixed $sender = null): void    {        $f = $this->friends->selectedItem()['data'] ?? null;        if (is_array($f)) {            $this->friendList = array_values(array_filter($this->friendList, static fn ($x) => $x['nickname'] !== $f['nickname']));            $this->engine->storage->writeJson('friends.json', $this->friendList);            $this->fillFriends();        }    }    public function onQuitClick(mixed $sender = null): void    {        $this->ended = true;        $this->chat?->close();        $this->scenes()->switchTo(LoginForm::class, [], false);    }    protected function tick(float $dt): void    {        Awt::keepCrisp($this, $this->fontScale);        $this->adapt();        $this->demo?->tick($dt, $this);        if ($this->link instanceof WsLink) {            $this->link->poll();        }        $this->chat?->tick($dt);        $this->countEvery -= $dt;        if ($this->current === self::SEARCH && $this->countEvery <= 0) {            $this->countEvery = 30;            $this->chat?->askCount();        }    }    public function exit(): void    {        $this->ended = true;        $this->chat?->close();        $this->chat = null;        parent::exit();    }}