mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(flags): add comment flags (#1140)
* feat(flags): add comment flags * fix(flags): address review feedback * fix(flags): clear stale flag publish data
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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()] : [];
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user