Emoji favicons for the web
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: useeffect infinite render loop on custom emoji

+129 -106
+2 -1
deno.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 "lib": [ 4 - "deno.unstable", 4 + "deno.ns", 5 5 "dom", 6 6 "dom.iterable", 7 + "dom.asynciterable", 7 8 "esnext" 8 9 ], 9 10 "jsx": "react",
+6 -1
import_map.json
··· 4 4 "emoji": "https://deno.land/x/emoji@0.2.0/mod.ts", 5 5 "preact": "https://esm.sh/preact?dev", 6 6 "preact/hooks": "https://esm.sh/preact/hooks?dev", 7 + 8 + "deno-dom": "https://deno.land/x/deno_dom@v0.1.32-alpha/deno-dom-wasm.ts", 9 + "react-test-renderer": "https://esm.sh/react-test-renderer", 7 10 "std/asserts": "https://deno.land/std@0.138.0/testing/asserts.ts", 8 11 "std/bdd": "https://deno.land/std@0.138.0/testing/bdd.ts", 9 12 "std/mock": "https://deno.land/std@0.138.0/testing/mock.ts", 10 - "std/snapshot": "https://deno.land/std@0.138.0/testing/snapshot.ts" 13 + "std/snapshot": "https://deno.land/std@0.138.0/testing/snapshot.ts", 14 + "@testing-library/preact": "https://esm.sh/@testing-library/preact/pure?dev", 15 + "@testing-library/preact-hooks": "https://esm.sh/@testing-library/preact-hooks/pure?dev" 11 16 } 12 17 }
+1 -1
source/background.ts
··· 27 27 if (!settings) await syncSettings(); 28 28 29 29 const [favicon, shouldOverride] = selectFavicon(url, settings, autoselect); 30 - console.log(autoselect); 30 + 31 31 if (!favicon?.emojiId) return; 32 32 33 33 const emoji = await getEmoji(favicon.emojiId);
+26
source/components/__tests__/only.test.tsx
··· 1 + /* @jsx h */ 2 + import { h } from 'preact'; 3 + import { assertEquals } from 'std/asserts'; 4 + import { describe, it } from 'std/bdd'; 5 + import { render } from '@testing-library/preact'; 6 + 7 + import '../../utilities/test_dom.ts'; 8 + import Only from '../only.tsx'; 9 + 10 + it('should render if === true', () => { 11 + const { container } = render( 12 + <Only if={true}> 13 + <span>hello</span> 14 + </Only>, 15 + ); 16 + assertEquals(container.textContent, 'hello'); 17 + }); 18 + 19 + it('should not render if === false', () => { 20 + const { container } = render( 21 + <Only if={false}> 22 + <span>hello</span> 23 + </Only>, 24 + ); 25 + assertEquals(container.textContent, ''); 26 + });
+4 -2
source/components/emoji_selector/components/popup.tsx
··· 40 40 ) { 41 41 const [groupFilter, setGroupFilter] = useState(''); 42 42 const [filter, setFilter] = useFilterState(''); 43 - useEffect(() => { 43 + 44 + const allEmojis = useMemo(() => { 44 45 emojiGroups['Custom Emojis'].emojis = Object.keys(customEmojis) 45 46 .map((id) => customEmojis[id]); 47 + return { ...emojiGroups }; 46 48 }, [customEmojis]); 47 49 48 50 if (!isOpen) return null; ··· 90 92 91 93 <Groups 92 94 setIsCustom={setIsCustom} 93 - emojiGroups={emojiGroups} 95 + emojiGroups={allEmojis} 94 96 filter={filter} 95 97 groupFilter={groupFilter} 96 98 onSelected={onSelected}
+21 -13
source/components/emoji_selector/mod.tsx
··· 21 21 DEFAULT_EMOJI, 22 22 emoji, 23 23 getEmoji, 24 + getEmojis, 24 25 getEmojiStorageId, 25 26 saveEmoji, 26 27 } from '../../models/emoji.ts'; ··· 29 30 import Popup from './components/popup.tsx'; 30 31 import { OnSelected } from './types.ts'; 31 32 33 + const defaultState = {}; 32 34 export default function EmojiSelector({ onSelected, emojiId }: { 33 35 emojiId?: string; 34 36 onSelected: OnSelected; 35 37 }) { 36 38 const settings = useContext<BrowserStorage<Settings>>(SettingsContext); 37 - const { cache, saveToStorageBypassCache } = settings; 38 - const customEmojiIds = cache?.customEmojiIds || []; 39 - const storageIds = useMemo( 40 - () => customEmojiIds.map(getEmojiStorageId), 41 - [customEmojiIds], 42 - ); 43 - const customEmojis = useBrowserStorage<EmojiMap>(storageIds, {}); 39 + const { cache, setCache, saveToStorageBypassCache } = settings; 40 + const [customEmojis, setCustomEmojis] = useState({}); 41 + useEffect(() => { 42 + const currIds = Object.keys(customEmojis).sort(); 43 + const matches = cache.customEmojiIds.sort() 44 + .every((value, index) => currIds[index] === value); 45 + 46 + if (!matches) { 47 + (async function fetchEmojis() { 48 + setCustomEmojis(await getEmojis(cache.customEmojiIds)); 49 + })(); 50 + } 51 + }, [cache.customEmojiIds]); 44 52 45 53 const buttonRef = useRef<HTMLButtonElement>(); 46 54 const [isOpen, setIsOpen] = useState<boolean>(false); ··· 83 91 setIsOpen={setIsOpen} 84 92 isCustom={isCustom} 85 93 setIsCustom={setIsCustom} 86 - customEmojis={customEmojis.cache || {}} 94 + customEmojis={customEmojis} 87 95 submitCustomEmoji={useCallback(async (description, url) => { 88 - await saveToStorageBypassCache({ 89 - ...cache, 90 - customEmojiIds: customEmojiIds.concat(description), 91 - }); 92 96 await saveEmoji(createEmoji(description, url)); 93 - }, [settings, customEmojiIds])} 97 + const customEmojiIds = Array.from( 98 + new Set(cache.customEmojiIds.concat(description)), 99 + ); 100 + await saveToStorageBypassCache({ ...cache, customEmojiIds }); 101 + }, [settings, cache.customEmojiIds])} 94 102 onSelected={useCallback((emoji: Emoji) => { 95 103 if (!isOpen) return; 96 104 onSelected(emoji);
+1 -8
source/components/list_input.tsx
··· 1 1 /* @jsx h */ 2 - import type { BrowserStorage } from '../hooks/use_browser_storage.ts'; 3 2 import type { Emoji } from '../models/emoji.ts'; 4 3 import type { Favicon } from '../models/favicon.ts'; 5 - import type { Settings } from '../models/settings.ts'; 6 4 7 5 import { h } from 'preact'; 8 - import { useCallback, useContext } from 'preact/hooks'; 6 + import { useCallback } from 'preact/hooks'; 9 7 10 - import { createEmoji, emoji, getEmoji, saveEmoji } from '../models/emoji.ts'; 11 - import { SettingsContext } from '../models/settings.ts'; 12 8 import { isRegexString } from '../utilities/predicates.ts'; 13 9 import EmojiSelector from './emoji_selector/mod.tsx'; 14 10 import Only from './only.tsx'; ··· 45 41 value, 46 42 index, 47 43 }: ListInputProps) { 48 - const settings = useContext<BrowserStorage<Settings>>(SettingsContext); 49 - const { cache, saveToStorageBypassCache } = settings; 50 - 51 44 const onChangeMatcher = useCallback((e: Event) => { 52 45 const matcher = (e.target as HTMLInputElement).value; 53 46 const next = { emojiId: value?.emojiId || '', matcher };
+25 -26
source/hooks/use_browser_storage.ts
··· 8 8 9 9 export interface BrowserStorage<Type extends Storage> { 10 10 error?: string; 11 - cache?: Type; 11 + cache: Type; 12 12 loading: boolean; 13 13 setCache: (nextCache: Partial<Type>, saveImmediately?: boolean) => void; 14 14 saveCacheToStorage: () => Promise<void>; ··· 24 24 * - `setCache` onChange data to the locally cached copy without interacting with BrowserStorage 25 25 * - `saveCacheToStorage` saves that local data into browserStorage on a separate interaction 26 26 */ 27 + type Keys = string | readonly string[]; 27 28 export default function useBrowserStorage<Type extends Storage>( 28 - keys: string | readonly string[], 29 + keys: Keys, 29 30 defaultState: Type, 30 31 ) { 31 32 const [error, setError] = useState<string>(); 32 33 const [cache, setCache] = useState<Type>(defaultState); 33 34 const [loading, setLoading] = useState<boolean>(true); 34 - const keyArray = Array.isArray(keys) ? keys : [keys]; 35 + 36 + const updateState = useCallback(async function (changes: void | { [key: string]: any }) { 37 + const keyArray = Array.isArray(keys) ? keys : [keys]; 38 + const noChange = changes && !keyArray.some((key) => Boolean(changes[key])); 39 + if (!keyArray.length || noChange) return; 35 40 36 - useEffect(function setupStorageFetcher() { 41 + const nextState: Type = Array.isArray(keys) 42 + ? await storage.sync.get(keyArray) as Type 43 + : (await storage.sync.get(keyArray))[keys as string] as Type; 44 + if (runtime?.lastError?.message) setError(runtime?.lastError?.message); 45 + if (nextState) setCache(nextState); 46 + setLoading(false); 47 + }, [keys]); 48 + 49 + useEffect(function () { 37 50 updateState(); 38 51 if (!storage.onChanged.hasListener(updateState)) { 39 52 storage.onChanged.addListener(updateState); 40 53 } 41 - 42 - async function updateState() { 43 - if (Array.isArray(keys) && !keys.length) return; 44 - const nextState = Array.isArray(keys) 45 - ? await storage.sync.get(keyArray) as Type 46 - : (await storage.sync.get(keyArray))[keys as string] as Type; 47 - if (runtime?.lastError?.message) setError(runtime?.lastError?.message); 48 - if (nextState) { 49 - setCache(nextState); 50 - } 51 - setLoading(false); 52 - } 53 - 54 54 return () => { 55 55 storage.onChanged.removeListener(updateState); 56 56 }; 57 - }, [keys]); 57 + }, []); 58 58 59 59 const saveToStorage = useCallback( 60 60 async (next: Partial<Type> | void): Promise<void> => { 61 61 if (!next) return; 62 - if (Array.isArray(keys)) { 63 - await storage.sync.set(next); 64 - } else { 65 - await storage.sync.set({ [keys as string]: next }); 66 - } 62 + 63 + await storage.sync.set( 64 + Array.isArray(keys) ? next : { [keys as string]: next }, 65 + ); 66 + 67 67 if (runtime?.lastError?.message) setError(runtime?.lastError?.message); 68 68 }, 69 - [], 69 + [keys], 70 70 ); 71 71 72 72 const result: BrowserStorage<Type> = { ··· 91 91 saveToStorageBypassCache: useCallback( 92 92 async (next: Partial<Type>): Promise<void> => { 93 93 await saveToStorage(next); 94 - const nextStorage = { ...cache, ...next }; 95 - setCache(nextStorage); 94 + setCache({ ...cache, ...next }); 96 95 }, 97 96 [cache, setCache], 98 97 ),
-14
source/hooks/use_input_state.ts
··· 1 - import { useState } from 'preact/hooks'; 2 - 3 - export default () => { 4 - const [value, setValue] = useState(''); 5 - 6 - return { 7 - value, 8 - onChange: (event: Event) => { 9 - const target = (event.target as HTMLInputElement); 10 - setValue(target.value); 11 - }, 12 - reset: () => setValue(''), 13 - }; 14 - };
+3 -3
source/models/__tests__/emoji.test.ts
··· 78 78 ]); 79 79 }); 80 80 const emojis = await getEmojis(['nother', 'poro', 'grinning squinting face']); 81 - assertEquals(emojis[0].emoji, '😆'); 82 - assertEquals(emojis[1].imageURL, 'poro://poro-url'); 83 - assertEquals(emojis[2].imageURL, 'nother://nother-custom-emoji'); 81 + assertEquals(emojis['grinning squinting face'].emoji, '😆'); 82 + assertEquals(emojis['poro'].imageURL, 'poro://poro-url'); 83 + assertEquals(emojis['nother'].imageURL, 'nother://nother-custom-emoji'); 84 84 85 85 assertSpyCalls(storageStub, 1); 86 86
+12 -5
source/models/emoji.ts
··· 37 37 keys(emojiGroups).map((name) => emojiGroups[name]), 38 38 ); 39 39 40 + const DEFAULT_CUSTOM_EMOJI_IDS: string[] = []; 40 41 export const CustomEmojiContext = createContext<BrowserStorage<string[]>>({ 41 42 loading: true, 43 + cache: DEFAULT_CUSTOM_EMOJI_IDS, 42 44 setCache: () => {}, 43 45 saveCacheToStorage: async () => {}, 44 46 saveToStorageBypassCache: async () => {}, ··· 73 75 return resp[storageID] as Emoji; 74 76 } 75 77 76 - export async function getEmojis(descs: string[]): Promise<Emoji[]> { 77 - const localEmojis: Emoji[] = []; 78 + export async function getEmojis(descs: string[]): Promise<EmojiMap> { 79 + const localEmojis: EmojiMap = {}; 78 80 const customDescIds: string[] = []; 79 81 descs.forEach((desc: string) => { 80 - if (byDescription[desc]) localEmojis.push(byDescription[desc]); 82 + if (byDescription[desc]) localEmojis[desc] = byDescription[desc]; 81 83 else customDescIds.push(getEmojiStorageId(desc)); 82 84 }); 83 - const customDescs = await storage.sync.get(customDescIds) as Emoji[]; 84 - return localEmojis.concat(customDescs); 85 + const customEmojis = await storage.sync.get(customDescIds) as EmojiMap; 86 + const customEmojiWithProperName: EmojiMap = {}; 87 + Object.keys(customEmojis).forEach((storageId) => { 88 + const emoji = customEmojis[storageId]; 89 + customEmojiWithProperName[emoji.description] = emoji; 90 + }); 91 + return { ...localEmojis, ...customEmojiWithProperName }; 85 92 } 86 93 87 94 export async function saveEmoji(emojiToSave: Emoji): Promise<void> {
+1
source/models/settings.ts
··· 45 45 46 46 export const SettingsContext = createContext<BrowserStorage<Settings>>({ 47 47 loading: true, 48 + cache: DEFAULT_SETTINGS, 48 49 setCache: () => {}, 49 50 saveCacheToStorage: async () => {}, 50 51 saveToStorageBypassCache: async () => {},
+3 -4
source/pages/about_page.tsx
··· 9 9 import { SettingsContext } from '../models/settings.ts'; 10 10 11 11 export default function () { 12 - const storage = useContext<BrowserStorage<Settings>>(SettingsContext); 13 - const { cache = { version: 0 } } = storage || {}; 12 + const { cache } = useContext<BrowserStorage<Settings>>(SettingsContext); 14 13 15 14 return ( 16 15 <Fragment> 17 16 <h1>About Favioli</h1> 18 - <Only if={Boolean(cache?.version)}> 19 - <p>Version {cache?.version}</p> 17 + <Only if={Boolean(cache.version)}> 18 + <p>Version {cache.version}</p> 20 19 </Only> 21 20 22 21 <h2>What is Favioli?</h2>
+3 -6
source/pages/favicons_page.tsx
··· 8 8 import List from '../components/list.tsx'; 9 9 import Only from '../components/only.tsx'; 10 10 import useListState from '../hooks/use_list_state.ts'; 11 - import { DEFAULT_SETTINGS, SettingsContext } from '../models/settings.ts'; 11 + import { SettingsContext } from '../models/settings.ts'; 12 12 import { t } from '../utilities/i18n.ts'; 13 13 14 14 export interface FaviconsPageProps { ··· 19 19 20 20 export default function FaviconsPage({ save }: FaviconsPageProps) { 21 21 const storage = useContext<BrowserStorage<Settings>>(SettingsContext); 22 - const { siteList, ignoreList, features } = storage?.cache || DEFAULT_SETTINGS; 22 + const { siteList, ignoreList, features } = storage.cache; 23 23 const { enableSiteIgnore } = features; 24 24 const siteListState = useListState(siteList); 25 25 const ignoreListState = useListState(ignoreList); ··· 32 32 }); 33 33 } 34 34 }, [siteListState.contents, ignoreListState.contents]); 35 - if (!storage) return null; 36 - 37 - const hasIgnores = ignoreListState.contents?.length; 38 35 39 36 return ( 40 37 <form onSubmit={save}> 41 38 <h1>{t('faviconListTitle')}</h1> 42 39 <List type='FAVICON' state={siteListState} /> 43 40 44 - <Only if={Boolean(enableSiteIgnore || hasIgnores)}> 41 + <Only if={Boolean(enableSiteIgnore || ignoreListState.contents?.length)}> 45 42 <Fragment> 46 43 <h1> 47 44 {t('ignoreListTitle')}
+7 -14
source/pages/settings_page.tsx
··· 19 19 20 20 const SettingsPage = ({ save }: SettingsProps) => { 21 21 const storage = useContext<BrowserStorage<Settings>>(SettingsContext); 22 - const { cache = DEFAULT_SETTINGS, setCache } = storage || {}; 23 - const { autoselectorVersion } = cache; 22 + const { cache, setCache } = storage; 23 + const { autoselectorVersion, features } = cache; 24 24 const { 25 25 enableAutoselectorIncludeCountryFlags, 26 26 enableFaviconAutofill, 27 27 enableSiteIgnore, 28 28 enableOverrideAll, 29 - } = cache.features; 29 + } = features; 30 30 31 - const setFeature = useCallback((feature: Target) => { 32 - if (storage) { 33 - storage.setCache({ 34 - features: { 35 - ...cache.features, 36 - ...feature, 37 - }, 38 - }); 39 - } 40 - }, [cache.features]); 31 + const setFeature = useCallback((nextFeature: Target) => { 32 + if (storage) setCache({ features: { ...features, ...nextFeature } }); 33 + }, [features]); 41 34 42 35 const setAutoselectorVersion = useCallback((e: Event) => { 43 36 const autoselectorVersion = (e.target as HTMLInputElement).value; 44 - if (storage) storage.setCache({ autoselectorVersion }); 37 + if (storage) setCache({ autoselectorVersion }); 45 38 }, [storage]); 46 39 47 40 return (
+6 -8
source/popup.tsx
··· 7 7 8 8 import useActiveTab from './hooks/use_active_tab.ts'; 9 9 import useBrowserStorage from './hooks/use_browser_storage.ts'; 10 - import useSelectedFavicon from './hooks/use_selected_favicon.ts'; 10 + import useFavioliIcon from './hooks/use_selected_favicon.ts'; 11 11 import useStatus from './hooks/use_status.ts'; 12 12 import { DEFAULT_SETTINGS, SETTINGS_KEY } from './models/settings.ts'; 13 13 14 14 const App = () => { 15 15 const settings = useBrowserStorage<Settings>(SETTINGS_KEY, DEFAULT_SETTINGS); 16 + const { setCache, cache, error } = settings; 16 17 const { favIconUrl = '', url = '' } = useActiveTab() || {}; 17 - const { selectedFavicon, selectedFaviconURL } = useSelectedFavicon( 18 - url, 19 - settings.cache, 20 - ); 18 + const { selectedFavicon, selectedFaviconURL } = useFavioliIcon(url, cache); 21 19 22 20 const { status, save } = useStatus( 23 - settings.error || '', 21 + error || '', 24 22 useCallback(function updateSiteList(shouldAddToSiteList: boolean) { 25 23 if (!url) return; 26 24 const { origin } = new URL(url); 27 - const siteList = (settings.cache?.siteList || []) 25 + const siteList = (cache.siteList || []) 28 26 .filter(({ matcher }) => matcher !== origin); 29 27 30 28 if (shouldAddToSiteList && selectedFavicon) { 31 29 siteList.push({ ...selectedFavicon, matcher: origin }); 32 30 } 33 31 34 - settings.setCache({ siteList }, true); 32 + setCache({ siteList }, true); 35 33 }, [url, settings]), 36 34 ); 37 35
+8
source/utilities/test_dom.ts
··· 1 + import { DOMParser } from 'deno-dom'; 2 + 3 + globalThis.document = new DOMParser() 4 + .parseFromString( 5 + `<!DOCTYPE html><html lang="en"></html>`, 6 + 'text/html', 7 + // deno-lint-ignore no-explicit-any 8 + ) as any;