[accounts] emoji

This commit is contained in:
Devin Zuczek
2026-09-09 11:31:48 -04:00
parent 261ff21e0d
commit 629a702403
5 changed files with 509 additions and 5 deletions
+63 -2
View File
@@ -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
+20 -1
View File
@@ -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)'),
})
+113 -1
View File
@@ -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)
})
+312
View File
@@ -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
}
+1 -1
View File
@@ -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