From 2a2d9760132c5077ced735569492e10a262214fa Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Wed, 21 Oct 2026 14:28:58 -0400 Subject: [PATCH] Emoji autofill --- README.md | 2 + static/app.js | 107 ++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 5 ++- static/style.css | 4 ++ 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0c70a60..bcebb98 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Every successful button press also posts an automatic chat announcement with the ## Seasons and champions +Chat supports emoji autocomplete: type `:` followed by a name (for example `:sm`, `:heart`, or `:fire:`), then choose a suggestion with the arrow keys and Enter/Tab, or click/tap it. Escape dismisses suggestions. Selection inserts the actual Unicode emoji at the cursor without sending the message; press Enter again or Send to post. Unknown shortcodes remain unchanged. Suggestions are a built-in curated list, with no external service, and respect the message length limit. + Seasons begin at **00:00 UTC on the UTC date of each astronomical equinox or solstice**, not at the event's exact hour and not on fixed March/June/September/December dates. They are displayed as **Season I, Season II, Season III**, etc., including in the subtitle under **THE BUTTON**. Numbering begins at the earliest saved season (including existing archives on upgrade); its origin is persisted so numbering remains stable across restarts. Each astronomical boundary advances the number, including seasons missed during downtime. Date-based internal IDs and start/end dates remain unchanged. The built-in Meeus equinox/solstice calculation (with periodic and Delta-T corrections) supports 2000–2100 without network access or dependencies. The 2026 boundaries are March 20, June 21, September 23, and December 21; the December 2027 boundary is December 22. Calendar tests check these against USNO data. The far-future calendar is an astronomical estimate; extend/revalidate it before 2101. At each boundary the server archives the old season and starts a fresh countdown anchored to that midnight. Seasonal points, press history, personal bests, achievements, and milestone progress reset. Accounts, password hashes, login sessions, mailing-list subscriptions, and chat are preserved. Previous press histories remain stored in the season archive; public archives expose only score summaries, not private per-player histories. Old milestone announcements remain visible in chat. diff --git a/static/app.js b/static/app.js index 2395305..30e18f9 100644 --- a/static/app.js +++ b/static/app.js @@ -637,9 +637,116 @@ async function loadChat() { } } +const CHAT_EMOJIS = [ + ["smile", "😄"], ["smiley", "😃"], ["grin", "😁"], ["laughing", "😆"], + ["joy", "😂"], ["rofl", "🤣"], ["wink", "😉"], ["blush", "😊"], + ["heart_eyes", "😍"], ["thinking", "🤔"], ["sunglasses", "😎"], ["sweat_smile", "😅"], + ["nervous", "😬"], ["eyes", "👀"], ["cry", "😢"], ["sob", "😭"], + ["neutral", "😐"], ["surprised", "😮"], ["scream", "😱"], ["sleeping", "😴"], + ["heart", "❤️"], ["green_heart", "💚"], ["blue_heart", "💙"], ["yellow_heart", "💛"], + ["purple_heart", "💜"], ["black_heart", "🖤"], ["white_heart", "🤍"], + ["thumbsup", "👍"], ["+1", "👍"], ["thumbsdown", "👎"], ["-1", "👎"], + ["clap", "👏"], ["wave", "👋"], ["pray", "🙏"], ["muscle", "💪"], + ["fire", "🔥"], ["tada", "🎉"], ["party", "🥳"], ["sparkles", "✨"], + ["star", "⭐"], ["trophy", "🏆"], ["medal", "🏅"], ["rocket", "🚀"], + ["hourglass", "⌛"], ["clock", "🕒"], ["alarm", "⏰"], ["stopwatch", "⏱️"], + ["button", "🔘"], ["red_circle", "🔴"], ["blue_circle", "🔵"], ["green_circle", "🟢"], + ["yellow_circle", "🟡"], ["white_circle", "⚪"], ["black_circle", "⚫"], + ["100", "💯"], ["gg", "🤝"], ["skull", "💀"], ["coffee", "☕"], +]; +let emojiMatches = []; +let emojiSelection = 0; +let emojiRange = null; + +function closeEmojiSuggestions() { + emojiMatches = []; + emojiRange = null; + $("emoji-suggestions").hidden = true; + $("chat-input").setAttribute("aria-expanded", "false"); + $("chat-input").removeAttribute("aria-activedescendant"); +} + +function renderEmojiSuggestions() { + const list = $("emoji-suggestions"); + list.replaceChildren(...emojiMatches.map(([name, emoji], index) => { + const option = el("li", undefined, "emoji-option"); + option.id = `emoji-option-${index}`; + option.dataset.index = String(index); + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", String(index === emojiSelection)); + option.append(el("span", emoji, "emoji-glyph"), el("span", `:${name}:`)); + return option; + })); + list.hidden = false; + $("chat-input").setAttribute("aria-expanded", "true"); + $("chat-input").setAttribute("aria-activedescendant", `emoji-option-${emojiSelection}`); +} + +function suggestEmoji() { + const input = $("chat-input"); + if (input.selectionStart !== input.selectionEnd) return closeEmojiSuggestions(); + const before = input.value.slice(0, input.selectionStart); + const match = before.match(/(?:^|\s):([a-z0-9_+\-]*):?$/i); + if (!match) return closeEmojiSuggestions(); + const query = match[1].toLowerCase(); + // A completed shortcode must match exactly; never turn an unknown code into another emoji. + const complete = before.endsWith(":") && match[0].trim() !== ":"; + emojiMatches = CHAT_EMOJIS.filter(([name]) => complete ? name === query : name.startsWith(query)).slice(0, 6); + if (!emojiMatches.length) return closeEmojiSuggestions(); + emojiSelection = 0; + emojiRange = { start: before.lastIndexOf(":", complete ? before.length - 2 : before.length - 1), end: input.selectionStart }; + renderEmojiSuggestions(); +} + +function insertEmoji(index) { + if (!emojiRange || !emojiMatches[index]) return; + const input = $("chat-input"); + const emoji = emojiMatches[index][1]; + const { start, end } = emojiRange; + if (input.value.length - (end - start) + emoji.length > input.maxLength) { + $("chat-error").textContent = "This emoji would exceed the message length limit."; + closeEmojiSuggestions(); + return; + } + input.setRangeText(emoji, start, end, "end"); + $("chat-error").textContent = ""; + closeEmojiSuggestions(); + input.focus(); +} + +$("chat-input").addEventListener("input", (event) => { + if (!event.isComposing) suggestEmoji(); +}); +$("chat-input").addEventListener("compositionend", suggestEmoji); +$("chat-input").addEventListener("click", suggestEmoji); +$("chat-input").addEventListener("blur", closeEmojiSuggestions); +$("chat-input").addEventListener("keydown", (event) => { + if (event.isComposing || !emojiMatches.length) return; + if (["ArrowDown", "ArrowUp"].includes(event.key)) { + event.preventDefault(); + emojiSelection = (emojiSelection + (event.key === "ArrowDown" ? 1 : -1) + emojiMatches.length) % emojiMatches.length; + renderEmojiSuggestions(); + } else if (["Enter", "Tab"].includes(event.key)) { + event.preventDefault(); + insertEmoji(emojiSelection); + } else if (event.key === "Escape") { + event.preventDefault(); + closeEmojiSuggestions(); + } else if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { + closeEmojiSuggestions(); + } +}); +$("emoji-suggestions").addEventListener("pointerdown", (event) => { + const option = event.target.closest("[data-index]"); + if (!option) return; + event.preventDefault(); // keep the caret and avoid blur before insertion + insertEmoji(Number(option.dataset.index)); +}); + $("chat-form").addEventListener("submit", async (event) => { event.preventDefault(); const input = $("chat-input"); + closeEmojiSuggestions(); $("chat-send").disabled = true; $("chat-error").textContent = ""; try { diff --git a/static/index.html b/static/index.html index ede4d89..b98181c 100644 --- a/static/index.html +++ b/static/index.html @@ -219,11 +219,12 @@ -

Be kind. 200 characters max · 2 seconds between messages

+

Type : to find an emoji. Use ↑/↓ to choose, Enter or Tab to insert, Esc to dismiss. 200 characters max · 2 seconds between messages.

diff --git a/static/style.css b/static/style.css index 272d8b0..907f87a 100644 --- a/static/style.css +++ b/static/style.css @@ -383,6 +383,10 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; } #chat-messages p { margin: 0; padding: 0.7rem 0.85rem; border: 1px solid #45475a60; border-radius: 0 12px 12px 12px; background: var(--base); font-size: 0.9rem; line-height: 1.55; overflow-wrap: anywhere; } .chat-composer { border-top: 1px solid var(--surface); padding: 1.25rem 1.5rem; background: #11111b35; } #chat-form { display: block; } +.emoji-suggestions { list-style: none; margin: 0.5rem 0 0; padding: 0.3rem; border: 1px solid var(--surface); border-radius: 12px; background: var(--base); max-height: 280px; overflow-y: auto; } +.emoji-option { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.65rem; min-height: 44px; border-radius: 8px; color: var(--subtext); font-size: 0.8rem; cursor: pointer; overflow-wrap: anywhere; } +.emoji-option[aria-selected="true"], .emoji-option:hover { background: var(--surface); color: var(--lavender); } +.emoji-glyph { font-size: 1.25rem; flex-shrink: 0; } #chat-form label { display: block; margin-bottom: 0.6rem; font-size: 0.75rem; font-weight: 600; color: var(--subtext); } .chat-input-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem; background: var(--base); border: 1px solid var(--surface); border-radius: 12px; } .chat-input-row:focus-within { border-color: var(--mauve); }