import { LANGUAGE_ICON, FRIENDS_ICON, ACCOUNT_ICON, SOUND_ON_ICON, SOUND_OFF_ICON } from './screens/icons.js';
import { LANGUAGES, languageLabel } from './languages.js';
import { getSettings, updateSettings } from './settings.js';
import { renderThemeButton } from './themeButton.js';
import { getUser, onAuthChange } from './auth.js';
import { onCountsChange, getCounts } from './counts.js';
import { attachTooltips } from './tooltip.js';
import { escapeHtml } from './util.js';
// The top-right cluster: the typing language, the theme, and who you are --
// Friends sits directly beside the account control because the two are the
// same thing, your identity and who it connects you to. These are the app's own chrome --
// they belong together and away from the bottom rail's per-screen
// navigation. Screens with their own top-right controls (the typing screen's
// Restart and Zen Finish) pass them in as `extras` so everything shares one
// row instead of each fixed-positioning itself into a collision.
/// The letter shown on an account's avatar. Falls back to a dot rather than
/// an empty circle for a name that starts with something unprintable.
function initialOf(username) {
const ch = (username || '').trim()[0];
return ch ? ch.toUpperCase() : '\u00b7';
}
/// A stable colour per account, from a hash of the whole name rather than the
/// first letter, so two people whose names start alike still look different.
function avatarColour(username) {
let hash = 0;
for (const ch of username || '') hash = (hash * 31 + ch.codePointAt(0)) >>> 0;
return `hsl(${hash % 360} 65% 45%)`;
}
export function renderTopRail(root, { onShowAccount, onShowFriends, extras = [] } = {}) {
const wrap = document.createElement('div');
wrap.className = 'top-rail';
root.appendChild(wrap);
// Row one holds every icon. Row two holds the sign-in links, which used
// to sit inline and made the row far wider than the icons needed.
const icons = document.createElement('div');
icons.className = 'top-rail-icons';
wrap.appendChild(icons);
for (const el of extras) icons.appendChild(el);
const langGroup = document.createElement('div');
langGroup.className = 'lang-group';
langGroup.innerHTML = `
Typing language
${LANGUAGES.map(l => `
`).join('')}
Applies to Words, Timed, Zen and Practice.
`;
icons.appendChild(langGroup);
const cleanupTheme = renderThemeButton(icons);
// Keystroke sound. It was a line inside the settings dialog and off by
// default, so the usual way to discover it was to be told it existed.
const sound = document.createElement('div');
sound.className = 'sound-control';
icons.appendChild(sound);
const friends = document.createElement('div');
friends.className = 'friends-control';
if (onShowFriends) {
// The count sits under the icon rather than beside it, so it does not
// widen the row and reads as a property of the button above it.
friends.innerHTML = `
`;
}
icons.appendChild(friends);
// The account control, immediately right of Friends: your identity and
// who it connects you to, in that order.
const accountIcon = document.createElement('div');
accountIcon.className = 'account-icon-control';
icons.appendChild(accountIcon);
const auth = document.createElement('div');
auth.className = 'auth-control';
wrap.appendChild(auth);
attachTooltips(wrap);
// Language ------------------------------------------------------------
const langPopover = langGroup.querySelector('.lang-popover');
function paintLang() {
const current = getSettings().language || 'en';
langGroup.querySelectorAll('.lang-item').forEach(item => {
item.classList.toggle('active', item.dataset.lang === current);
});
langGroup.querySelector('[data-action="lang"]').dataset.tooltip = `Typing language: ${languageLabel(current)}`;
}
paintLang();
langGroup.querySelector('[data-action="lang"]').addEventListener('click', e => {
e.stopPropagation();
langPopover.hidden = !langPopover.hidden;
});
langGroup.querySelectorAll('.lang-item').forEach(item => {
item.addEventListener('click', () => {
updateSettings({ language: item.dataset.lang });
paintLang();
langPopover.hidden = true;
});
});
// Identity ------------------------------------------------------------
function paintAuth() {
const user = getUser();
// Signed out it is a plain person icon; signed in it is the account's
// own picture. There is no avatar upload, so the picture is built
// from the name: the initial over a colour derived from the whole
// username, which gives every account a stable, distinct mark.
accountIcon.innerHTML = user
? ``
: ``;
const avatarBtn = accountIcon.querySelector('.rail-avatar');
if (avatarBtn) avatarBtn.style.setProperty('--avatar-colour', avatarColour(user.username));
// Signed in, the name is already on the avatar's tooltip, so the row
// below is only needed while signed out.
auth.hidden = !!user;
auth.innerHTML = user
? ''
: `
|
`;
attachTooltips(auth);
attachTooltips(accountIcon);
[...auth.querySelectorAll('button'), ...accountIcon.querySelectorAll('button')]
.forEach(btn => btn.addEventListener('click', () => onShowAccount?.()));
}
// The rail is one row when signed in and two when signed out, so its
// height is not a constant the stylesheet can hold. Everything that has to
// start below it - the content column, and the close button at that
// column's top-right - reads this. Without it the close button sat under
// the rail again the moment the rail grew a second row.
function publishHeight() {
const h = Math.round(wrap.getBoundingClientRect().height);
if (h > 0) document.documentElement.style.setProperty('--top-rail-h', `${h}px`);
}
function paintAuthAndMeasure() {
paintAuth();
// After layout, not during: the row has to exist before it can be
// measured.
requestAnimationFrame(publishHeight);
}
paintAuthAndMeasure();
const unsubscribeAuth = onAuthChange(paintAuthAndMeasure);
window.addEventListener('resize', publishHeight);
// Sound ---------------------------------------------------------------
// On by default, set to the mechanical tone. The speaker here is how it
// gets turned off, which is why it is in the rail rather than buried in
// the settings dialog where it used to live.
const SOUND_THEMES = ['off', 'click', 'mech'];
const SOUND_LABELS = { off: 'Off', click: 'Click', mech: 'Mechanical' };
function paintSound() {
const current = getSettings().soundTheme || 'off';
const on = current !== 'off';
sound.innerHTML = ``;
attachTooltips(sound);
sound.querySelector('button').addEventListener('click', () => {
const now = getSettings().soundTheme || 'off';
const next = SOUND_THEMES[(SOUND_THEMES.indexOf(now) + 1) % SOUND_THEMES.length];
updateSettings({ soundTheme: next });
paintSound();
});
}
paintSound();
// Friends ------------------------------------------------------------
const friendsBtn = friends.querySelector('[data-action="rail-friends"]');
if (friendsBtn) friendsBtn.addEventListener('click', onShowFriends);
// How many friends are online, not how many you have - a total is a fact
// about your account, whereas "2 online" is a reason to open the screen.
const friendsOnlineEl = friends.querySelector('[data-badge="friends-online"]');
function paintCounts({ friends: friendCount, friendsOnline }) {
if (friendsBtn) {
friendsBtn.dataset.tooltip = !friendCount
? 'Friends'
: (friendsOnline ? `Friends - ${friendsOnline} of ${friendCount} online` : `Friends - ${friendCount}, none online`);
}
if (!friendsOnlineEl) return;
friendsOnlineEl.hidden = !friendsOnline;
friendsOnlineEl.textContent = String(friendsOnline);
}
paintCounts(getCounts());
const unsubscribeCounts = onCountsChange(paintCounts);
const handleOutsideClick = e => {
if (!langPopover.hidden && !langGroup.contains(e.target)) langPopover.hidden = true;
};
document.addEventListener('click', handleOutsideClick);
return () => {
window.removeEventListener('resize', publishHeight);
unsubscribeCounts();
document.removeEventListener('click', handleOutsideClick);
unsubscribeAuth();
cleanupTheme();
wrap.remove();
};
}