Merge branch 'master' of github.com:bitsocialnet/5chan

# Conflicts:
#	public/translations/ar/default.json
#	public/translations/bn/default.json
#	public/translations/cs/default.json
#	public/translations/da/default.json
#	public/translations/de/default.json
#	public/translations/el/default.json
#	public/translations/en/default.json
#	public/translations/es/default.json
#	public/translations/fa/default.json
#	public/translations/fi/default.json
#	public/translations/fil/default.json
#	public/translations/fr/default.json
#	public/translations/he/default.json
#	public/translations/hi/default.json
#	public/translations/hu/default.json
#	public/translations/id/default.json
#	public/translations/it/default.json
#	public/translations/ja/default.json
#	public/translations/ko/default.json
#	public/translations/mr/default.json
#	public/translations/nl/default.json
#	public/translations/no/default.json
#	public/translations/pl/default.json
#	public/translations/pt/default.json
#	public/translations/ro/default.json
#	public/translations/ru/default.json
#	public/translations/sq/default.json
#	public/translations/sv/default.json
#	public/translations/te/default.json
#	public/translations/th/default.json
#	public/translations/tr/default.json
#	public/translations/uk/default.json
#	public/translations/ur/default.json
#	public/translations/vi/default.json
#	public/translations/zh/default.json
This commit is contained in:
Tommaso Casaburi
2026-05-24 23:18:28 +07:00
66 changed files with 1293 additions and 74 deletions
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { getBoardFlagDefinition, normalizeBoardFlagKind } from '../board-flags';
describe('board-flags', () => {
it('normalizes board flag kind aliases', () => {
expect(normalizeBoardFlagKind('pol')).toBe('pol');
expect(normalizeBoardFlagKind('memeflags')).toBe('pol');
expect(normalizeBoardFlagKind('mlp')).toBe('pony');
expect(normalizeBoardFlagKind('unknown')).toBeUndefined();
});
it('maps political flags to the flags-2 sprite grid', () => {
expect(getBoardFlagDefinition('pol', 'AC')).toMatchObject({
code: 'AC',
label: 'Anarcho-Capitalist',
spritePath: 'assets/icons/flags-2.png',
width: 16,
height: 12,
x: 0,
y: 0,
});
expect(getBoardFlagDefinition('pol', 'WP')).toMatchObject({ code: 'WP', x: 64, y: 48 });
});
it('maps pony flags to the flags-pony sprite grid', () => {
expect(getBoardFlagDefinition('pony', '4CC')).toMatchObject({
code: '4CC',
label: '4cc /mlp/',
spritePath: 'assets/icons/flags-pony.png',
width: 16,
height: 16,
x: 0,
y: 0,
});
expect(getBoardFlagDefinition('pony', 'AJ')).toMatchObject({ code: 'AJ', label: 'Applejack', x: 80, y: 0 });
expect(getBoardFlagDefinition('pony', 'ZS')).toMatchObject({ code: 'ZS', label: 'Zipp Storm', x: 16, y: 144 });
});
});
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsFromSelection, getCommentFlagRequestFromSelection } from '../comment-flag-selection';
describe('comment-flag-selection', () => {
it('does not expose options for boards without flags', () => {
expect(getCommentFlagOptionsForDirectory({ features: {}, title: '/mu/ - Music' })).toEqual([]);
});
it('uses geographic location as the default for country flag boards', () => {
expect(
getCommentFlagOptionsForDirectory({
directoryCode: 'int',
features: { hasFlags: true },
title: '/int/ - International',
}),
).toEqual([{ label: 'Geographic Location', value: 'country:auto' }]);
});
it('matches the 4chan /pol/ flag order', () => {
const options = getCommentFlagOptionsForDirectory({
directoryCode: 'pol',
features: { hasFlags: true },
title: '/pol/ - Politically Incorrect',
});
expect(options.slice(0, 6)).toEqual([
{ label: 'Geographic Location', value: 'country:auto' },
{ label: 'Anarcho-Capitalist', value: 'pol:AC' },
{ label: 'Anarchist', value: 'pol:AN' },
{ label: 'Black Nationalist', value: 'pol:BL' },
{ label: 'Confederate', value: 'pol:CF' },
{ label: 'Communist', value: 'pol:CM' },
]);
});
it('matches the 4chan /mlp/ flag default', () => {
const options = getCommentFlagOptionsForDirectory({
directoryCode: 'mlp',
features: { hasFlags: true },
title: '/mlp/ - Pony',
});
expect(options.slice(0, 4)).toEqual([
{ label: 'None', value: 'none' },
{ label: '4cc /mlp/', value: 'pony:4CC' },
{ label: 'Adagio Dazzle', value: 'pony:ADA' },
{ label: 'Anon', value: 'pony:AN' },
]);
});
it('falls back to the board title when directoryCode is blank', () => {
const options = getCommentFlagOptionsForDirectory({
directoryCode: ' ',
features: { hasFlags: true },
title: '/mlp/ - Pony',
});
expect(options.slice(0, 2)).toEqual([
{ label: 'None', value: 'none' },
{ label: '4cc /mlp/', value: 'pony:4CC' },
]);
});
it('serializes selected flags as challenge-readable flag requests', () => {
expect(getCommentFlagRequestFromSelection('country:auto')).toEqual({
type: 'country',
code: 'auto',
text: 'flag:country:auto',
});
expect(getCommentFlagRequestFromSelection('pol:AC')).toEqual({
type: 'pol',
code: 'AC',
text: 'flag:pol:AC',
});
expect(getCommentFlagRequestFromSelection('pony:AJ')).toEqual({
type: 'pony',
code: 'AJ',
text: 'flag:pony:AJ',
});
expect(getCommentFlagRequestFromSelection('none')).toBeUndefined();
});
it('publishes selected flags as signed comment flairs and challenge answers', () => {
expect(getCommentFlagPublishOptionsFromSelection('country:auto')).toEqual({
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'],
},
flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }],
});
expect(getCommentFlagPublishOptionsFromSelection('pony:AJ')).toEqual({
challengeRequest: {
challengeAnswers: ['bitsocial-flags:5chan:flag:pony:AJ'],
},
flairs: [{ type: 'pony', code: 'AJ', text: 'flag:pony:AJ' }],
});
});
it('clears flag publish data when no flag is selected', () => {
expect(getCommentFlagPublishOptionsFromSelection('none')).toEqual({
challengeRequest: undefined,
flairs: undefined,
});
expect(getCommentFlagPublishOptionsFromSelection(undefined)).toEqual({
challengeRequest: undefined,
flairs: undefined,
});
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { getCommentFlagFlairs, getAuthorFlagFlairs, getAuthorFlagViewModels } from '../comment-flags';
describe('comment-flags', () => {
it('parses the pkc-js challenge flair example for country flags', () => {
const [flag] = getAuthorFlagViewModels([{ text: 'US-emoji' }], 1000);
expect(flag).toMatchObject({
key: 'country:us',
type: 'country',
code: 'us',
spritePath: 'assets/icons/flags-1.png',
width: 16,
height: 11,
x: 240,
y: 154,
});
});
it('parses namespaced country, political, and pony flag flairs', () => {
const flags = getAuthorFlagViewModels([{ text: 'flag:country:DE' }, { text: 'flag:pol:AC' }, { text: 'flag:pony:AJ' }], 1000);
expect(flags.map((flag) => flag.key)).toEqual(['country:de', 'pol:AC', 'pony:AJ']);
expect(flags[1]).toMatchObject({ label: 'Anarcho-Capitalist', x: 0, y: 0 });
expect(flags[2]).toMatchObject({ label: 'Applejack', x: 80, y: 0 });
});
it('normalizes text flag kinds before parsing', () => {
const flags = getAuthorFlagViewModels([{ text: 'FLAG:COUNTRY:DE' }, { text: 'POL:AC' }, { text: 'flag:MLP:AJ' }], 1000);
expect(flags.map((flag) => flag.key)).toEqual(['country:de', 'pol:AC', 'pony:AJ']);
});
it('parses structured flag flair fields', () => {
const flags = getAuthorFlagViewModels(
[
{ text: 'ignored', type: 'Country', country: 'GB' },
{ text: 'ignored', kind: 'MLP', code: 'RD' },
],
1000,
);
expect(flags.map((flag) => flag.key)).toEqual(['country:gb', 'pony:RD']);
});
it('skips expired, duplicate, and unknown flags', () => {
const flags = getAuthorFlagViewModels(
[{ text: 'flag:country:DE', expiresAt: 999 }, { text: 'flag:country:DE' }, { text: 'DE-emoji' }, { text: 'flag:pony:NOPE' }],
1000,
);
expect(flags.map((flag) => flag.key)).toEqual(['country:de']);
});
it('prefers trusted challenge-written author community flairs', () => {
const flairs = getAuthorFlagFlairs({
flairs: [{ text: 'flag:pol:AC' }],
community: {
flairs: [{ text: 'US-emoji' }],
},
});
expect(getAuthorFlagViewModels(flairs).map((flag) => flag.key)).toEqual(['country:us']);
});
it('reads the signed 5chan flag assertion from the comment', () => {
const flairs = getCommentFlagFlairs({
'5chan': {
country: 'VN',
flag: {
code: 'VN',
text: 'flag:country:vn',
type: 'country',
},
},
flairs: [{ code: 'auto', text: 'flag:country:auto', type: 'country' }],
author: {
community: {
flairs: [{ text: 'US-emoji' }],
},
},
});
expect(getAuthorFlagViewModels(flairs).map((flag) => flag.key)).toEqual(['country:vn']);
});
it('falls back to author or post flairs when there is no signed flag assertion', () => {
expect(
getAuthorFlagViewModels(
getCommentFlagFlairs({
author: {
subplebbit: {
flairs: [{ text: 'flag:country:DE' }],
},
},
flairs: [{ text: 'flag:pony:AJ' }],
}),
).map((flag) => flag.key),
).toEqual(['country:de']);
expect(
getAuthorFlagViewModels(
getCommentFlagFlairs({
flairs: [{ text: 'flag:pony:AJ' }],
}),
).map((flag) => flag.key),
).toEqual(['pony:AJ']);
});
});
+190
View File
@@ -0,0 +1,190 @@
export type BoardFlagKind = 'pol' | 'pony';
export interface BoardFlagDefinition {
kind: BoardFlagKind;
code: string;
label: string;
spritePath: string;
width: number;
height: number;
x: number;
y: number;
}
const POLITICAL_FLAG_WIDTH = 16;
const POLITICAL_FLAG_HEIGHT = 12;
const POLITICAL_FLAG_COLUMNS = 5;
const POLITICAL_FLAG_SPRITE_PATH = 'assets/icons/flags-2.png';
const PONY_FLAG_WIDTH = 16;
const PONY_FLAG_HEIGHT = 16;
const PONY_FLAG_COLUMNS = 9;
const PONY_FLAG_SPRITE_PATH = 'assets/icons/flags-pony.png';
const POLITICAL_FLAG_ENTRIES = [
['AC', 'Anarcho-Capitalist'],
['AN', 'Anarchist'],
['BL', 'Black Nationalist'],
['CF', 'Confederate'],
['CM', 'Communist'],
['CT', 'Catalonia'],
['DM', 'Democrat'],
['EU', 'European'],
['FC', 'Fascist'],
['GN', 'Gadsden'],
['GY', 'Gay'],
['JH', 'Jihadi'],
['KN', 'Kekistani'],
['MF', 'Muslim'],
['NB', 'National Bolshevik'],
['NT', 'NATO'],
['NZ', 'Nazi'],
['PC', 'Hippie'],
['PR', 'Pirate'],
['RE', 'Republican'],
['TM', 'Templar'],
['MZ', 'Task Force Z'],
['TR', 'Tree Hugger'],
['UN', 'United Nations'],
['WP', 'White Supremacist'],
] as const;
const PONY_FLAG_ENTRIES = [
['4CC', '4cc /mlp/'],
['ADA', 'Adagio Dazzle'],
['AN', 'Anon'],
['ANF', 'Anonfilly'],
['APB', 'Apple Bloom'],
['AJ', 'Applejack'],
['AB', 'Aria Blaze'],
['AU', 'Autumn Blaze'],
['BB', 'Bon Bon'],
['BM', 'Big Mac'],
['BP', 'Berry Punch'],
['BS', 'Babs Seed'],
['CL', 'Changeling'],
['CO', 'Coco Pommel'],
['CG', 'Cozy Glow'],
['CHE', 'Cheerilee'],
['CB', 'Cherry Berry'],
['DAY', 'Daybreaker'],
['DD', 'Daring Do'],
['DER', 'Derpy Hooves'],
['DT', 'Diamond Tiara'],
['DIS', 'Discord'],
['EQA', 'EqG Applejack'],
['EQF', 'EqG Fluttershy'],
['EQP', 'EqG Pinkie Pie'],
['EQR', 'EqG Rainbow Dash'],
['EQT', 'EqG Trixie'],
['EQI', 'EqG Twilight Sparkle'],
['EQS', 'EqG Sunset Shimmer'],
['ERA', 'EqG Rarity'],
['FAU', 'Fausticorn'],
['FLE', 'Fleur de lis'],
['FL', 'Fluttershy'],
['GI', 'Gilda'],
['HT', 'Hitch Trailblazer'],
['IZ', 'Izzy Moonbow'],
['LI', 'Limestone'],
['LT', 'Lord Tirek'],
['LY', 'Lyra Heartstrings'],
['MA', 'Marble'],
['MAU', 'Maud'],
['MIN', 'Minuette'],
['NI', 'Nightmare Moon'],
['NUR', 'Nurse Redheart'],
['OCT', 'Octavia'],
['PAR', 'Parasprite'],
['PC', 'Princess Cadance'],
['PCE', 'Princess Celestia'],
['PI', 'Pinkie Pie'],
['PLU', 'Princess Luna'],
['PM', 'Pinkamena'],
['PP', 'Pipp Petals'],
['QC', 'Queen Chrysalis'],
['RAR', 'Rarity'],
['RD', 'Rainbow Dash'],
['RLU', 'Roseluck'],
['S1L', 'S1 Luna'],
['SCO', 'Scootaloo'],
['SHI', 'Shining Armor'],
['SIL', 'Silver Spoon'],
['SON', 'Sonata Dusk'],
['SP', 'Spike'],
['SPI', 'Spitfire'],
['SS', 'Sunny Starscout'],
['STA', 'Star Dancer'],
['STL', 'Starlight Glimmer'],
['SPT', 'Sprout'],
['SUN', 'Sunburst'],
['SUS', 'Sunset Shimmer'],
['SWB', 'Sweetie Belle'],
['TFA', 'TFH Arizona'],
['TFO', 'TFH Oleander'],
['TFP', 'TFH Paprika'],
['TFS', 'TFH Shanty'],
['TFT', 'TFH Tianhuo'],
['TFV', 'TFH Velvet'],
['TP', 'TFH Pom'],
['TS', 'Tempest Shadow'],
['TWI', 'Twilight Sparkle'],
['TX', 'Trixie'],
['VS', 'Vinyl Scratch'],
['ZE', 'Zecora'],
['ZS', 'Zipp Storm'],
] as const;
const normalizeCode = (value: string | undefined) => value?.trim().toUpperCase();
const buildFlagDefinitions = (
entries: readonly (readonly [string, string])[],
kind: BoardFlagKind,
spritePath: string,
width: number,
height: number,
columns: number,
): ReadonlyMap<string, BoardFlagDefinition> =>
new Map(
entries.map(([code, label], index) => [
code,
{
kind,
code,
label,
spritePath,
width,
height,
x: (index % columns) * width,
y: Math.floor(index / columns) * height,
},
]),
);
const BOARD_FLAGS_BY_KIND: Record<BoardFlagKind, ReadonlyMap<string, BoardFlagDefinition>> = {
pol: buildFlagDefinitions(POLITICAL_FLAG_ENTRIES, 'pol', POLITICAL_FLAG_SPRITE_PATH, POLITICAL_FLAG_WIDTH, POLITICAL_FLAG_HEIGHT, POLITICAL_FLAG_COLUMNS),
pony: buildFlagDefinitions(PONY_FLAG_ENTRIES, 'pony', PONY_FLAG_SPRITE_PATH, PONY_FLAG_WIDTH, PONY_FLAG_HEIGHT, PONY_FLAG_COLUMNS),
};
export const normalizeBoardFlagKind = (value: string | undefined): BoardFlagKind | undefined => {
const kind = value?.trim().toLowerCase();
if (kind === 'pol' || kind === 'political' || kind === 'meme' || kind === 'memeflag' || kind === 'memeflags') {
return 'pol';
}
if (kind === 'pony' || kind === 'mlp') {
return 'pony';
}
return undefined;
};
export const getBoardFlagDefinition = (kindValue: string | undefined, codeValue: string | undefined): BoardFlagDefinition | undefined => {
const kind = normalizeBoardFlagKind(kindValue);
const code = normalizeCode(codeValue);
if (!kind || !code) return undefined;
return BOARD_FLAGS_BY_KIND[kind].get(code);
};
export const getBoardFlagDefinitions = (kindValue: string | undefined): BoardFlagDefinition[] => {
const kind = normalizeBoardFlagKind(kindValue);
return kind ? [...BOARD_FLAGS_BY_KIND[kind].values()] : [];
};
+108
View File
@@ -0,0 +1,108 @@
import { getBoardFlagDefinition, getBoardFlagDefinitions, type BoardFlagKind } from './board-flags';
import type { DirectoryCommunity } from './utils/directory-list-utils';
const GEOGRAPHIC_LOCATION_FLAG_VALUE = 'country:auto';
const NO_FLAG_VALUE = 'none';
const FLAG_CHALLENGE_ANSWER_PREFIX = 'bitsocial-flags:5chan:';
export interface CommentFlagSelectOption {
value: string;
label: string;
}
export interface CommentFlagRequest {
type: 'country' | BoardFlagKind;
code: string;
text: string;
}
interface CommentFlagChallengeRequest {
challengeAnswers: string[];
}
export interface CommentFlagPublishOptions {
challengeRequest: CommentFlagChallengeRequest | undefined;
flairs: CommentFlagRequest[] | undefined;
}
const GEOGRAPHIC_LOCATION_OPTION: CommentFlagSelectOption = {
value: GEOGRAPHIC_LOCATION_FLAG_VALUE,
label: 'Geographic Location',
};
const NO_FLAG_OPTION: CommentFlagSelectOption = {
value: NO_FLAG_VALUE,
label: 'None',
};
const getDirectoryCode = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'title'> | undefined): string | undefined => {
const directoryCode = directory?.directoryCode?.trim().toLowerCase();
return directoryCode || directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase();
};
const getBoardFlagOptions = (kind: BoardFlagKind): CommentFlagSelectOption[] =>
getBoardFlagDefinitions(kind).map((flag) => ({
value: `${flag.kind}:${flag.code}`,
label: flag.label,
}));
export const getCommentFlagOptionsForDirectory = (directory: Pick<DirectoryCommunity, 'directoryCode' | 'features' | 'title'> | undefined): CommentFlagSelectOption[] => {
if (directory?.features?.hasFlags !== true) {
return [];
}
const directoryCode = getDirectoryCode(directory);
if (directoryCode === 'mlp') {
return [NO_FLAG_OPTION, ...getBoardFlagOptions('pony')];
}
if (directoryCode === 'pol') {
return [GEOGRAPHIC_LOCATION_OPTION, ...getBoardFlagOptions('pol')];
}
return [GEOGRAPHIC_LOCATION_OPTION];
};
export const getCommentFlagRequestFromSelection = (value: string | undefined): CommentFlagRequest | undefined => {
if (!value || value === NO_FLAG_VALUE) {
return undefined;
}
if (value === GEOGRAPHIC_LOCATION_FLAG_VALUE) {
return {
type: 'country',
code: 'auto',
text: 'flag:country:auto',
};
}
const match = value.match(/^(pol|pony):([A-Z0-9]+)$/);
if (!match) {
return undefined;
}
const [, kind, code] = match;
const flag = getBoardFlagDefinition(kind, code);
return flag
? {
type: flag.kind,
code: flag.code,
text: `flag:${flag.kind}:${flag.code}`,
}
: undefined;
};
export const getCommentFlagPublishOptionsFromSelection = (value: string | undefined): CommentFlagPublishOptions => {
const flag = getCommentFlagRequestFromSelection(value);
return flag
? {
challengeRequest: {
challengeAnswers: [`${FLAG_CHALLENGE_ANSWER_PREFIX}${flag.text}`],
},
flairs: [flag],
}
: {
challengeRequest: undefined,
flairs: undefined,
};
};
+221
View File
@@ -0,0 +1,221 @@
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 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;
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<string, unknown> => 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: getCountryLabel(code) ?? code.toUpperCase(),
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<string>();
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;
};