Update emojis and icons with the new structure

This commit is contained in:
Hakan Shehu
2024-11-09 17:29:22 +01:00
parent 372fe62fa8
commit 0dffc7d7ff
28 changed files with 417 additions and 38351 deletions

View File

@@ -104,6 +104,7 @@
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.5.0",
"unzipper": "^0.12.3",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -120,6 +121,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-window": "^1.8.8",
"@types/unzipper": "^0.10.10",
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
import { app, net } from 'electron';
import path from 'path';
import fs from 'fs';
import unzipper from 'unzipper';
import { EmojiData } from '@/types/emojis';
import { IconData } from '@/types/icons';
type AssetsVersion = {
date: string;
emojis: string;
icons: string;
};
const EMOJIS_VERSION = '1.0.0';
const ICONS_VERSION = '1.0.0';
class AssetManager {
private readonly assetsDir: string;
constructor() {
this.assetsDir = path.join(app.getPath('userData'), 'assets');
}
public async handleAssetRequest(request: Request): Promise<Response> {
const url = request.url.replace('asset://', '');
const assetPath = this.getAssetPath(url);
const localFileUrl = `file://${assetPath}`;
return net.fetch(localFileUrl);
}
private getAssetPath(url: string): string {
if (url.includes('emojis') || url.includes('icons')) {
return path.join(this.assetsDir, url);
}
return path.join(__dirname, 'assets', url);
}
public getEmojiData(): EmojiData {
const emojisDir = path.join(this.assetsDir, 'emojis');
const emojisMetadataPath = path.join(emojisDir, 'emojis.json');
return JSON.parse(fs.readFileSync(emojisMetadataPath, 'utf8'));
}
public getIconData(): IconData {
const iconsDir = path.join(this.assetsDir, 'icons');
const iconsMetadataPath = path.join(iconsDir, 'icons.json');
return JSON.parse(fs.readFileSync(iconsMetadataPath, 'utf8'));
}
public async checkAssets(): Promise<void> {
if (!this.shouldUpdateAssets()) {
return;
}
await this.updateAssets();
}
private async updateAssets(): Promise<void> {
await this.updateEmojis();
await this.updateIcons();
this.writeAssetsVersion();
}
private async updateEmojis(): Promise<void> {
const emojisZipPath = path.join(this.assetsDir, 'emojis.zip');
const emojisDir = path.join(this.assetsDir, 'emojis');
if (fs.existsSync(emojisDir)) {
fs.rmSync(emojisDir, { recursive: true });
}
fs.mkdirSync(emojisDir, { recursive: true });
await fs
.createReadStream(emojisZipPath)
.pipe(unzipper.Extract({ path: emojisDir }))
.promise();
}
private async updateIcons(): Promise<void> {
const iconsZipPath = path.join(this.assetsDir, 'icons.zip');
const iconsDir = path.join(this.assetsDir, 'icons');
if (fs.existsSync(iconsDir)) {
fs.rmSync(iconsDir, { recursive: true });
}
fs.mkdirSync(iconsDir, { recursive: true });
await fs
.createReadStream(iconsZipPath)
.pipe(unzipper.Extract({ path: iconsDir }))
.promise();
}
private shouldUpdateAssets(): boolean {
const assetsVersion = this.readAssetsVersion();
if (!assetsVersion) {
return true;
}
return (
assetsVersion.emojis !== EMOJIS_VERSION ||
assetsVersion.icons !== ICONS_VERSION
);
}
private readAssetsVersion(): AssetsVersion | null {
const assetsVersionPath = path.join(this.assetsDir, 'version.json');
if (!fs.existsSync(assetsVersionPath)) {
return null;
}
return JSON.parse(fs.readFileSync(assetsVersionPath, 'utf8'));
}
private writeAssetsVersion(): void {
const assetsVersionPath = path.join(this.assetsDir, 'version.json');
fs.writeFileSync(
assetsVersionPath,
JSON.stringify({
date: new Date().toISOString(),
emojis: EMOJIS_VERSION,
icons: ICONS_VERSION,
})
);
}
}
export const assetManager = new AssetManager();

View File

@@ -0,0 +1,33 @@
import { assetManager } from '@/main/asset-manager';
import {
MutationChange,
ChangeCheckResult,
QueryHandler,
QueryResult,
} from '@/main/types';
import { EmojisGetQueryInput } from '@/operations/queries/emojis-get';
export class EmojisGetQueryHandler
implements QueryHandler<EmojisGetQueryInput>
{
public async handleQuery(
_: EmojisGetQueryInput
): Promise<QueryResult<EmojisGetQueryInput>> {
const data = assetManager.getEmojiData();
return {
output: data,
state: {},
};
}
public async checkForChanges(
_: MutationChange[],
__: EmojisGetQueryInput,
___: Record<string, any>
): Promise<ChangeCheckResult<EmojisGetQueryInput>> {
return {
hasChanges: false,
};
}
}

View File

@@ -0,0 +1,33 @@
import { assetManager } from '@/main/asset-manager';
import {
MutationChange,
ChangeCheckResult,
QueryHandler,
QueryResult,
} from '@/main/types';
import { IconsGetQueryInput } from '@/operations/queries/icons-get';
export class IconsGetQueryHandler implements QueryHandler<IconsGetQueryInput> {
public async handleQuery(
_: IconsGetQueryInput
): Promise<QueryResult<IconsGetQueryInput>> {
console.time('icons_get');
const data = assetManager.getIconData();
console.timeEnd('icons_get');
return {
output: data,
state: {},
};
}
public async checkForChanges(
_: MutationChange[],
__: IconsGetQueryInput,
___: Record<string, any>
): Promise<ChangeCheckResult<IconsGetQueryInput>> {
return {
hasChanges: false,
};
}
}

View File

@@ -17,6 +17,8 @@ import { ChatGetQueryHandler } from '@/main/handlers/queries/chat-get';
import { FileListQueryHandler } from '@/main/handlers/queries/file-list';
import { FileGetQueryHandler } from '@/main/handlers/queries/file-get';
import { DocumentGetQueryHandler } from '@/main/handlers/queries/document-get';
import { EmojisGetQueryHandler } from '@/main/handlers/queries/emojis-get';
import { IconsGetQueryHandler } from '@/main/handlers/queries/icons-get';
type QueryHandlerMap = {
[K in keyof QueryMap]: QueryHandler<QueryMap[K]['input']>;
@@ -40,4 +42,6 @@ export const queryHandlerMap: QueryHandlerMap = {
file_list: new FileListQueryHandler(),
file_get: new FileGetQueryHandler(),
document_get: new DocumentGetQueryHandler(),
emojis_get: new EmojisGetQueryHandler(),
icons_get: new IconsGetQueryHandler(),
};

View File

@@ -1,12 +1,4 @@
import {
app,
shell,
BrowserWindow,
ipcMain,
protocol,
net,
dialog,
} from 'electron';
import { app, shell, BrowserWindow, ipcMain, protocol, dialog } from 'electron';
import { join } from 'path';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { eventBus } from '@/lib/event-bus';
@@ -19,12 +11,14 @@ import { mediator } from '@/main/mediator';
import { FileMetadata } from '@/types/files';
import { MutationInput, MutationMap } from '@/operations/mutations';
import { QueryInput, QueryMap } from '@/operations/queries';
import { assetManager } from '@/main/asset-manager';
let subscriptionId: string | null = null;
const icon = join(__dirname, '../assets/icon.png');
const createWindow = async (): Promise<void> => {
await databaseManager.init();
assetManager.checkAssets();
socketManager.init();
synchronizer.init();
@@ -84,10 +78,7 @@ const createWindow = async (): Promise<void> => {
if (!protocol.isProtocolHandled('asset')) {
protocol.handle('asset', (request) => {
const url = request.url.replace('asset://', '');
const filePath = join(__dirname, 'assets', url);
const localFileUrl = `file://${filePath}`;
return net.fetch(localFileUrl);
return assetManager.handleAssetRequest(request);
});
}
};

View File

@@ -0,0 +1,14 @@
import { EmojiData } from '@/types/emojis';
export type EmojisGetQueryInput = {
type: 'emojis_get';
};
declare module '@/operations/queries' {
interface QueryMap {
emojis_get: {
input: EmojisGetQueryInput;
output: EmojiData;
};
}
}

View File

@@ -0,0 +1,14 @@
import { IconData } from '@/types/icons';
export type IconsGetQueryInput = {
type: 'icons_get';
};
declare module '@/operations/queries' {
interface QueryMap {
icons_get: {
input: IconsGetQueryInput;
output: IconData;
};
}
}

View File

@@ -22,8 +22,8 @@ export const AvatarPicker = ({ onPick }: AvatarPickerProps) => {
</TabsList>
<TabsContent value="emojis">
<EmojiPicker
onPick={(emoji) => {
onPick(emoji.id);
onPick={(emoji, skinTone) => {
onPick(emoji.skins[skinTone].id);
}}
/>
</TabsContent>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { EmojiPickerRowData } from '@/lib/emojis';
import { EmojiPickerRowData } from '@/types/emojis';
import { EmojiPickerItem } from '@/renderer/components/emojis/emoji-picker-item';
interface EmojiPickerBrowserRowProps {

View File

@@ -1,29 +1,34 @@
import { EmojiPickerRowData, categories, emojis } from '@/lib/emojis';
import { EmojiPickerRowData } from '@/types/emojis';
import React from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { EmojiPickerBrowserRow } from '@/renderer/components/emojis/emoji-picker-browser-row';
import { useEmojiPicker } from '@/renderer/contexts/emoji-picker';
const emojisPerRow = 10;
export const EmojiPickerBrowser = () => {
const { data } = useEmojiPicker();
const rowDataArray = React.useMemo<EmojiPickerRowData[]>(() => {
const rows: EmojiPickerRowData[] = [];
for (let i = 0; i < categories.length; i++) {
for (let i = 0; i < data.categories.length; i++) {
const category = data.categories[i];
// Add the category label
rows.push({
type: 'label',
category: categories[i],
category: category.name,
});
const numEmojis = emojis[i].length;
const numEmojis = category.emojis.length;
const numRowsInCategory = Math.ceil(numEmojis / emojisPerRow);
for (let rowIndex = 0; rowIndex < numRowsInCategory; rowIndex++) {
const start = rowIndex * emojisPerRow;
const end = start + emojisPerRow;
const emojisInRow = emojis[i].slice(start, end);
const emojisIds = category.emojis.slice(start, end);
const emojisInRow = emojisIds.map((id) => data.emojis[id]);
rows.push({
type: 'emoji',

View File

@@ -1,4 +1,4 @@
import { Emoji } from '@/lib/emojis';
import { Emoji } from '@/types/emojis';
import { EmojiElement } from '@/renderer/components/emojis/emoji-element';
import { useEmojiPicker } from '@/renderer/contexts/emoji-picker';
@@ -9,14 +9,9 @@ interface EmojiPickerItemProps {
export const EmojiPickerItem = ({ emoji }: EmojiPickerItemProps) => {
const { skinTone, onPick: onEmojiClick } = useEmojiPicker();
let id = emoji.id;
if (
emoji.skins &&
emoji.skins.length > 0 &&
skinTone !== 0 &&
skinTone < emoji.skins.length
) {
id = emoji.skins[skinTone];
let id = emoji.skins[0].id;
if (skinTone !== 0 && skinTone < emoji.skins.length) {
id = emoji.skins[skinTone].id;
}
return (

View File

@@ -1,14 +1,17 @@
import React from 'react';
import { searchEmojis } from '@/lib/emojis';
import { EmojiPickerItem } from '@/renderer/components/emojis/emoji-picker-item';
import { useEmojiPicker } from '@/renderer/contexts/emoji-picker';
interface EmojiPickerSearchProps {
query: string;
}
export const EmojiPickerSearch = ({ query }: EmojiPickerSearchProps) => {
const { data } = useEmojiPicker();
const filteredEmojis = React.useMemo(() => {
return searchEmojis(query);
return searchEmojis(query, data);
}, [query]);
return (

View File

@@ -1,20 +1,28 @@
import React from 'react';
import { EmojiSkinToneSelector } from '@/renderer/components/emojis/emoji-skin-tone-selector';
import { Emoji } from '@/lib/emojis';
import { Emoji } from '@/types/emojis';
import { EmojiPickerContext } from '@/renderer/contexts/emoji-picker';
import { EmojiPickerBrowser } from '@/renderer/components/emojis/emoji-picker-browser';
import { EmojiPickerSearch } from '@/renderer/components/emojis/emoji-picker-search';
import { useQuery } from '@/renderer/hooks/use-query';
interface EmojiPickerProps {
onPick: (emoji: Emoji) => void;
onPick: (emoji: Emoji, skinTone: number) => void;
}
export const EmojiPicker = ({ onPick }: EmojiPickerProps) => {
const [query, setQuery] = React.useState('');
const [skinTone, setSkinTone] = React.useState(0);
const { data, isPending } = useQuery({ type: 'emojis_get' });
if (isPending || !data) {
return null;
}
return (
<EmojiPickerContext.Provider value={{ skinTone, onPick }}>
<EmojiPickerContext.Provider
value={{ data, skinTone, onPick: (emoji) => onPick(emoji, skinTone) }}
>
<div className="flex flex-col gap-1 p-1">
<div className="flex flex-row items-center gap-1">
<input

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { IconPickerRowData } from '@/lib/icons';
import { IconPickerRowData } from '@/types/icons';
import { IconPickerItem } from '@/renderer/components/icons/icon-picker-item';
interface IconPickerBrowserRowProps {

View File

@@ -1,29 +1,33 @@
import React from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { IconPickerRowData, iconCategories, icons } from '@/lib/icons';
import { IconPickerRowData } from '@/types/icons';
import { IconPickerBrowserRow } from '@/renderer/components/icons/icon-picker-browser-row';
import { useIconPicker } from '@/renderer/contexts/icon-picker';
const iconsPerRow = 10;
export const IconPickerBrowser = () => {
const { data } = useIconPicker();
const rowDataArray = React.useMemo<IconPickerRowData[]>(() => {
const rows: IconPickerRowData[] = [];
for (let i = 0; i < iconCategories.length; i++) {
for (let i = 0; i < data.categories.length; i++) {
const category = data.categories[i];
// Add the category label
rows.push({
type: 'label',
category: iconCategories[i],
category: category.name,
});
const numIcons = icons[i].length;
const numIcons = category.icons.length;
const numRowsInCategory = Math.ceil(numIcons / iconsPerRow);
for (let rowIndex = 0; rowIndex < numRowsInCategory; rowIndex++) {
const start = rowIndex * iconsPerRow;
const end = start + iconsPerRow;
const iconsInRow = icons[i].slice(start, end);
const iconIds = category.icons.slice(start, end);
const iconsInRow = iconIds.map((id) => data.icons[id]);
rows.push({
type: 'icon',

View File

@@ -1,4 +1,4 @@
import { Icon } from '@/lib/icons';
import { Icon } from '@/types/icons';
import { useIconPicker } from '@/renderer/contexts/icon-picker';
import { IconElement } from '@/renderer/components/icons/icon-element';

View File

@@ -1,14 +1,17 @@
import React from 'react';
import { IconPickerItem } from '@/renderer/components/icons/icon-picker-item';
import { searchIcons } from '@/lib/icons';
import { useIconPicker } from '@/renderer/contexts/icon-picker';
interface IconPickerSearchProps {
query: string;
}
export const IconPickerSearch = ({ query }: IconPickerSearchProps) => {
const { data } = useIconPicker();
const filteredIcons = React.useMemo(() => {
return searchIcons(query);
return searchIcons(query, data);
}, [query]);
return (

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { Icon } from '@/lib/icons';
import { Icon } from '@/types/icons';
import { IconPickerContext } from '@/renderer/contexts/icon-picker';
import { IconPickerSearch } from '@/renderer/components/icons/icon-picker-search';
import { IconPickerBrowser } from '@/renderer/components/icons/icon-picker-browser';
import { useQuery } from '@/renderer/hooks/use-query';
interface IconPickerProps {
onPick: (icon: Icon) => void;
@@ -10,9 +11,14 @@ interface IconPickerProps {
export const IconPicker = ({ onPick }: IconPickerProps) => {
const [query, setQuery] = React.useState('');
const { data, isPending } = useQuery({ type: 'icons_get' });
if (isPending || !data) {
return null;
}
return (
<IconPickerContext.Provider value={{ onPick }}>
<IconPickerContext.Provider value={{ data, onPick }}>
<div className="flex flex-col gap-1 p-1">
<input
type="text"

View File

@@ -23,8 +23,8 @@ export const MessageReactionCreatePopover = ({
</PopoverTrigger>
<PopoverContent className="w-max p-0" align="end">
<EmojiPicker
onPick={(emoji) => {
onReactionClick(emoji.id);
onPick={(emoji, skinTone) => {
onReactionClick(emoji.skins[skinTone].id);
setOpen(false);
}}
/>

View File

@@ -1,13 +1,14 @@
import { Emoji } from '@/lib/emojis';
import { EmojiData, Emoji } from '@/types/emojis';
import { createContext, useContext } from 'react';
interface EmojiPickerContextProps {
data: EmojiData;
skinTone: number;
onPick: (emoji: Emoji) => void;
}
export const EmojiPickerContext = createContext<EmojiPickerContextProps>(
{} as EmojiPickerContextProps,
{} as EmojiPickerContextProps
);
export const useEmojiPicker = () => useContext(EmojiPickerContext);

View File

@@ -1,12 +1,13 @@
import { Icon } from '@/lib/icons';
import { IconData, Icon } from '@/types/icons';
import { createContext, useContext } from 'react';
interface IconPickerContextProps {
data: IconData;
onPick: (icon: Icon) => void;
}
export const IconPickerContext = createContext<IconPickerContextProps>(
{} as IconPickerContextProps,
{} as IconPickerContextProps
);
export const useIconPicker = () => useContext(IconPickerContext);

View File

@@ -0,0 +1,36 @@
export type EmojiData = {
categories: EmojiCategory[];
emojis: Record<string, Emoji>;
};
export type Emoji = {
id: string;
code: string;
name: string;
tags: string[];
emoticons: string[] | undefined;
skins: EmojiSkin[];
};
export type EmojiCategory = {
id: string;
name: string;
emojis: string[];
};
export type EmojiSkin = {
id: string;
unified: string;
};
export type EmojiPickerRowData = EmojiPickerLabelRow | EmojiPickerEmojiRow;
export type EmojiPickerLabelRow = {
type: 'label';
category: string;
};
export type EmojiPickerEmojiRow = {
type: 'emoji';
emojis: Emoji[];
};

View File

@@ -0,0 +1,29 @@
export type IconData = {
categories: IconCategory[];
icons: Record<string, Icon>;
};
export type Icon = {
id: string;
name: string;
code: string;
tags: string[];
};
export type IconCategory = {
id: string;
name: string;
icons: string[];
};
export type IconPickerRowData = IconPickerLabelRow | IconPickerEmojiRow;
export type IconPickerLabelRow = {
type: 'label';
category: string;
};
export type IconPickerEmojiRow = {
type: 'icon';
icons: Icon[];
};