mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
[accounts] emoji
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
CreateAccountResult,
|
||||
DisplayNameRequest,
|
||||
EmailRequest,
|
||||
EmojiRequest,
|
||||
form,
|
||||
HealthResponse,
|
||||
IdentityFlagsRequest,
|
||||
@@ -45,7 +46,9 @@ import {
|
||||
SuccessResponse,
|
||||
UsernameRequest,
|
||||
UsernameResult,
|
||||
WhitelistedEmojis,
|
||||
} from './openapi'
|
||||
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
@@ -109,8 +112,8 @@ function toAccountDto(account: Account) {
|
||||
username: account.username,
|
||||
displayName: account.displayName,
|
||||
profileImage: account.profileImage,
|
||||
// Nothing writes these yet, and rows stored before they existed have neither
|
||||
// key — always emit them as "" rather than letting them go missing.
|
||||
// Rows stored before these fields existed have neither key — always emit them as
|
||||
// "" rather than letting them go missing.
|
||||
bannerImage: account.bannerImage ?? '',
|
||||
displayEmoji: account.displayEmoji ?? '',
|
||||
isJunior: account.isJunior,
|
||||
@@ -210,6 +213,24 @@ const app = new Hono<App>()
|
||||
(c) => c.json({ service: 'accounts', status: 'ok' })
|
||||
)
|
||||
|
||||
// ---- Emoji config --------------------------------------------------------
|
||||
// The picker the client fills its displayEmoji grid from. A BARE array — no
|
||||
// `{ success, error, value }` envelope and no wrapper object; the client parses the
|
||||
// response body itself as the list.
|
||||
.get(
|
||||
'/emojiConfig/whitelistedEmojis',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Emoji a player may use as their displayEmoji',
|
||||
description: [
|
||||
'A bare JSON array of emoji, in the order the client draws them. Static — not',
|
||||
'auth-gated, and identical for every player.',
|
||||
].join(' '),
|
||||
responses: { 200: json(WhitelistedEmojis, 'The whitelisted emoji, in picker order') },
|
||||
}),
|
||||
(c) => c.json(WHITELISTED_EMOJIS)
|
||||
)
|
||||
|
||||
// ---- Self account --------------------------------------------------------
|
||||
.get(
|
||||
'/account/me',
|
||||
@@ -665,6 +686,46 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The emoji shown beside the player's display name. The body is a single field —
|
||||
// `displayEmoji=%F0%9F%A4%AA` — and the value is checked against the same list
|
||||
// `GET /emojiConfig/whitelistedEmojis` serves, then stored in that list's CANONICAL
|
||||
// form: `displayEmoji` is compared as a plain string, and the client highlights the
|
||||
// current pick by matching it against the picker list it fetched, so a stored value
|
||||
// that differs only by a variation selector highlights nothing.
|
||||
//
|
||||
// Broadcast like every other public-DTO mutation here — the emoji rides along in the
|
||||
// AccountUpdate payload, so it redraws beside the name without a refetch.
|
||||
.put(
|
||||
'/account/me/emoji',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set display emoji',
|
||||
description: [
|
||||
'Persists the emoji shown beside the display name and broadcasts it in the',
|
||||
'AccountUpdate payload. The value must be one the whitelist serves; an empty value',
|
||||
'clears the pick.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Not a whitelisted emoji (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const submitted = (await formField(c, 'displayEmoji')).trim()
|
||||
// An empty value clears the pick; anything else has to be on the list.
|
||||
const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted)
|
||||
if (displayEmoji === null) return c.body(null, 400)
|
||||
const account = await updateAccount(c.env.DB, id, { displayEmoji })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// The profile banner — the wide image behind the header on a player's profile. Same
|
||||
// shape as the avatar below: the body names an image the player has already uploaded
|
||||
// (the client posts one of their own photos, `sharecamera/<date>/<uuid>.jpg`), so this
|
||||
|
||||
@@ -75,7 +75,7 @@ export const AccountDto = z.object({
|
||||
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
|
||||
displayEmoji: z
|
||||
.string()
|
||||
.describe('Emoji beside the display name — always "" (nothing sets it yet)'),
|
||||
.describe('Emoji beside the display name, set by PUT /account/me/emoji; "" when unset'),
|
||||
isJunior: z.boolean(),
|
||||
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
||||
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
||||
@@ -139,6 +139,15 @@ export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurch
|
||||
*/
|
||||
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
|
||||
|
||||
/**
|
||||
* `GET /emojiConfig/whitelistedEmojis` response — a BARE array of emoji, no envelope
|
||||
* and no object around it (see `WHITELISTED_EMOJIS`).
|
||||
*/
|
||||
export const WhitelistedEmojis = z
|
||||
.string()
|
||||
.array()
|
||||
.describe('The emoji a player may set as their displayEmoji, in picker order')
|
||||
|
||||
/** Root health check. */
|
||||
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
|
||||
|
||||
@@ -230,6 +239,16 @@ export const BioRequest = z.object({
|
||||
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /account/me/emoji` form body. The value must be one of the emoji served by
|
||||
* `GET /emojiConfig/whitelistedEmojis`; an empty value clears the current pick. Checked
|
||||
* in the handler rather than here, because the check also CANONICALIZES the value
|
||||
* (see `resolveWhitelistedEmoji`) and a schema can only accept or reject it.
|
||||
*/
|
||||
export const EmojiRequest = z.object({
|
||||
displayEmoji: z.string().describe('A whitelisted emoji, or "" to clear'),
|
||||
})
|
||||
|
||||
export const ProfileImageRequest = z.object({
|
||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||
})
|
||||
|
||||
@@ -6,6 +6,8 @@ import '../../accounts.app'
|
||||
|
||||
import { SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
@@ -173,7 +175,7 @@ describe('auth-gated endpoints', () => {
|
||||
// An unset email is "", not null — the client reads it as a string, and the
|
||||
// hub frame this DTO also rides drops null values outright.
|
||||
email: '',
|
||||
// Nothing sets these yet, but the key has to be present — the client reads
|
||||
// Unset on a fresh account, but the key has to be present — the client reads
|
||||
// both off the account DTO.
|
||||
bannerImage: '',
|
||||
displayEmoji: '',
|
||||
@@ -403,6 +405,91 @@ describe('auth-gated endpoints', () => {
|
||||
expect(sent.map((n) => (n.data as { bannerImage?: string }).bannerImage)).toContain(key)
|
||||
})
|
||||
|
||||
test('PUT /account/me/emoji persists the emoji and pushes the profile update', async () => {
|
||||
type Sent = { playerId: number; notificationType: string | number; data: unknown }
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
await hub().fetch('http://do/', { method: 'DELETE' })
|
||||
|
||||
// Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`).
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('779')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'displayEmoji=%F0%9F%A4%AA',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// Served back by both the self and public reads — displayEmoji is in the public DTO.
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('779') })
|
||||
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
|
||||
const pub = await exports.default.fetch(`${ORIGIN}/account/779`)
|
||||
expect(((await pub.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
|
||||
|
||||
// And it rides the profile-update notification, so it redraws beside the name.
|
||||
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||
expect(sent.length).toBeGreaterThan(0)
|
||||
expect(sent.map((n) => (n.data as { displayEmoji?: string }).displayEmoji)).toContain(
|
||||
'\u{1F92A}'
|
||||
)
|
||||
})
|
||||
|
||||
// The pick is stored in the whitelist's CANONICAL form. A client that posts the emoji
|
||||
// without its U+FE0F variation selector means the same pick, but storing what arrived
|
||||
// would leave a string the picker list no longer matches, so the current pick would
|
||||
// stop highlighting.
|
||||
test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji: '\u{2764}' }),
|
||||
headers: { ...(await bearer('780')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('780') })
|
||||
const stored = ((await me.json()) as { displayEmoji: string }).displayEmoji
|
||||
expect(stored).toBe('\u{2764}\u{FE0F}')
|
||||
expect(WHITELISTED_EMOJIS).toContain(stored)
|
||||
})
|
||||
|
||||
// An empty value clears the pick rather than 400ing — that's how the picker's "none"
|
||||
// gets back to no emoji at all.
|
||||
test('PUT /account/me/emoji clears the pick on an empty value', async () => {
|
||||
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji: '\u{1F389}' }),
|
||||
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(set.status).toBe(200)
|
||||
|
||||
const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji: '' }),
|
||||
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(cleared.status).toBe(200)
|
||||
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('781') })
|
||||
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
|
||||
})
|
||||
|
||||
// displayEmoji renders beside the display name, so an unchecked field would be a
|
||||
// free-text label on every profile. Off-list values are refused, not stored.
|
||||
test('PUT /account/me/emoji 401s without a token, 400s on an off-list value', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji: '\u{1F92A}' }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji }),
|
||||
headers: { ...(await bearer('782')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
}
|
||||
|
||||
// Nothing was stored by the refusals.
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('782') })
|
||||
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
|
||||
})
|
||||
|
||||
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
|
||||
...form({ imageName: 'x.jpg' }),
|
||||
@@ -563,6 +650,7 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /account/{id}',
|
||||
'GET /account/{id}/bio',
|
||||
'GET /accountprivacysettings/{id}',
|
||||
'GET /emojiConfig/whitelistedEmojis',
|
||||
'GET /parentalcontrol/me',
|
||||
'POST /account/create',
|
||||
'POST /account/me/email',
|
||||
@@ -570,6 +658,7 @@ describe('auth-gated endpoints', () => {
|
||||
'PUT /account/me/bannerimage',
|
||||
'PUT /account/me/bio',
|
||||
'PUT /account/me/displayname',
|
||||
'PUT /account/me/emoji',
|
||||
'PUT /account/me/identityflags',
|
||||
'PUT /account/me/personalpronouns',
|
||||
'PUT /account/me/profileimage',
|
||||
@@ -758,3 +847,26 @@ test('POST /account/me/phone stores an E.164 number exactly as the client sends
|
||||
// Verbatim — no normalising, no stripping of the +.
|
||||
expect(row?.phone).toBe('+15552223333')
|
||||
})
|
||||
|
||||
// The emoji picker. Served as a BARE array — the client parses the response body itself
|
||||
// as the list, so wrapping it in `{ value: [...] }` or the success envelope every
|
||||
// mutation here uses would leave the picker empty.
|
||||
test('GET /emojiConfig/whitelistedEmojis serves the list as a bare array', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as string[]
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body).toEqual(WHITELISTED_EMOJIS)
|
||||
// Order is the picker's order, and the first entry anchors it.
|
||||
expect(body[0]).toBe('😀')
|
||||
// Every entry is a non-empty string and appears once — a duplicate draws twice in
|
||||
// the grid, and the list is compared against `displayEmoji` as an exact string.
|
||||
expect(body.every((e) => typeof e === 'string' && e.length > 0)).toBe(true)
|
||||
expect(new Set(body).size).toBe(body.length)
|
||||
})
|
||||
|
||||
// Not auth-gated: the client asks for the picker before it has a token in hand.
|
||||
test('GET /emojiConfig/whitelistedEmojis needs no bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* The emoji a player may pick as their `displayEmoji` — served verbatim as a bare JSON
|
||||
* array by `GET /emojiConfig/whitelistedEmojis`, with no envelope around it.
|
||||
*
|
||||
* ORDER IS THE PICKER'S ORDER: the client renders the array as it arrives, so this is
|
||||
* grouped by theme (smileys, hearts, gestures, animals, food, weather, tech, music,
|
||||
* sport, objects, vehicles, symbols). Sorting it would reshuffle the grid; append a new
|
||||
* entry to the end of the group it belongs to instead.
|
||||
*
|
||||
* Entries are UNIQUE — a repeat draws the same emoji twice in the grid — and are stored
|
||||
* as the exact code-point sequence the client sends back, variation selectors (U+FE0F)
|
||||
* and zero-width joiners included: `displayEmoji` is compared as a plain string, so
|
||||
* `\u{2764}` and `\u{2764}\u{FE0F}` are two different values and only the form listed
|
||||
* here round-trips.
|
||||
*/
|
||||
export const WHITELISTED_EMOJIS: string[] = [
|
||||
'😀',
|
||||
'😃',
|
||||
'😄',
|
||||
'😁',
|
||||
'😆',
|
||||
'😅',
|
||||
'😂',
|
||||
'🤣',
|
||||
'😊',
|
||||
'😇',
|
||||
'🙂',
|
||||
'🙃',
|
||||
'😉',
|
||||
'😍',
|
||||
'🥰',
|
||||
'😘',
|
||||
'😗',
|
||||
'😚',
|
||||
'😋',
|
||||
'😛',
|
||||
'😜',
|
||||
'🤪',
|
||||
'😝',
|
||||
'🤑',
|
||||
'🤗',
|
||||
'🤭',
|
||||
'🤫',
|
||||
'🤔',
|
||||
'🤨',
|
||||
'😐',
|
||||
'😑',
|
||||
'😶',
|
||||
'🙄',
|
||||
'😏',
|
||||
'😣',
|
||||
'😥',
|
||||
'😮',
|
||||
'🤐',
|
||||
'😯',
|
||||
'😪',
|
||||
'😫',
|
||||
'🥱',
|
||||
'😴',
|
||||
'😌',
|
||||
'🤓',
|
||||
'😎',
|
||||
'🤩',
|
||||
'🥳',
|
||||
'😤',
|
||||
'😭',
|
||||
'😢',
|
||||
'🥺',
|
||||
'😡',
|
||||
'🤬',
|
||||
'😱',
|
||||
'😨',
|
||||
'😰',
|
||||
'😬',
|
||||
'🤯',
|
||||
'🥶',
|
||||
'🥵',
|
||||
'🤠',
|
||||
'🤖',
|
||||
'👽',
|
||||
'👻',
|
||||
'💀',
|
||||
'☠️',
|
||||
'👹',
|
||||
'👺',
|
||||
'👾',
|
||||
'❤️',
|
||||
'🧡',
|
||||
'💛',
|
||||
'💚',
|
||||
'💙',
|
||||
'💜',
|
||||
'🖤',
|
||||
'🤍',
|
||||
'🤎',
|
||||
'💖',
|
||||
'💗',
|
||||
'💓',
|
||||
'💕',
|
||||
'💞',
|
||||
'💘',
|
||||
'💝',
|
||||
'💟',
|
||||
'❣️',
|
||||
'💔',
|
||||
'👍',
|
||||
'👎',
|
||||
'👌',
|
||||
'✌️',
|
||||
'🤞',
|
||||
'🤟',
|
||||
'🤘',
|
||||
'🤙',
|
||||
'👏',
|
||||
'🙌',
|
||||
'👐',
|
||||
'🤲',
|
||||
'🙏',
|
||||
'👋',
|
||||
'✋',
|
||||
'🤚',
|
||||
'🫶',
|
||||
'💪',
|
||||
'🧠',
|
||||
'👀',
|
||||
'👁️',
|
||||
'👄',
|
||||
'🦾',
|
||||
'🦿',
|
||||
'🐶',
|
||||
'🐱',
|
||||
'🐭',
|
||||
'🐹',
|
||||
'🐰',
|
||||
'🦊',
|
||||
'🐻',
|
||||
'🐼',
|
||||
'🐨',
|
||||
'🐯',
|
||||
'🦁',
|
||||
'🐸',
|
||||
'🐵',
|
||||
'🐧',
|
||||
'🐦',
|
||||
'🦅',
|
||||
'🦆',
|
||||
'🦄',
|
||||
'🐴',
|
||||
'🐢',
|
||||
'🐙',
|
||||
'🦈',
|
||||
'🐬',
|
||||
'🐳',
|
||||
'🦋',
|
||||
'🐝',
|
||||
'🐞',
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🐲',
|
||||
'🍎',
|
||||
'🍌',
|
||||
'🍇',
|
||||
'🍉',
|
||||
'🍓',
|
||||
'🍒',
|
||||
'🥝',
|
||||
'🍍',
|
||||
'🥑',
|
||||
'🌮',
|
||||
'🍕',
|
||||
'🍔',
|
||||
'🍟',
|
||||
'🌭',
|
||||
'🥪',
|
||||
'🍗',
|
||||
'🍿',
|
||||
'🍩',
|
||||
'🍪',
|
||||
'🎂',
|
||||
'🍫',
|
||||
'🍬',
|
||||
'🍭',
|
||||
'🧋',
|
||||
'☕',
|
||||
'🥤',
|
||||
'🍺',
|
||||
'🥛',
|
||||
'☀️',
|
||||
'🌤️',
|
||||
'⛅',
|
||||
'🌥️',
|
||||
'☁️',
|
||||
'🌧️',
|
||||
'⛈️',
|
||||
'❄️',
|
||||
'🌈',
|
||||
'⭐',
|
||||
'🌟',
|
||||
'✨',
|
||||
'⚡',
|
||||
'🔥',
|
||||
'💧',
|
||||
'🌊',
|
||||
'🌸',
|
||||
'🌹',
|
||||
'🍀',
|
||||
'🌲',
|
||||
'🎮',
|
||||
'🕹️',
|
||||
'💻',
|
||||
'⌨️',
|
||||
'🖥️',
|
||||
'📱',
|
||||
'🖱️',
|
||||
'🎧',
|
||||
'📷',
|
||||
'📹',
|
||||
'💿',
|
||||
'💾',
|
||||
'🔋',
|
||||
'🔌',
|
||||
'🛰️',
|
||||
'🚀',
|
||||
'🎵',
|
||||
'🎶',
|
||||
'🎼',
|
||||
'🎤',
|
||||
'🥁',
|
||||
'🎸',
|
||||
'🎹',
|
||||
'🎺',
|
||||
'🎷',
|
||||
'🎻',
|
||||
'⚽',
|
||||
'🏀',
|
||||
'🏈',
|
||||
'⚾',
|
||||
'🎾',
|
||||
'🏐',
|
||||
'🏓',
|
||||
'🥊',
|
||||
'🏆',
|
||||
'🥇',
|
||||
'🥈',
|
||||
'🥉',
|
||||
'💎',
|
||||
'💰',
|
||||
'💸',
|
||||
'🪙',
|
||||
'🎁',
|
||||
'📦',
|
||||
'🔑',
|
||||
'🗝️',
|
||||
'🛡️',
|
||||
'⚔️',
|
||||
'🧸',
|
||||
'🎈',
|
||||
'🎉',
|
||||
'🎊',
|
||||
'🕯️',
|
||||
'💡',
|
||||
'📚',
|
||||
'📖',
|
||||
'✏️',
|
||||
'🖊️',
|
||||
'🚗',
|
||||
'🚕',
|
||||
'🚌',
|
||||
'🚓',
|
||||
'🚑',
|
||||
'🚒',
|
||||
'🏎️',
|
||||
'🚲',
|
||||
'✈️',
|
||||
'🚁',
|
||||
'🚢',
|
||||
'✔️',
|
||||
'✅',
|
||||
'❌',
|
||||
'⭕',
|
||||
'❗',
|
||||
'❓',
|
||||
'💯',
|
||||
'♾️',
|
||||
'🔔',
|
||||
'🔕',
|
||||
'❤️🔥',
|
||||
]
|
||||
|
||||
/**
|
||||
* The whitelist keyed by its FE0F-stripped form, so a lookup tolerates the one way the
|
||||
* client legitimately disagrees with this list: U+FE0F is a PRESENTATION hint, and
|
||||
* `\u{2764}`/`\u{2764}\u{FE0F}` are the same picked emoji even though they are different
|
||||
* strings. Verified collision-free — stripping FE0F maps the 271 entries onto 271
|
||||
* distinct keys — so the fold can never make two picks ambiguous.
|
||||
*/
|
||||
const BY_STRIPPED = new Map(
|
||||
WHITELISTED_EMOJIS.map((emoji) => [emoji.replaceAll('\u{FE0F}', ''), emoji])
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a client-submitted emoji to its CANONICAL whitelist entry, or null when it
|
||||
* isn't on the list. Storing the canonical form (rather than what arrived) is what keeps
|
||||
* a stored `displayEmoji` string-equal to the picker entry it came from — the client
|
||||
* highlights the current pick by comparing against the list it fetched.
|
||||
*
|
||||
* Whitelisting matters here beyond tidiness: `displayEmoji` renders beside the display
|
||||
* name, so an unchecked field is a free-text label on every player's profile.
|
||||
*/
|
||||
export function resolveWhitelistedEmoji(input: string): string | null {
|
||||
return BY_STRIPPED.get(input.replaceAll('\u{FE0F}', '')) ?? null
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export interface Account {
|
||||
profileImage: string
|
||||
/** Profile banner image key, set by `accounts` `PUT /account/me/bannerimage`. `""` until then. */
|
||||
bannerImage: string
|
||||
/** The emoji shown beside the display name. No route sets it yet — always `""`. */
|
||||
/** The emoji beside the display name, set by `accounts` `PUT /account/me/emoji`. `""` until then. */
|
||||
displayEmoji: string
|
||||
isJunior: boolean
|
||||
platforms: number
|
||||
|
||||
Reference in New Issue
Block a user