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> 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> the lines of each private talk */ private array $pages = []; /** @var array private tab key => nickname */ private array $privateNames = []; /** @var array private talks whose correspondent left */ private array $privateGone = []; /** @var array private talks with the time shown */ private array $privateTimes = []; private string $current = self::INFO; private ?Member $chosen = null; private ?Member $foundChosen = null; /** @var list 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 */ 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 $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 $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(); } }