This commit is contained in:
Echo 2025-11-17 15:08:24 +00:00 committed by GitHub
commit f426c5ee97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 740 additions and 36 deletions

View File

@ -0,0 +1,74 @@
import type { GroupKey, GroupMessage } from 'emojibase';
import groupData from 'emojibase-data/meta/groups.json';
import type { CustomEmojiData } from '@/mastodon/features/emoji/types';
type CustomGroupMessage = Omit<GroupMessage, 'key'> & {
key: string;
};
export const groupsToHide: readonly GroupKey[] = ['component'];
export const groupKeysToNumber = Object.fromEntries(
Object.entries(groupData.groups).map(([number, key]) => [
key,
Number(number),
]),
);
export const mockCustomGroups = [
{ key: 'blobcat', message: 'Blobcat', order: 1 },
{ key: 'lgbt', message: 'LGBTQ+', order: 2 },
{ key: 'logos', message: 'Logos', order: 3 },
] satisfies CustomGroupMessage[];
export const mockCustomEmojis = [
{
shortcode: 'blobcat_heart',
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/217/original/abede62a1fe634cf.png',
static_url:
'https://pics.ishella.gay/custom_emojis/images/000/001/217/static/abede62a1fe634cf.png',
visible_in_picker: true,
category: 'blobcat',
},
{
shortcode: 'blobcat_wave',
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/250/original/f924277a36414906.png',
static_url:
'https://pics.ishella.gay/custom_emojis/images/000/001/250/static/f924277a36414906.png',
visible_in_picker: true,
category: 'blobcat',
},
{
shortcode: 'mastodon',
url: 'https://pics.ishella.gay/custom_emojis/images/000/025/993/original/56c38669cdca5d1c.png',
static_url:
'https://pics.ishella.gay/custom_emojis/images/000/025/993/static/56c38669cdca5d1c.png',
visible_in_picker: true,
category: 'mastodon',
},
{
shortcode: 'fediverse',
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/198/original/b8041a4f365c4518.png',
static_url:
'https://pics.ishella.gay/custom_emojis/images/000/001/198/static/b8041a4f365c4518.png',
visible_in_picker: true,
category: 'logos',
},
{
shortcode: 'ace_heart',
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/220/original/8689758e37a1bfbc.png',
static_url:
'https://pics.ishella.gay/custom_emojis/images/000/001/220/static/8689758e37a1bfbc.png',
visible_in_picker: true,
category: 'lgbt',
},
{
shortcode: 'nbi_heart',
url: 'https://pics.ishella.gay/custom_emojis/images/000/001/199/original/a06d788bce50f260.png',
static_url:
'https://pics.ishella.gay/custom_emojis/images/000/001/199/static/a06d788bce50f260.png',
visible_in_picker: true,
category: 'lgbt',
},
] satisfies (CustomEmojiData & { category: string })[];

View File

@ -0,0 +1,53 @@
import type { FC } from 'react';
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { CompactEmoji } from 'emojibase';
import { flattenEmojiData } from 'emojibase';
import { action } from 'storybook/actions';
import { putEmojiData } from '@/mastodon/features/emoji/database';
import { toSupportedLocale } from '@/mastodon/features/emoji/locale';
import { MockEmojiPicker } from './index';
const onSelect = action('emoji selected');
const meta = {
title: 'Components/Emoji/EmojiPicker',
render(_args, { globals }) {
const locale = typeof globals.locale === 'string' ? globals.locale : 'en';
return <StoryComponent locale={locale} key={locale} />;
},
} satisfies Meta;
const StoryComponent: FC<{ locale: string }> = ({ locale }) => {
const [loaded, setLoaded] = useState(false);
if (!loaded) {
void loadEmojiData(locale).then(() => {
action('emoji data loaded')(locale);
setLoaded(true);
});
}
if (!loaded) {
return null;
}
return <MockEmojiPicker onSelect={onSelect} />;
};
async function loadEmojiData(localeString: string) {
const locale = toSupportedLocale(localeString);
const emojis = (await import(
`../../../../../../node_modules/emojibase-data/${locale}/compact.json`
)) as { default: CompactEmoji[] };
const flattenedEmojis = flattenEmojiData(emojis.default);
await putEmojiData(flattenedEmojis, locale);
}
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@ -0,0 +1,71 @@
import type { FC, MouseEventHandler } from 'react';
import { useCallback, useState, useEffect } from 'react';
import classNames from 'classnames';
import { loadUnicodeEmojiGroupIcon } from '@/mastodon/features/emoji/database';
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
import type { AnyEmojiData } from '@/mastodon/features/emoji/types';
import { Emoji } from '..';
import { mockCustomEmojis, groupKeysToNumber } from './constants';
import classes from './styles.module.css';
interface PickerGroupButtonProps {
onSelect: (key: string) => void;
group: string;
message: string;
disabled?: boolean;
}
export const PickerGroupButton: FC<PickerGroupButtonProps> = ({
onSelect,
message,
group,
disabled = false,
}) => {
const handleClick: MouseEventHandler = useCallback(
(event) => {
event.preventDefault();
onSelect(group);
},
[onSelect, group],
);
const { currentLocale } = useEmojiAppState();
const [icon, setIcon] = useState<AnyEmojiData | null>(() => {
const emoji = mockCustomEmojis.find((emoji) => emoji.category === group);
return emoji ?? null;
});
useEffect(() => {
if (icon !== null) {
return;
}
if (group in groupKeysToNumber) {
const groupNum = groupKeysToNumber[group];
if (typeof groupNum !== 'undefined') {
void loadUnicodeEmojiGroupIcon(groupNum, currentLocale).then(setIcon);
}
}
}, [currentLocale, group, icon]);
return (
<li>
<button
type='button'
title={message}
onClick={handleClick}
className={classNames(classes.groupButton)}
disabled={disabled}
>
{icon && (
<Emoji
code={'unicode' in icon ? icon.unicode : `:${icon.shortcode}:`}
/>
)}
</button>
</li>
);
};

View File

@ -0,0 +1,40 @@
import { useMemo, useState } from 'react';
import { usePrevious } from '@dnd-kit/utilities';
import type { MessagesDataset } from 'emojibase';
import enMessages from 'emojibase-data/en/messages.json';
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
import { groupsToHide } from './constants';
export function useLocaleMessages() {
const { currentLocale } = useEmojiAppState();
// This isn't needed in real life, as the current locale is only set on page load.
// However it Storybook can update the locale without a refresh.
const prevLocale = usePrevious(currentLocale);
const [messages, setMessages] = useState<MessagesDataset>(enMessages);
if (prevLocale !== currentLocale) {
// This is messy, but it's just for the mock picker.
import(
`../../../../../../node_modules/emojibase-data/${currentLocale}/messages.json`
)
.then((module: { default: MessagesDataset }) => {
setMessages(module.default);
})
.catch((err: unknown) => {
console.warn('fell back to en messages', err);
});
}
const groups = useMemo(
() => messages.groups.filter((group) => !groupsToHide.includes(group.key)),
[messages.groups],
);
return {
...messages,
groups,
};
}

View File

@ -0,0 +1,136 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FC, MouseEventHandler } from 'react';
import classNames from 'classnames';
import type { AnyEmojiData } from '@/mastodon/features/emoji/types';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { IconButton } from '../../icon_button';
import { CustomEmojiProvider } from '../context';
import { mockCustomEmojis, mockCustomGroups } from './constants';
import { PickerGroupButton } from './group-button';
import { useLocaleMessages } from './hooks';
import { PickerGroupList } from './list';
import { PickerSettings } from './settings';
import classes from './styles.module.css';
interface MockEmojiPickerProps {
onSelect?: (emojiCode: string) => void;
}
export const MockEmojiPicker: FC<MockEmojiPickerProps> = ({ onSelect }) => {
const handleEmojiSelect = useCallback(
(emoji: AnyEmojiData) => {
if (onSelect) {
const code =
'unicode' in emoji ? emoji.unicode : `:${emoji.shortcode}:`;
onSelect(code);
}
},
[onSelect],
);
const { groups } = useLocaleMessages();
const wrapperRef = useRef<HTMLDivElement>(null);
const handleGroupSelect = useCallback((key: string) => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
if (mockCustomGroups.at(0)?.key === key) {
wrapper.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
const groupHeader = wrapper.querySelector<HTMLHeadingElement>(
`[data-group="${key}"]`,
);
if (groupHeader) {
groupHeader.focus({ preventScroll: true });
groupHeader.scrollIntoView({ behavior: 'smooth' });
}
}, []);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const searchInput = searchRef.current;
if (!searchInput) return;
searchInput.focus();
}, []);
const [showSettings, setShowSettings] = useState(false);
const handleSettingsClick: MouseEventHandler = useCallback((event) => {
event.preventDefault();
setShowSettings((prev) => !prev);
}, []);
return (
<CustomEmojiProvider emojis={mockCustomEmojis}>
<div className={classes.wrapper}>
<div className={classes.header}>
<input
type='search'
placeholder='Search emojis'
className={classes.search}
ref={searchRef}
/>
<IconButton
icon='settings'
iconComponent={SettingsIcon}
title='Picker settings'
onClick={handleSettingsClick}
/>
</div>
{showSettings && <PickerSettings />}
{!showSettings && (
<div className={classes.main} ref={wrapperRef}>
{mockCustomGroups.map((group) => (
<PickerGroupList
key={group.key}
group={group.key}
name={group.message}
onSelect={handleEmojiSelect}
/>
))}
{groups.map((group) => (
<PickerGroupList
key={group.key}
group={group.key}
name={group.message}
onSelect={handleEmojiSelect}
/>
))}
</div>
)}
<ul
className={classNames(
classes.nav,
showSettings && classes.settingsNav,
)}
>
{mockCustomGroups.map((group) => (
<PickerGroupButton
key={group.key}
onSelect={handleGroupSelect}
message={group.message}
group={group.key}
disabled={showSettings}
/>
))}
<li key='separator' className={classes.separator} />
{groups.map((group) => (
<PickerGroupButton
key={group.key}
onSelect={handleGroupSelect}
message={group.message}
group={group.key}
disabled={showSettings}
/>
))}
</ul>
</div>
</CustomEmojiProvider>
);
};

View File

@ -0,0 +1,101 @@
import { useCallback, useState } from 'react';
import type { FC, MouseEventHandler } from 'react';
import classNames from 'classnames';
import { loadUnicodeEmojiGroup } from '@/mastodon/features/emoji/database';
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
import type { AnyEmojiData } from '@/mastodon/features/emoji/types';
import ArrowIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
import { Emoji } from '..';
import { groupKeysToNumber, mockCustomEmojis } from './constants';
import classes from './styles.module.css';
interface PickerGroupListProps {
group: string;
name: string;
onSelect: (emoji: AnyEmojiData) => void;
}
export const PickerGroupList: FC<PickerGroupListProps> = ({
group,
name,
onSelect,
}) => {
const [emojis, setEmojis] = useState<AnyEmojiData[] | null>(() => {
const emojis = mockCustomEmojis.filter((emoji) => emoji.category === group);
return emojis.length > 0 ? emojis : null;
});
const { currentLocale } = useEmojiAppState();
if (group in groupKeysToNumber && emojis === null) {
const groupNum = groupKeysToNumber[group];
if (typeof groupNum !== 'undefined') {
void loadUnicodeEmojiGroup(groupNum, currentLocale).then(setEmojis);
}
}
const [isMinimized, setMinimized] = useState(false);
const handleToggleMinimize = useCallback(() => {
setMinimized((prev) => !prev);
}, []);
// Still loading emojis.
if (emojis === null) {
return null;
}
return (
<div tabIndex={-1}>
<h2
className={classNames(
classes.groupHeader,
isMinimized && classes.isMinimized,
)}
>
<button type='button' onClick={handleToggleMinimize} data-group={group}>
{name}
<ArrowIcon className={classes.groupHeaderArrow} />
</button>
</h2>
{!isMinimized && (
<ul className={classes.emojiGrid}>
{emojis.map((emoji) => (
<PickerListEmoji
key={'unicode' in emoji ? emoji.hexcode : emoji.shortcode}
emoji={emoji}
onClick={onSelect}
/>
))}
</ul>
)}
</div>
);
};
interface PickerListEmojiProps {
emoji: AnyEmojiData;
onClick: (emoji: AnyEmojiData) => void;
}
const PickerListEmoji: FC<PickerListEmojiProps> = ({ emoji, onClick }) => {
const handleClick: MouseEventHandler = useCallback(() => {
onClick(emoji);
}, [emoji, onClick]);
return (
<li>
<button
type='button'
title={'unicode' in emoji ? emoji.label : `:${emoji.shortcode}:`}
onClick={handleClick}
className={classes.listButton}
>
<Emoji
code={'unicode' in emoji ? emoji.unicode : `:${emoji.shortcode}:`}
/>
</button>
</li>
);
};

View File

@ -0,0 +1,36 @@
import type { FC } from 'react';
import type { SkinToneKey } from 'emojibase';
import { useLocaleMessages } from './hooks';
import classes from './styles.module.css';
const toneToEmoji: Record<SkinToneKey, string> = {
light: '👋🏻',
'medium-light': '👋🏼',
medium: '👋🏽',
'medium-dark': '👋🏾',
dark: '👋🏿',
};
export const PickerSettings: FC = () => {
const { skinTones } = useLocaleMessages();
return (
<div className={classes.main}>
<h1>Emoji Settings</h1>
<label>
<select>
<option value='default' title='Default skin tone'>
👋
</option>
{skinTones.map((tone) => (
<option key={tone.key} value={tone.key} title={tone.message}>
{toneToEmoji[tone.key]}
</option>
))}
</select>{' '}
Skin tone
</label>
</div>
);
};

View File

@ -0,0 +1,160 @@
.wrapper {
--max-width: 20rem;
--col-count: 6;
display: grid;
gap: 0.5rem;
grid-template-rows: auto 1fr;
grid-template-columns: 2rem minmax(10rem, calc(var(--max-width) - 4rem));
background-color: lightgray;
padding: 0.5rem;
border-radius: 0.25rem;
width: 100%;
max-width: var(--max-width);
max-height: 50vh;
box-sizing: border-box;
:global(.emojione) {
margin: 0;
}
* {
box-sizing: border-box;
}
}
.header {
display: flex;
gap: 0.5rem;
align-items: center;
grid-area: 1 / 1 / 1 / 3;
svg {
color: gray;
}
}
.search {
flex-grow: 1;
appearance: none;
border: 1px solid gray;
border-radius: 0.25rem;
height: 100%;
padding: 0.25rem 0.5rem;
}
.main {
grid-area: 2 / 2 / 3 / 3;
border-radius: 0.25rem;
background-color: whitesmoke;
overflow: hidden auto;
scrollbar-width: thin;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.groupHeader {
font-size: 1rem;
font-weight: bold;
margin-bottom: 0.5rem;
transition: opacity 0.1s ease;
&:not(.isMinimized):hover {
opacity: 0.7;
}
button {
appearance: none;
border: none;
background-color: transparent;
padding: 0;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
}
.groupHeader.isMinimized {
opacity: 0.5;
}
.groupHeaderArrow {
transition: transform 0.1s ease;
width: 1rem;
height: 1rem;
}
.isMinimized .groupHeaderArrow {
transform: rotate(-90deg);
}
.emojiGrid {
display: grid;
grid-template-columns: repeat(var(--col-count), 1fr);
gap: 0.5rem;
}
.listButton,
.groupButton {
aspect-ratio: 1;
padding: 0;
appearance: none;
display: block;
> * {
pointer-events: none;
}
}
.listButton {
border: none;
background-color: transparent;
font-size: 1.5rem;
text-align: center;
width: 100%;
border-radius: 5px;
transition: background-color 0.1s ease;
&:hover {
background-color: gray;
}
> img {
padding: 5px;
}
> * {
width: 100%;
height: 100%;
}
}
.nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
grid-area: 2 / 1 / 3 / 1;
overflow: hidden scroll;
scrollbar-width: none;
}
.groupButton {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid gray;
.settingsNav & {
opacity: 0.5;
pointer-events: none;
}
}
.separator {
height: 1px;
background-color: gray;
flex-shrink: 0;
}

View File

@ -30,8 +30,9 @@ interface LocaleTable {
value: UnicodeEmojiData;
indexes: {
group: number;
groupOrder: [number, number];
label: string;
order: number;
order?: number;
tags: string[];
};
}
@ -39,7 +40,7 @@ type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
const loadedLocales = new Set<Locale>();
@ -52,24 +53,42 @@ const loadDB = (() => {
// Actually load the DB.
async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
database.createObjectStore('etags');
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
upgrade(database, oldVersion, _newVersion, transaction) {
const storeNames = database.objectStoreNames;
if (!storeNames.contains('custom')) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
customTable.createIndex('category', 'category');
}
if (!storeNames.contains('etags')) {
database.createObjectStore('etags');
}
for (const locale of SUPPORTED_LOCALES) {
if (!storeNames.contains(locale)) {
database.createObjectStore(locale, {
keyPath: 'hexcode',
autoIncrement: false,
});
}
const localeTable = transaction.objectStore(locale);
if (oldVersion < 1) {
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
}
if (oldVersion < 2) {
if (localeTable.indexNames.contains('order')) {
localeTable.deleteIndex('order');
}
localeTable.createIndex('groupOrder', ['group', 'order'], {
unique: false,
});
}
}
},
});
@ -172,6 +191,29 @@ export async function loadLatestEtag(localeString: string) {
return etag ?? null;
}
export async function loadUnicodeEmojiGroup(
group: number,
localeString: string,
) {
const locale = toLoadedLocale(localeString);
const db = await loadDB();
const emojis = await db.getAllFromIndex(locale, 'group', group);
return emojis.toSorted(({ order: a = 0 }, { order: b = 0 }) => a - b);
}
export async function loadUnicodeEmojiGroupIcon(
group: number,
localeString: string,
) {
const locale = toLoadedLocale(localeString);
const db = await loadDB();
const trx = db.transaction(locale, 'readonly');
const index = trx.store.index('groupOrder');
const range = IDBKeyRange.bound([group, 0], [group, Number.MAX_SAFE_INTEGER]);
const cursor = await index.openCursor(range);
return cursor?.value ?? null;
}
// Private functions
async function syncLocales(db: Database) {

View File

@ -1,9 +1,6 @@
import type { CompactEmoji } from 'emojibase';
import { http, HttpResponse } from 'msw';
import { action } from 'storybook/actions';
import { toSupportedLocale } from '@/mastodon/features/emoji/locale';
import { customEmojiFactory, relationshipsFactory } from './factories';
export const mockHandlers = {
@ -47,21 +44,6 @@ export const mockHandlers = {
action('fetching custom emoji data')();
return HttpResponse.json([customEmojiFactory()]);
}),
emojiData: http.get<{ locale: string }>(
'/packs-dev/emoji/:locale.json',
async ({ params }) => {
const locale = toSupportedLocale(params.locale);
action('fetching emoji data')(locale);
const { default: data } = (await import(
/* @vite-ignore */
`emojibase-data/${locale}/compact.json`
)) as {
default: CompactEmoji[];
};
return HttpResponse.json([data]);
},
),
};
export const unhandledRequestHandler = ({ url }: Request) => {

View File

@ -43,5 +43,14 @@ module.exports = {
],
},
},
{
'files': ['app/javascript/**/*.module.scss', 'app/javascript/**/*.module.css'],
rules: {
'selector-pseudo-class-no-unknown': [
true,
{ ignorePseudoClasses: ['global'] },
],
}
}
],
};