mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-10 07:31:27 -07:00
[accounts] emoji
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
|||||||
CreateAccountResult,
|
CreateAccountResult,
|
||||||
DisplayNameRequest,
|
DisplayNameRequest,
|
||||||
EmailRequest,
|
EmailRequest,
|
||||||
|
EmojiRequest,
|
||||||
form,
|
form,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
IdentityFlagsRequest,
|
IdentityFlagsRequest,
|
||||||
@@ -45,7 +46,9 @@ import {
|
|||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
UsernameRequest,
|
UsernameRequest,
|
||||||
UsernameResult,
|
UsernameResult,
|
||||||
|
WhitelistedEmojis,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
|
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { Account } from '@repo/domain'
|
import type { Account } from '@repo/domain'
|
||||||
@@ -109,8 +112,8 @@ function toAccountDto(account: Account) {
|
|||||||
username: account.username,
|
username: account.username,
|
||||||
displayName: account.displayName,
|
displayName: account.displayName,
|
||||||
profileImage: account.profileImage,
|
profileImage: account.profileImage,
|
||||||
// Nothing writes these yet, and rows stored before they existed have neither
|
// Rows stored before these fields existed have neither key — always emit them as
|
||||||
// key — always emit them as "" rather than letting them go missing.
|
// "" rather than letting them go missing.
|
||||||
bannerImage: account.bannerImage ?? '',
|
bannerImage: account.bannerImage ?? '',
|
||||||
displayEmoji: account.displayEmoji ?? '',
|
displayEmoji: account.displayEmoji ?? '',
|
||||||
isJunior: account.isJunior,
|
isJunior: account.isJunior,
|
||||||
@@ -210,6 +213,24 @@ const app = new Hono<App>()
|
|||||||
(c) => c.json({ service: 'accounts', status: 'ok' })
|
(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 --------------------------------------------------------
|
// ---- Self account --------------------------------------------------------
|
||||||
.get(
|
.get(
|
||||||
'/account/me',
|
'/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
|
// 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
|
// 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
|
// (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)'),
|
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
|
||||||
displayEmoji: z
|
displayEmoji: z
|
||||||
.string()
|
.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(),
|
isJunior: z.boolean(),
|
||||||
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
||||||
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
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() })
|
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. */
|
/** Root health check. */
|
||||||
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
|
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'),
|
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({
|
export const ProfileImageRequest = z.object({
|
||||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
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 { SCHEMA_DDL } from '@repo/domain'
|
||||||
|
|
||||||
|
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
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
|
// 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.
|
// hub frame this DTO also rides drops null values outright.
|
||||||
email: '',
|
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.
|
// both off the account DTO.
|
||||||
bannerImage: '',
|
bannerImage: '',
|
||||||
displayEmoji: '',
|
displayEmoji: '',
|
||||||
@@ -403,6 +405,91 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(sent.map((n) => (n.data as { bannerImage?: string }).bannerImage)).toContain(key)
|
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 () => {
|
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
|
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
|
||||||
...form({ imageName: 'x.jpg' }),
|
...form({ imageName: 'x.jpg' }),
|
||||||
@@ -563,6 +650,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'GET /account/{id}',
|
'GET /account/{id}',
|
||||||
'GET /account/{id}/bio',
|
'GET /account/{id}/bio',
|
||||||
'GET /accountprivacysettings/{id}',
|
'GET /accountprivacysettings/{id}',
|
||||||
|
'GET /emojiConfig/whitelistedEmojis',
|
||||||
'GET /parentalcontrol/me',
|
'GET /parentalcontrol/me',
|
||||||
'POST /account/create',
|
'POST /account/create',
|
||||||
'POST /account/me/email',
|
'POST /account/me/email',
|
||||||
@@ -570,6 +658,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'PUT /account/me/bannerimage',
|
'PUT /account/me/bannerimage',
|
||||||
'PUT /account/me/bio',
|
'PUT /account/me/bio',
|
||||||
'PUT /account/me/displayname',
|
'PUT /account/me/displayname',
|
||||||
|
'PUT /account/me/emoji',
|
||||||
'PUT /account/me/identityflags',
|
'PUT /account/me/identityflags',
|
||||||
'PUT /account/me/personalpronouns',
|
'PUT /account/me/personalpronouns',
|
||||||
'PUT /account/me/profileimage',
|
'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 +.
|
// Verbatim — no normalising, no stripping of the +.
|
||||||
expect(row?.phone).toBe('+15552223333')
|
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
|
profileImage: string
|
||||||
/** Profile banner image key, set by `accounts` `PUT /account/me/bannerimage`. `""` until then. */
|
/** Profile banner image key, set by `accounts` `PUT /account/me/bannerimage`. `""` until then. */
|
||||||
bannerImage: string
|
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
|
displayEmoji: string
|
||||||
isJunior: boolean
|
isJunior: boolean
|
||||||
platforms: number
|
platforms: number
|
||||||
|
|||||||
Reference in New Issue
Block a user