import { COUNTRY_FLAG_HEIGHT, COUNTRY_FLAG_WIDTH, getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from './country-flags'; import { getBoardFlagDefinition, normalizeBoardFlagKind, type BoardFlagKind } from './board-flags'; const COUNTRY_FLAG_SPRITE_PATH = 'assets/icons/flags-1.png'; const TOR_COUNTRY_CODE = 'xx'; const TOR_AUTHOR_FLAG_LABEL = 'This user is on Tor'; export const TOR_AUTHOR_FLAG_LABEL_KEY = 'tor_author_flag_label'; const FLAG_TEXT_PATTERN = /^flag:([a-z-]+):([a-z0-9-]+)$/i; const SHORT_FLAG_TEXT_PATTERN = /^(country|geo|pol|political|meme|memeflag|memeflags|pony|mlp):([a-z0-9-]+)$/i; const COUNTRY_EMOJI_TEXT_PATTERN = /^([a-z]{2}|catalonia|eu|fam|xe|xs|xw|xk|xx)-emoji$/i; type FlagSource = | { type: 'country'; code: string } | { type: BoardFlagKind; code: string; }; interface AuthorFlairLike { text?: unknown; code?: unknown; country?: unknown; type?: unknown; kind?: unknown; expiresAt?: unknown; } interface AuthorLike { flairs?: unknown; community?: { flairs?: unknown; }; subplebbit?: { flairs?: unknown; }; } interface CommentLike { '5chan'?: unknown; author?: unknown; flairs?: unknown; raw?: { comment?: unknown; }; } export interface AuthorFlagViewModel { key: string; type: 'country' | BoardFlagKind; code: string; label: string; labelKey?: string; spritePath: string; width: number; height: number; x: number; y: number; } const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const normalizeFlairsContainer = (flairs: unknown): unknown => { if (Array.isArray(flairs)) return flairs; return isRecord(flairs) && Array.isArray(flairs.author) ? flairs.author : undefined; }; export const getAuthorFlagFlairs = (author: unknown): unknown => { if (!isRecord(author)) return undefined; const authorLike = author as AuthorLike; return normalizeFlairsContainer(authorLike.community?.flairs) ?? normalizeFlairsContainer(authorLike.subplebbit?.flairs) ?? normalizeFlairsContainer(authorLike.flairs); }; const getFiveChanFlagFlair = (comment: unknown): AuthorFlairLike | undefined => { if (!isRecord(comment)) return undefined; const fiveChan = (comment as CommentLike)['5chan']; if (!isRecord(fiveChan) || !isRecord(fiveChan.flag)) return undefined; const flag = fiveChan.flag; const text = toString(flag.text); const type = toString(flag.type); const code = toString(flag.code) ?? toString(fiveChan.country); const country = toString(fiveChan.country); if (!text && (!type || !code)) { return undefined; } return { text, type, code, country, }; }; export const getCommentFlagFlairs = (comment: unknown, author?: unknown): unknown => { if (!isRecord(comment)) return getAuthorFlagFlairs(author); const commentLike = comment as CommentLike; const fiveChanFlagFlair = getFiveChanFlagFlair(comment) ?? getFiveChanFlagFlair(commentLike.raw?.comment); if (fiveChanFlagFlair) { return [fiveChanFlagFlair]; } return getAuthorFlagFlairs(author ?? commentLike.author) ?? normalizeFlairsContainer(commentLike.flairs); }; const isFreshFlair = (flair: AuthorFlairLike, nowSeconds: number) => typeof flair.expiresAt !== 'number' || !Number.isFinite(flair.expiresAt) || flair.expiresAt > nowSeconds; const parseTextFlagSource = (text: string | undefined): FlagSource | undefined => { if (!text) return undefined; const flagMatch = text.match(FLAG_TEXT_PATTERN); if (flagMatch) { const [, kindValue, code] = flagMatch; const normalizedKindValue = kindValue.toLowerCase(); if (normalizedKindValue === 'country' || normalizedKindValue === 'geo') { return { type: 'country', code }; } const kind = normalizeBoardFlagKind(normalizedKindValue); return kind ? { type: kind, code } : undefined; } const shortMatch = text.match(SHORT_FLAG_TEXT_PATTERN); if (shortMatch) { const [, kindValue, code] = shortMatch; const normalizedKindValue = kindValue.toLowerCase(); if (normalizedKindValue === 'country' || normalizedKindValue === 'geo') { return { type: 'country', code }; } const kind = normalizeBoardFlagKind(normalizedKindValue); return kind ? { type: kind, code } : undefined; } const countryEmojiMatch = text.match(COUNTRY_EMOJI_TEXT_PATTERN); return countryEmojiMatch ? { type: 'country', code: countryEmojiMatch[1] } : undefined; }; const parseStructuredFlagSource = (flair: AuthorFlairLike): FlagSource | undefined => { const typeValue = toString(flair.type) ?? toString(flair.kind); if (!typeValue) return undefined; const normalizedTypeValue = typeValue.toLowerCase(); if (normalizedTypeValue === 'country' || normalizedTypeValue === 'geo') { const code = toString(flair.country) ?? toString(flair.code); return code ? { type: 'country', code } : undefined; } const kind = normalizeBoardFlagKind(normalizedTypeValue); const code = toString(flair.code); return kind && code ? { type: kind, code } : undefined; }; const toCountryFlagViewModel = (codeValue: string): AuthorFlagViewModel | undefined => { const code = normalizeCountryCode(codeValue); const position = getCountryFlagPosition(code); if (!code || !position) return undefined; return { key: `country:${code}`, type: 'country', code, label: code === TOR_COUNTRY_CODE ? TOR_AUTHOR_FLAG_LABEL : (getCountryLabel(code) ?? code.toUpperCase()), ...(code === TOR_COUNTRY_CODE ? { labelKey: TOR_AUTHOR_FLAG_LABEL_KEY } : {}), spritePath: COUNTRY_FLAG_SPRITE_PATH, width: COUNTRY_FLAG_WIDTH, height: COUNTRY_FLAG_HEIGHT, x: position.x, y: position.y, }; }; const toBoardFlagViewModel = (type: BoardFlagKind, code: string): AuthorFlagViewModel | undefined => { const flag = getBoardFlagDefinition(type, code); return flag ? { key: `${flag.kind}:${flag.code}`, type: flag.kind, code: flag.code, label: flag.label, spritePath: flag.spritePath, width: flag.width, height: flag.height, x: flag.x, y: flag.y, } : undefined; }; const toFlagViewModel = (source: FlagSource | undefined): AuthorFlagViewModel | undefined => { if (!source) return undefined; return source.type === 'country' ? toCountryFlagViewModel(source.code) : toBoardFlagViewModel(source.type, source.code); }; export const getAuthorFlagViewModels = (flairs: unknown, nowSeconds = Date.now() / 1000): AuthorFlagViewModel[] => { if (!Array.isArray(flairs)) return []; const seenKeys = new Set(); const flags: AuthorFlagViewModel[] = []; for (const flair of flairs) { if (typeof flair !== 'object' || flair === null) { continue; } const flairRecord = flair as AuthorFlairLike; if (!isFreshFlair(flairRecord, nowSeconds)) { continue; } const flag = toFlagViewModel(parseStructuredFlagSource(flairRecord) ?? parseTextFlagSource(toString(flairRecord.text))); if (!flag || seenKeys.has(flag.key)) { continue; } seenKeys.add(flag.key); flags.push(flag); } return flags; };