Commit 8cb1f74b authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(game): toasts sent over the bus were dropped; friend rows get game buttons

Nothing listened for bus.emit('toast'), so sync errors, login-required notes
and group messages silently vanished. engine.js now shows them with a new
ui.toast (parchment note with a sticker and a sound). Friend rows: Challenge is
a green game button, chat a sticker button. shoot.mjs --with-friend makes a
befriended second throwaway account.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 6abd77c5
......@@ -100,3 +100,21 @@ export function roomHero(gameKey, { title = '' } = {}) {
</div>
</div>`;
}
/**
* A toast: a parchment note dropped onto the table for a moment.
* kind: 'info' | 'error' | 'success' | 'reward'. Also reachable as
* bus.emit('toast', { text, type, duration }) (wired in engine.js).
*/
export function toast(text, { type = 'info', duration = 2600 } = {}) {
const box = document.getElementById('toast-container');
if (!box || !text) return;
const el = document.createElement('div');
el.className = `toast${type === 'error' ? ' error' : type === 'reward' || type === 'success' ? ' reward' : ''}`;
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
const sticker = type === 'error' ? 'cross' : type === 'reward' ? 'coin' : type === 'success' ? 'check' : 'bell';
el.innerHTML = `<span style="display:inline-flex;align-items:center;gap:8px;">${gicon(sticker, 26)}<span>${esc(text)}</span></span>`;
box.appendChild(el);
audio.play(type === 'error' ? 'error' : 'notification');
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 260); }, duration);
}
......@@ -10,6 +10,7 @@ import { getRecoverableMatch } from './core/match-session.js';
import * as tournamentSession from './core/tournament-session.js';
import * as nativeBridge from './core/native-bridge.js';
import * as feel from './core/feel.js';
import { toast } from './core/ui.js';
async function boot() {
// Handle OAuth callback — extract tokens from URL hash before anything else
......@@ -39,6 +40,8 @@ async function boot() {
await theme.load();
audio.init();
feel.init();
// Any module can drop a note on the table with bus.emit('toast', {...}).
bus.on('toast', (p = {}) => toast(p.text || p.message, { type: p.type, duration: p.duration }));
if (window.flutter_bridge || window.IS_NATIVE_APP) nativeBridge.init();
input.init();
scene.init();
......
......@@ -8,7 +8,7 @@ import * as store from '../../../core/store.js';
import * as juice from '../../../core/juice.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js';
import { icon, gicon, GAME_MARK } from '../../../core/icons.js';
import { screenHead, emptyState, loadingRows, errorState, avatar, esc } from '../../../core/ui.js';
import { activityRows, activityEmpty, ACTIVITY_CSS } from './activity.js';
......@@ -371,8 +371,8 @@ function renderFriendCard(f, arrived = new Set()) {
<div class="row-sub"><span class="${f.is_online ? 'sfr-on' : ''}">${f.is_online ? t('common.online') : t('common.offline')}</span>${f.level ? `${sep}${t('common.level', { n: num(f.level) })}` : ''}</div>
</div>
<div class="friend-actions sfr-actions">
${f.is_online ? `<button class="icon-btn sfr-swords" data-invite="${f.id}" aria-label="${t('chat.challenge_title', { name: esc(name) })}">${icon('swords', 20)}</button>` : ''}
<button class="icon-btn" data-chat="${f.id}" aria-label="${t('profile.message')}">${icon('message', 20)}</button>
${f.is_online ? `<button class="btn btn-primary btn-sm sfr-challenge" data-invite="${f.id}" aria-label="${t('chat.challenge_title', { name: esc(name) })}">${gicon('swords', 24)}${t('home.side_challenge')}</button>` : ''}
<button class="icon-btn" data-chat="${f.id}" aria-label="${t('profile.message')}">${gicon('chat', 28)}</button>
<button class="icon-btn sfr-quiet" data-remove="${f.id}" aria-label="${t('social.remove_friend')}">${icon('user-minus', 18)}</button>
</div>
</div>
......@@ -719,6 +719,8 @@ const STYLE = `
@keyframes sfrFlip { 0% { transform: perspective(700px) rotateX(-90deg); opacity: 0.3; } 100% { transform: none; opacity: 1; } }
.sfr-actions { display: flex; align-items: center; gap: var(--s-2); flex-shrink: 0; }
.sfr-swords { color: var(--red); }
.sfr-challenge { padding-inline: 10px 12px; gap: 4px; }
.sfr-quiet { width: 40px; height: 40px; opacity: 0.85; }
.sfr-quiet { background: transparent; border-color: transparent; color: var(--text-muted); }
.sfr-sm { min-height: 44px; padding: 0 var(--s-4); font-size: var(--fs-sm); flex-shrink: 0; }
.sfr-done { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-sm); font-weight: 600; color: var(--win); }
......
......@@ -26,6 +26,7 @@ const W = +argOf('--width', 390), H = +argOf('--height', 844);
const ONLY = (argOf('--only', '') || '').split(',').filter(Boolean);
const GUEST = args.includes('--guest');
const ANON = args.includes('--anon'); // signed out: the first-run screens
const WITH_FRIEND = args.includes('--with-friend'); // a second account, befriended and online
const RAW_THEME = args.includes('--raw-theme');
const PUSH = argOf('--push', '');
const PARAMS = argOf('--params', '{}');
......@@ -78,6 +79,7 @@ async function sb(p, { method = 'GET', body } = {}) {
}
let account = null;
let friend = null;
async function createAccount() {
const r = await fetch(`${BASE}/api/auth.php`, {
......@@ -94,7 +96,23 @@ async function createAccount() {
account.profile = profile;
}
async function makeFriend() {
const r = await fetch(`${BASE}/api/auth.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'guest' }),
}).then(x => x.json());
if (!r.profile?.id) return;
friend = { id: r.profile.id };
await sb(`profiles?id=eq.${friend.id}`, { method: 'PATCH', body: { display_name: 'يوسف', elo_rapid: 1612, level: 12, is_online: true, last_seen_at: new Date().toISOString() } });
await sb('friendships', { method: 'POST', body: { requester_id: account.id, addressee_id: friend.id, status: 'accepted' } });
}
async function removeAccount() {
if (friend) {
await sb(`friendships?requester_id=eq.${account?.id}`, { method: 'DELETE' }).catch(() => {});
await sb(`profiles?id=eq.${friend.id}`, { method: 'DELETE' }).catch(() => {});
await fetch(`${SB}/auth/v1/admin/users/${friend.id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
}
if (!account) return;
const id = account.id;
for (const p of [`chat_messages?sender_id=eq.${id}`, `matchmaking_queue?player_id=eq.${id}`,
......@@ -120,6 +138,7 @@ fs.mkdirSync(OUT, { recursive: true });
let browser;
try {
if (!ANON) await createAccount();
if (WITH_FRIEND && account) await makeFriend();
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--font-render-hinting=none'] });
const page = await browser.newPage();
const errors = [];
......@@ -155,7 +174,8 @@ try {
await page.goto(BASE + '/', { waitUntil: 'networkidle2', timeout: 60000 });
await new Promise(r => setTimeout(r, 2000));
const list = PUSH ? [{ id: NAME, push: [PUSH, JSON.parse(PARAMS)], settle: SETTLE }] : SCENES;
// __FRIEND__ in --params stands for the --with-friend account's id.
const list = PUSH ? [{ id: NAME, push: [PUSH, JSON.parse(PARAMS.replaceAll('__FRIEND__', friend?.id || ''))], settle: SETTLE }] : SCENES;
for (const s of list) {
if (!PUSH && ONLY.length && !ONLY.includes(s.id)) continue;
try {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment