Emoji autofill
This commit is contained in:
@@ -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.
|
||||
|
||||
+107
@@ -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 {
|
||||
|
||||
+3
-2
@@ -219,11 +219,12 @@
|
||||
<form id="chat-form" hidden>
|
||||
<label for="chat-input">Add to the conversation</label>
|
||||
<div class="chat-input-row">
|
||||
<input id="chat-input" maxlength="200" placeholder="What's on your mind?" required autocomplete="off" aria-describedby="chat-help">
|
||||
<input id="chat-input" maxlength="200" placeholder="What's on your mind? Try :smile:" required autocomplete="off" aria-describedby="chat-help" role="combobox" aria-autocomplete="list" aria-controls="emoji-suggestions" aria-expanded="false">
|
||||
<button id="chat-send" type="submit">Send <span aria-hidden="true">↑</span></button>
|
||||
</div>
|
||||
<ul id="emoji-suggestions" class="emoji-suggestions" role="listbox" aria-label="Emoji suggestions" hidden></ul>
|
||||
</form>
|
||||
<p id="chat-help">Be kind. 200 characters max · 2 seconds between messages</p>
|
||||
<p id="chat-help">Type : to find an emoji. Use ↑/↓ to choose, Enter or Tab to insert, Esc to dismiss. 200 characters max · 2 seconds between messages.</p>
|
||||
<p id="chat-error" class="error" role="status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user