Emoji favicons for the web
0
fork

Configure Feed

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

feat: move data utilities into models

- primarily, this lets us segregate our models from custom emojis
- custom emojis need to be stored at storage.sync root-level for space

+1072 -817
+8 -2
deno.json
··· 23 23 } 24 24 }, 25 25 "tasks": { 26 - "test": "deno fmt && deno task check && deno test source && deno lint", 27 - "check": "deno check source/background.ts && deno check source/content_script.ts && deno check source/options.tsx && deno check source/popup.tsx" 26 + "test": "deno test -A source", 27 + "test:all": "deno fmt && deno task check:all && deno task test && deno lint", 28 + "check:all": "deno task check:background && deno task check:content_script && deno task check:options && deno task check:popup", 29 + "check:background": "deno check source/background.ts", 30 + "check:content_script": "deno check source/content_script.ts", 31 + "check:options": "deno check source/options.tsx", 32 + "check:popup": "deno check source/popup.tsx", 33 + "test:update": "deno test -A -- --update source" 28 34 } 29 35 }
+6 -6
import_map.json
··· 1 1 { 2 2 "imports": { 3 - "asserts": "https://deno.land/std@0.138.0/testing/asserts.ts", 4 - "bdd": "https://deno.land/std@0.138.0/testing/bdd.ts", 5 - "browser": "https://deno.land/x/bext/mod.ts", 3 + "browser": "https://deno.land/x/bext@v0.1.2/mod.ts", 6 4 "emoji": "https://deno.land/x/emoji@0.2.0/mod.ts", 7 - "fs": "https://deno.land/std@0.138.0/fs/mod.ts", 8 - "mock": "https://deno.land/std@0.138.0/testing/mock.ts", 9 5 "preact": "https://esm.sh/preact?dev", 10 - "preact/hooks": "https://esm.sh/preact/hooks?dev" 6 + "preact/hooks": "https://esm.sh/preact/hooks?dev", 7 + "std/asserts": "https://deno.land/std@0.138.0/testing/asserts.ts", 8 + "std/bdd": "https://deno.land/std@0.138.0/testing/bdd.ts", 9 + "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" 11 11 } 12 12 }
+48 -73
source/background.ts
··· 1 - /** 2 - * Serves as bridge point between popup and content_script. 3 - */ 4 - 5 1 import type { Tab, TabChangeInfo } from 'browser'; 6 - import type { Settings, SettingsV1 } from './utilities/settings.ts'; 2 + import type { Settings } from './models/settings.ts'; 3 + import type { SettingsV1 } from './models/storage_legacy.ts'; 7 4 8 5 import browserAPI from 'browser'; 6 + 7 + import { getEmoji } from './models/emoji.ts'; 8 + import { DEFAULT_SETTINGS, SETTINGS_KEY } from './models/settings.ts'; 9 9 import { 10 - DEFAULT_SETTINGS, 11 - isV1Settings, 10 + isSettingsV1, 12 11 LEGACY_STORAGE_KEYS, 13 - migrateFromV1, 14 - STORAGE_KEYS, 15 - } from './utilities/settings.ts'; 16 - import { FaviconData, getEmojiFromFavicon } from './utilities/favicon_data.ts'; 17 - import Autoselector from './utilities/autoselector.ts'; 12 + migrateStorageFromV1, 13 + } from './models/storage_legacy.ts'; 14 + import Autoselector from './utilities/favicon_autoselector.ts'; 15 + import selectFavicon from './utilities/favicon_selector.ts'; 18 16 19 - let settings: Settings = DEFAULT_SETTINGS; 20 - let autoselector: Autoselector | void; 17 + let settings: Settings; 18 + let autoselect: Autoselector | undefined; 21 19 22 20 syncSettings(); 23 21 browserAPI.storage.onChanged.addListener(syncSettings); 24 22 25 23 // Send tab a favicon 26 24 browserAPI.tabs.onUpdated.addListener( 27 - async (tabId: number, _: TabChangeInfo, tab: Tab) => { 25 + async (tabId: number, _: TabChangeInfo, { url }: Tab) => { 26 + if (!tabId || !url) return; 27 + if (!settings) await syncSettings(); 28 + 29 + const [favicon, shouldOverride] = selectFavicon(url, settings, autoselect); 30 + console.log(autoselect); 31 + if (!favicon?.emojiId) return; 32 + 33 + const emoji = await getEmoji(favicon.emojiId); 34 + if (!emoji) return; 35 + 28 36 try { 29 - const [favicon, shouldOverride] = selectFavicon(tab.url, settings) || []; 30 - const overrideText = shouldOverride ? 'Override' : 'Append'; 31 - console.info(`${overrideText} favicon, tab ${tabId}:`, favicon); 32 - if (favicon && tabId) { 33 - const customEmojis = settings?.emojiDatabase?.customEmojis || []; 34 - const emoji = getEmojiFromFavicon(favicon, { customEmojis }); 35 - if (emoji) { 36 - await browserAPI.tabs.sendMessage(tabId, { emoji, shouldOverride }); 37 - } 38 - } 37 + await browserAPI.tabs.sendMessage(tabId, { emoji, shouldOverride }); 39 38 } catch (e) { 40 39 console.log(e); 41 40 } 42 41 }, 43 42 ); 44 43 44 + type StoredSettings = { 45 + settings: Settings; 46 + }; 47 + 45 48 async function syncSettings() { 46 - autoselector = undefined; 47 - const storedSettings: Settings | SettingsV1 = await browserAPI.storage.sync 48 - .get(STORAGE_KEYS) as Settings | SettingsV1; 49 + const storage: StoredSettings | SettingsV1 = await browserAPI.storage.sync 50 + .get([SETTINGS_KEY, ...LEGACY_STORAGE_KEYS]) as StoredSettings | SettingsV1; 49 51 50 - if ( 51 - !storedSettings || 52 - Object.keys(storedSettings).length !== Object.keys(DEFAULT_SETTINGS).length 53 - ) { 54 - return; 55 - } else if (isV1Settings(storedSettings)) { 56 - console.info('Version < 2 versions found', storedSettings); 57 - settings = migrateFromV1(storedSettings); 52 + if (isSettingsV1(storage)) { 53 + console.info('Version < 2 versions found', storage); 58 54 console.info('Migrating to', settings); 55 + settings = migrateStorageFromV1(storage); 59 56 await browserAPI.storage.sync.remove(LEGACY_STORAGE_KEYS); 60 57 await browserAPI.storage.sync.set(settings); 58 + } else if ( 59 + !storage?.settings || 60 + Object.keys(storage.settings).length !== 61 + Object.keys(DEFAULT_SETTINGS).length 62 + ) { 63 + settings = DEFAULT_SETTINGS; 61 64 } else { 62 - settings = storedSettings; 65 + settings = storage.settings; 63 66 } 64 - } 65 67 66 - function listItemMatchesUrl(favicon: FaviconData, url: string) { 67 - if (!favicon || !favicon.matcher) return false; 68 - return new RegExp(favicon.matcher || '^$').test(url); 69 - } 70 - 71 - /** 72 - * Override Priority 73 - * 74 - * 1. Ignore list (always ignore if ignore list enabled) 75 - * 2. Site list (if matched in site list, user manually added) 76 - * 3. Autofill (if autofill is enabled) 77 - * 4. Ignore (autofill NOT enabled, user hasn't added to sitelist) 78 - */ 79 - function selectFavicon( 80 - url: string | void, 81 - settings: Settings, 82 - ): [FaviconData | void, boolean] { 83 - const { autoselectorVersion, ignoreList, siteList, features } = settings; 68 + const { features, autoselectorVersion } = settings; 84 69 const includeFlags = features.enableAutoselectorIncludeCountryFlags; 85 70 86 - if (!url) return [undefined, false]; // Should never happen... 87 - 88 - const shouldIgnore = features.enableSiteIgnore && 89 - ignoreList.some((item) => listItemMatchesUrl(item, url)); 90 - if (shouldIgnore) return [undefined, false]; 91 - 92 - const favicons = siteList.filter((item) => listItemMatchesUrl(item, url)); 93 - const isInSiteList = Boolean(favicons.length); 94 - if (isInSiteList) { 95 - return [favicons[0], true]; 96 - } else if (features.enableFaviconAutofill) { 97 - if (!autoselector) { 98 - autoselector = new Autoselector(autoselectorVersion, { includeFlags }); 99 - } 100 - return [autoselector.selectFavicon(url), !!features.enableOverrideAll]; 71 + if (!features.enableFaviconAutofill) { 72 + autoselect = undefined; 73 + } else if ( 74 + !autoselect || autoselectorVersion !== autoselect.version || 75 + includeFlags !== autoselect.includeFlags 76 + ) { 77 + autoselect = new Autoselector(autoselectorVersion, { includeFlags }); 101 78 } 102 - 103 - return [undefined, false]; 104 79 }
+1 -1
source/components/emoji_selector/components/custom_upload.tsx
··· 5 5 import * as emoji from 'emoji'; 6 6 7 7 import Only from '../../only.tsx'; 8 - import { createFaviconURLFromImage } from '../../../utilities/create_favicon_url.ts'; 8 + import { createFaviconURLFromImage } from '../../../utilities/image_helpers.ts'; 9 9 import type { SetSwitch } from '../types.ts'; 10 10 11 11 export default function CustomUpload(
+3 -4
source/components/emoji_selector/components/emoji_button.tsx
··· 1 1 /* @jsx h */ 2 + import type { Emoji } from '../../../models/emoji.ts'; 3 + 2 4 import { h } from 'preact'; 3 - import { Emoji } from '../../../utilities/emoji.ts'; 4 5 5 6 export default function EmojiButton({ 6 7 emoji, ··· 10 11 // deno-lint-ignore no-explicit-any 11 12 [name: string]: any; 12 13 }) { 13 - const isCustom = Boolean(emoji?.imageURL?.length || emoji?.videoURL?.length); 14 - 15 - if (isCustom) { 14 + if (emoji?.imageURL) { 16 15 return ( 17 16 <button type='button' {...props}> 18 17 <img src={emoji?.imageURL} />
+1 -5
source/components/emoji_selector/components/groups.tsx
··· 1 1 /* @jsx h */ 2 2 import type { OnSelected, SetSwitch } from '../types.ts'; 3 - import type { Emoji } from '../../../utilities/emoji.ts'; 4 - import type { 5 - EmojiGroup, 6 - EmojiGroups, 7 - } from '../../../utilities/favicon_data.ts'; 3 + import type { Emoji, EmojiGroup, EmojiGroups } from '../../../models/emoji.ts'; 8 4 9 5 import { Fragment, h } from 'preact'; 10 6 import { useCallback, useMemo } from 'preact/hooks';
+6 -12
source/components/emoji_selector/components/popup.tsx
··· 1 1 /* @jsx h */ 2 - import type { Emoji } from '../../../utilities/emoji.ts'; 3 - import type { EmojiGroup } from '../../../utilities/favicon_data.ts'; 2 + import type { Emoji, EmojiGroup, EmojiMap } from '../../../models/emoji.ts'; 3 + import type { OnSelected, SetSwitch } from '../types.ts'; 4 4 5 5 import { Fragment, h } from 'preact'; 6 6 import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; 7 - import * as emoji from 'emoji'; 8 7 9 - import { OnSelected, SetSwitch } from '../types.ts'; 10 - import { 11 - emojiGroups, 12 - emojiGroupsArray, 13 - } from '../../../utilities/favicon_data.ts'; 8 + import { emoji, emojiGroups, emojiGroupsArray } from '../../../models/emoji.ts'; 14 9 import Groups from './groups.tsx'; 15 10 import CustomUpload from './custom_upload.tsx'; 16 11 ··· 35 30 popupRef: any; 36 31 setIsCustom: SetSwitch; 37 32 setIsOpen: SetSwitch; 38 - customEmojis: { [name: string]: Emoji }; 33 + customEmojis: EmojiMap; 39 34 submitCustomEmoji: ( 40 35 name: string, 41 36 image: string, ··· 46 41 const [groupFilter, setGroupFilter] = useState(''); 47 42 const [filter, setFilter] = useFilterState(''); 48 43 useEffect(() => { 49 - emojiGroups['Custom Emojis'].emojis = Object.keys(customEmojis).map((id) => 50 - customEmojis[id] 51 - ); 44 + emojiGroups['Custom Emojis'].emojis = Object.keys(customEmojis) 45 + .map((id) => customEmojis[id]); 52 46 }, [customEmojis]); 53 47 54 48 if (!isOpen) return null;
+51 -32
source/components/emoji_selector/mod.tsx
··· 1 1 /* @jsx h */ 2 + import type { Emoji, EmojiMap } from '../../models/emoji.ts'; 3 + import type { Settings } from '../../models/settings.ts'; 4 + import type { BrowserStorage } from '../../hooks/use_browser_storage.ts'; 2 5 3 6 import { Fragment, h } from 'preact'; 4 - import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; 5 - import * as emoji from 'emoji'; 7 + import { 8 + useCallback, 9 + useContext, 10 + useEffect, 11 + useMemo, 12 + useRef, 13 + useState, 14 + } from 'preact/hooks'; 6 15 16 + import useBrowserStorage from '../../hooks/use_browser_storage.ts'; 7 17 import useFocusObserver from '../../hooks/use_focus_observer.ts'; 8 - import { DEFAULT_EMOJI } from '../../utilities/favicon_data.ts'; 9 - import { OnSelected } from './types.ts'; 10 - import { createCustomEmoji, Emoji } from '../../utilities/emoji.ts'; 11 - 18 + import { 19 + areEqualEmojis, 20 + createEmoji, 21 + DEFAULT_EMOJI, 22 + emoji, 23 + getEmoji, 24 + getEmojiStorageId, 25 + saveEmoji, 26 + } from '../../models/emoji.ts'; 27 + import { SettingsContext } from '../../models/settings.ts'; 12 28 import EmojiButton from './components/emoji_button.tsx'; 13 29 import Popup from './components/popup.tsx'; 30 + import { OnSelected } from './types.ts'; 14 31 15 - function areSameEmoji(emoji1: Emoji, emoji2: Emoji): boolean { 16 - if (!emoji1 || !emoji2) return false; 17 - if (emoji1.imageURL && (emoji1.imageURL === emoji2.imageURL)) return true; 18 - if (emoji1.videoURL && (emoji1.videoURL === emoji2.videoURL)) return true; 19 - if (emoji1.emoji && (emoji1.emoji === emoji2.emoji)) return true; 20 - return false; 21 - } 32 + export default function EmojiSelector({ onSelected, emojiId }: { 33 + emojiId?: string; 34 + onSelected: OnSelected; 35 + }) { 36 + 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, {}); 22 44 23 - export default function EmojiSelector( 24 - { onAddedCustomEmoji, onSelected, value, customEmojis, frequentlyUsed }: { 25 - value?: Emoji; 26 - onSelected: OnSelected; 27 - onAddedCustomEmoji: (description: string, url: string) => Promise<void>; 28 - customEmojis: { [name: string]: Emoji }; 29 - frequentlyUsed: Emoji[]; 30 - }, 31 - ) { 32 45 const buttonRef = useRef<HTMLButtonElement>(); 33 46 const [isOpen, setIsOpen] = useState<boolean>(false); 34 47 const [isCustom, setIsCustom] = useState<boolean>(false); ··· 36 49 37 50 // For reverting to default state when list_items are deleted 38 51 useEffect(function updateStateWithNewValue() { 39 - try { 40 - const passedEmoji = value; 41 - if (!passedEmoji) return setSelectedEmoji(DEFAULT_EMOJI); 42 - if (!areSameEmoji(passedEmoji, selectedEmoji)) { 43 - setSelectedEmoji(passedEmoji); 52 + emojiIdToEmoji(); 53 + async function emojiIdToEmoji() { 54 + if (!emojiId) return setSelectedEmoji(DEFAULT_EMOJI); 55 + const nextEmoji = await getEmoji(emojiId); 56 + if (!nextEmoji) return setSelectedEmoji(DEFAULT_EMOJI); 57 + if (!areEqualEmojis(nextEmoji, selectedEmoji)) { 58 + setSelectedEmoji(nextEmoji); 44 59 } 45 - } catch { 46 - setSelectedEmoji(DEFAULT_EMOJI); 47 60 } 48 - }, [value]); 61 + }, [emojiId]); 49 62 50 63 return ( 51 64 <Fragment> ··· 70 83 setIsOpen={setIsOpen} 71 84 isCustom={isCustom} 72 85 setIsCustom={setIsCustom} 73 - customEmojis={customEmojis} 74 - submitCustomEmoji={onAddedCustomEmoji} 86 + customEmojis={customEmojis.cache || {}} 87 + submitCustomEmoji={useCallback(async (description, url) => { 88 + await saveToStorageBypassCache({ 89 + ...cache, 90 + customEmojiIds: customEmojiIds.concat(description), 91 + }); 92 + await saveEmoji(createEmoji(description, url)); 93 + }, [settings, customEmojiIds])} 75 94 onSelected={useCallback((emoji: Emoji) => { 76 95 if (!isOpen) return; 77 96 onSelected(emoji);
+1 -2
source/components/emoji_selector/types.ts
··· 1 - import { Emoji } from '../../utilities/emoji.ts'; 1 + import { Emoji } from '../../models/emoji.ts'; 2 2 3 3 export type SetSwitch = (state: boolean) => void; 4 - 5 4 export type OnSelected = (emoji: Emoji) => void;
+3 -3
source/components/list.tsx
··· 1 1 /* @jsx h */ 2 2 3 3 import type { ListState } from '../hooks/use_list_state.ts'; 4 + import type { Favicon } from '../models/favicon.ts'; 4 5 5 6 import { h } from 'preact'; 6 7 import { useRef } from 'preact/hooks'; 7 8 8 - import { FaviconData } from '../utilities/favicon_data.ts'; 9 9 import ListInput from './list_input.tsx'; 10 10 11 11 export interface ListProps<Type> { 12 12 type: 'FAVICON' | 'IGNORE'; 13 - state: ListState<FaviconData>; 13 + state: ListState<Favicon>; 14 14 } 15 15 16 16 export default function List<Type,>({ type, state }: ListProps<Type>) { 17 17 const listRef = useRef<HTMLInputElement>(null); 18 18 const listInputs = state.contents.map( 19 - (listItem: FaviconData, index: number) => { 19 + (listItem: Favicon, index: number) => { 20 20 return ( 21 21 <ListInput 22 22 type={type}
+19 -30
source/components/list_input.tsx
··· 1 1 /* @jsx h */ 2 2 import type { BrowserStorage } from '../hooks/use_browser_storage.ts'; 3 - import type { Settings } from '../utilities/settings.ts'; 3 + import type { Emoji } from '../models/emoji.ts'; 4 + import type { Favicon } from '../models/favicon.ts'; 5 + import type { Settings } from '../models/settings.ts'; 4 6 5 - import * as emoji from 'emoji'; 6 7 import { h } from 'preact'; 7 8 import { useCallback, useContext } from 'preact/hooks'; 8 9 9 - import { StorageContext } from '../hooks/use_browser_storage.ts'; 10 - import { createCustomEmoji, Emoji } from '../utilities/emoji.ts'; 11 - import { FaviconData, getEmojiFromFavicon } from '../utilities/favicon_data.ts'; 10 + import { createEmoji, emoji, getEmoji, saveEmoji } from '../models/emoji.ts'; 11 + import { SettingsContext } from '../models/settings.ts'; 12 12 import { isRegexString } from '../utilities/predicates.ts'; 13 13 import EmojiSelector from './emoji_selector/mod.tsx'; 14 14 import Only from './only.tsx'; 15 15 16 + const IGNORE = 'IGNORE'; 17 + const FAVICON = 'FAVICON'; 18 + type ListType = typeof IGNORE | typeof FAVICON; 19 + 16 20 type Target = { 17 21 matcher?: string; 18 22 index: number; ··· 22 26 interface ListInputProps { 23 27 autoFocus?: boolean; 24 28 canDelete?: boolean; 25 - type: 'IGNORE' | 'FAVICON'; 29 + type: ListType; 26 30 index: number; 27 - value?: FaviconData; 31 + value?: Favicon; 28 32 placeholder?: string; 29 33 deleteItem?: (index: number) => void; 30 - addItem?: (listitem: FaviconData) => void; 31 - updateItem?: (index: number, listItem: FaviconData) => void; 34 + addItem?: (listitem: Favicon) => void; 35 + updateItem?: (index: number, listItem: Favicon) => void; 32 36 } 33 37 34 38 export default function ListInput({ ··· 41 45 value, 42 46 index, 43 47 }: ListInputProps) { 44 - const storage = useContext<BrowserStorage<Settings>>(StorageContext); 45 - const { cache, saveToStorage } = storage; 46 - const { customEmojis = {}, frequentlyUsed = [] } = cache?.emojiDatabase || {}; 48 + const settings = useContext<BrowserStorage<Settings>>(SettingsContext); 49 + const { cache, saveToStorageBypassCache } = settings; 47 50 48 51 const onChangeMatcher = useCallback((e: Event) => { 49 52 const matcher = (e.target as HTMLInputElement).value; 50 - const next = { id: value?.id || '', matcher }; 53 + const next = { emojiId: value?.emojiId || '', matcher }; 51 54 addItem ? addItem(next) : updateItem(index, next); 52 55 }, [index, value, updateItem, addItem]); 53 56 54 57 const onChangeEmoji = useCallback((selectedEmoji: Emoji) => { 55 58 const next = { 56 - id: selectedEmoji.description, 59 + emojiId: selectedEmoji.description, 57 60 matcher: value?.matcher || '', 58 61 }; 59 62 addItem ? addItem(next) : updateItem(index, next); ··· 77 80 value={value?.matcher || ''} 78 81 /> 79 82 80 - <Only if={type === 'FAVICON'}> 83 + <Only if={type === FAVICON}> 81 84 <EmojiSelector 82 - value={getEmojiFromFavicon(value, { customEmojis })} 85 + emojiId={value?.emojiId} 83 86 onSelected={onChangeEmoji} 84 - customEmojis={customEmojis} 85 - frequentlyUsed={frequentlyUsed} 86 - onAddedCustomEmoji={async (description: string, imageURL: string) => { 87 - if (!cache?.emojiDatabase) return; 88 - await saveToStorage({ 89 - emojiDatabase: { 90 - ...cache.emojiDatabase, 91 - customEmojis: { 92 - ...customEmojis, 93 - [description]: createCustomEmoji({ description, imageURL }), 94 - }, 95 - }, 96 - }); 97 - }} 98 87 /> 99 88 </Only> 100 89
+1 -1
source/config/legacy_autoselect_set.ts
··· 63 63 LEGACY_EMOJI_SET.push('🐱‍💻'); 64 64 } 65 65 66 - export default LEGACY_EMOJI_SET; 66 + export default Object.freeze(LEGACY_EMOJI_SET);
+43 -28
source/content_script.ts
··· 1 1 /// <reference lib="dom" /> 2 + import type { Emoji } from './models/emoji.ts'; 3 + import type { Favicon } from './models/favicon.ts'; 2 4 3 5 /** 4 6 * Check siteList and ignoreList from chrome storage ··· 6 8 * Override favicon if applicable 7 9 */ 8 10 import browserAPI from 'browser'; 9 - 10 - import type { Emoji } from './utilities/emoji.ts'; 11 - import { appendFaviconLink } from './utilities/favicon_helpers.ts'; 11 + import appendFaviconLink from './utilities/append_favicon_link.ts'; 12 12 13 13 /** 14 14 * Reload the webpage if new Favioli settings may have updated the favicon ··· 17 17 * 3. Did we start/stop using ignoreList? 18 18 * 4. Did the emoji set change? 19 19 */ 20 - browserAPI.storage.onChanged.addListener((changes) => { 21 - const { autoselectorVersion, features, ignoreList, siteList } = changes || {}; 22 - if (siteList) { 23 - const { newValue = [], oldValue = [] } = siteList || {}; 24 - const newDiff = newValue.filter(includesCurrUrl); 25 - const oldDiff = oldValue.filter(includesCurrUrl); 26 - if (newDiff.length !== oldDiff.length) location.reload(); 27 - } else if (ignoreList) { 28 - const { newValue = [], oldValue = [] } = ignoreList || {}; 29 - const newDiff = newValue.filter(includesCurrUrl); 30 - const oldDiff = oldValue.filter(includesCurrUrl); 31 - if (newDiff.length !== oldDiff.length) location.reload(); 32 - } else if (autoselectorVersion) { 33 - const { newValue = '', oldValue = '' } = autoselectorVersion; 34 - if (newValue !== oldValue) location.reload(); 35 - } else if ( 36 - !shallowCompare(features?.newValue, features?.oldValue) 37 - ) { 20 + browserAPI.storage.onChanged.addListener((changes): void => { 21 + if (!changes?.settings) return; 22 + const { newValue, oldValue } = changes.settings; 23 + 24 + if (newValue.autoselectorVersion !== oldValue.autoselectorVersion) { 25 + location.reload(); 26 + return; 27 + } 28 + 29 + if (!shallowCompare(newValue.features, oldValue.features)) { 30 + location.reload(); 31 + return; 32 + } 33 + 34 + const newSiteList = newValue.siteList.filter(includesCurrUrl); 35 + const oldSiteList = oldValue.siteList.filter(includesCurrUrl); 36 + 37 + if (!shallowCompare(newSiteList, oldSiteList)) { 38 + location.reload(); 39 + return; 40 + } 41 + 42 + const newIgnoreList = newValue.ignoreList.filter(includesCurrUrl); 43 + const oldIgnoreList = oldValue.ignoreList.filter(includesCurrUrl); 44 + if (!shallowCompare(newIgnoreList, oldIgnoreList)) { 38 45 location.reload(); 46 + return; 39 47 } 40 48 }); 41 49 42 50 // Return true if objects are equivalent. 43 - // deno-lint-ignore no-explicit-any 44 - function shallowCompare(obj1: any, obj2: any) { 51 + function shallowCompare(obj1: unknown, obj2: unknown) { 45 52 if (typeof obj1 !== typeof obj2) return false; 46 - else if (typeof obj1 !== 'object' || typeof obj2 !== 'object') { 47 - return obj1 === obj2; 53 + else if ( 54 + (obj1 == null || obj2 == null) || 55 + (typeof obj1 !== 'object' || typeof obj2 !== 'object') 56 + ) { 57 + return obj1 == obj2; 48 58 } else if (Object.keys(obj1).length !== Object.keys(obj2).length) { 49 59 return false; 50 60 } else { 51 61 return Object.keys(obj1) 52 - .every((key) => Object.hasOwn(obj2, key) && obj1[key] === obj2[key]); 62 + .every((obj1Key: string) => 63 + Object.hasOwn(obj1, obj1Key) && 64 + Object.hasOwn(obj2, obj1Key) && 65 + (obj1 as Record<string, unknown>)[obj1Key] === 66 + (obj2 as Record<string, unknown>)[obj1Key] 67 + ); 53 68 } 54 69 } 55 70 56 - function includesCurrUrl(val: string) { 57 - return (new RegExp(val)).test(location.href); 71 + function includesCurrUrl({ matcher }: Favicon) { 72 + return (new RegExp(matcher)).test(location.href); 58 73 } 59 74 60 75 browserAPI.runtime.onMessage.addListener(({ emoji, shouldOverride }: {
+26
source/hooks/use_active_tab.ts
··· 1 + import type { Tab } from 'browser'; 2 + 3 + import { useEffect, useState } from 'preact/hooks'; 4 + import browserAPI from 'browser'; 5 + 6 + const queryOptions = { active: true }; 7 + const { storage, tabs } = browserAPI; 8 + 9 + export default function useActiveTab(): Tab | void { 10 + const [activeTab, setActiveTab] = useState<Tab | void>(); 11 + 12 + useEffect(function updateactiveTab() { 13 + async function queryAndSetActiveTab() { 14 + setActiveTab((await tabs.query(queryOptions))[0]); 15 + } 16 + storage.onChanged.addListener(queryAndSetActiveTab); 17 + tabs.onUpdated.addListener(queryAndSetActiveTab); 18 + queryAndSetActiveTab().catch(console.error); 19 + (() => { 20 + storage.onChanged.removeListener(queryAndSetActiveTab); 21 + tabs.onUpdated.removeListener(queryAndSetActiveTab); 22 + }); 23 + }, []); 24 + 25 + return activeTab; 26 + }
+27 -19
source/hooks/use_browser_storage.ts
··· 1 - import { createContext } from 'preact'; 2 1 import { useCallback, useEffect, useState } from 'preact/hooks'; 3 2 import browserAPI from 'browser'; 4 3 ··· 13 12 loading: boolean; 14 13 setCache: (nextCache: Partial<Type>, saveImmediately?: boolean) => void; 15 14 saveCacheToStorage: () => Promise<void>; 16 - saveToStorage: (next: Partial<Type>) => Promise<void>; 15 + saveToStorageBypassCache: (next: Partial<Type>) => Promise<void>; 17 16 } 18 17 19 - // deno-lint-ignore no-explicit-any 20 - export const StorageContext = createContext<BrowserStorage<any>>({ 21 - loading: true, 22 - setCache: () => {}, 23 - saveCacheToStorage: async () => {}, 24 - saveToStorage: async () => {}, 25 - }); 26 - 27 18 /** 28 19 * Interact with BrowserStorage as little as possible. 29 20 * This method probably does NOT work well if multiple sessions open at once. ··· 34 25 * - `saveCacheToStorage` saves that local data into browserStorage on a separate interaction 35 26 */ 36 27 export default function useBrowserStorage<Type extends Storage>( 37 - keys: readonly string[], 28 + keys: string | readonly string[], 38 29 defaultState: Type, 39 30 ) { 40 31 const [error, setError] = useState<string>(); 41 32 const [cache, setCache] = useState<Type>(defaultState); 42 33 const [loading, setLoading] = useState<boolean>(true); 34 + const keyArray = Array.isArray(keys) ? keys : [keys]; 43 35 44 36 useEffect(function setupStorageFetcher() { 45 37 updateState(); 46 - browserAPI.storage.onChanged.addListener(updateState); 38 + if (!storage.onChanged.hasListener(updateState)) { 39 + storage.onChanged.addListener(updateState); 40 + } 47 41 48 42 async function updateState() { 49 - const nextState = await storage.sync.get(keys) as Type; 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; 50 47 if (runtime?.lastError?.message) setError(runtime?.lastError?.message); 51 - 52 - if (Object.keys(nextState).length === Object.keys(defaultState).length) { 48 + if (nextState) { 53 49 setCache(nextState); 54 50 } 55 51 setLoading(false); 56 52 } 57 53 58 54 return () => { 59 - browserAPI.storage.onChanged.removeListener(updateState); 55 + storage.onChanged.removeListener(updateState); 60 56 }; 61 - }, []); 57 + }, [keys]); 62 58 63 59 const saveToStorage = useCallback( 64 60 async (next: Partial<Type> | void): Promise<void> => { 65 61 if (!next) return; 66 - await storage.sync.set(next); 62 + if (Array.isArray(keys)) { 63 + await storage.sync.set(next); 64 + } else { 65 + await storage.sync.set({ [keys as string]: next }); 66 + } 67 67 if (runtime?.lastError?.message) setError(runtime?.lastError?.message); 68 68 }, 69 69 [], ··· 87 87 return saveToStorage(cache); 88 88 }, [cache]), 89 89 90 - saveToStorage, 90 + // Save new data; don't save pre-existing cache (set storage before cache) 91 + saveToStorageBypassCache: useCallback( 92 + async (next: Partial<Type>): Promise<void> => { 93 + await saveToStorage(next); 94 + const nextStorage = { ...cache, ...next }; 95 + setCache(nextStorage); 96 + }, 97 + [cache, setCache], 98 + ), 91 99 }; 92 100 93 101 return result;
+66
source/hooks/use_selected_favicon.ts
··· 1 + import type { Emoji } from '../models/emoji.ts'; 2 + import type { Favicon } from '../models/favicon.ts'; 3 + import type { Settings } from '../models/settings.ts'; 4 + 5 + import { useEffect, useMemo, useState } from 'preact/hooks'; 6 + 7 + import { getEmoji } from '../models/emoji.ts'; 8 + import Autoselector from '../utilities/favicon_autoselector.ts'; 9 + import selectFavicon from '../utilities/favicon_selector.ts'; 10 + import { createFaviconURLFromChar } from '../utilities/image_helpers.ts'; 11 + 12 + /** 13 + * Get the favicon, emoji, and displayed favicon url for a specific location. 14 + * Treat as if we are autofilling, and not ignoring sites. 15 + */ 16 + export default function useSelectedFavicon( 17 + url: string, 18 + settings?: Settings, 19 + ): { 20 + selectedFavicon: Favicon | null; 21 + selectedEmoji: Emoji | null; 22 + selectedFaviconURL: string; 23 + } { 24 + const { autoselectorVersion, features } = settings || {}; 25 + const includeFlags = Boolean(features?.enableAutoselectorIncludeCountryFlags); 26 + 27 + const [selectedFavicon, setFavicon] = useState<Favicon | null>(null); 28 + const [selectedEmoji, setEmoji] = useState<Emoji | null>(null); 29 + 30 + const autoselector = useMemo(function () { 31 + if (!autoselectorVersion) return null; 32 + return new Autoselector(autoselectorVersion, { includeFlags }); 33 + }, [autoselectorVersion]); 34 + 35 + useEffect(function () { 36 + if (!url || !settings || !autoselector) return; 37 + (async () => { 38 + const features = { 39 + ...settings.features, 40 + enableFaviconAutofill: true, 41 + enableSiteIgnore: false, 42 + }; 43 + const checkSettings = { ...settings, features }; 44 + const [favicon] = await selectFavicon(url, checkSettings, autoselector); 45 + const emoji = favicon?.emojiId ? await getEmoji(favicon.emojiId) : null; 46 + setFavicon(favicon || null); 47 + setEmoji(emoji || null); 48 + })(); 49 + }, [autoselector, settings, url]); 50 + 51 + const selectedFaviconURL = useMemo((): string => { 52 + if (!selectedEmoji) return ''; 53 + const { imageURL, emoji } = selectedEmoji; 54 + return imageURL || createFaviconURLFromChar(emoji || ''); 55 + }, [selectedEmoji]); 56 + 57 + if (!url || !settings) { 58 + return { 59 + selectedFavicon: null, 60 + selectedEmoji: null, 61 + selectedFaviconURL: '', 62 + }; 63 + } 64 + 65 + return { selectedFavicon, selectedEmoji, selectedFaviconURL }; 66 + }
+107
source/models/__fixtures__/settings_fixtures.ts
··· 1 + import type { Settings } from '../settings.ts'; 2 + import type { SettingsV1 } from '../storage_legacy.ts'; 3 + 4 + import { emoji } from '../emoji.ts'; 5 + import { createFavicon } from '../favicon.ts'; 6 + 7 + export const v0: SettingsV1 = { 8 + flagReplaced: true, 9 + overrideAll: false, 10 + overrides: [ 11 + { 12 + emoji: '😍', 13 + filter: 'hello', 14 + }, 15 + { 16 + emoji: '😃', 17 + filter: 'goodbye', 18 + }, 19 + { 20 + emoji: '🤩', 21 + filter: 'sweet lahd', 22 + }, 23 + ], 24 + skips: [ 25 + 'hahahahh', 26 + ], 27 + }; 28 + 29 + export const v1: SettingsV1 = { 30 + flagReplaced: true, 31 + overrideAll: false, 32 + overrides: [ 33 + { 34 + emoji: { 35 + colons: ':heart_eyes:', 36 + emoticons: [], 37 + id: 'heart_eyes', 38 + name: 'Smiling Face with Heart-Shaped Eyes', 39 + native: '😍', 40 + short_names: [ 41 + 'heart_eyes', 42 + ], 43 + skin: null, 44 + unified: '1f60d', 45 + }, 46 + filter: 'hello', 47 + }, 48 + { 49 + emoji: { 50 + colons: ':smiley:', 51 + emoticons: [ 52 + '=)', 53 + '=-)', 54 + ], 55 + id: 'smiley', 56 + name: 'Smiling Face with Open Mouth', 57 + native: '😃', 58 + short_names: [ 59 + 'smiley', 60 + ], 61 + skin: null, 62 + unified: '1f603', 63 + }, 64 + filter: 'goodbye', 65 + }, 66 + { 67 + emoji: { 68 + colons: ':star-struck:', 69 + emoticons: [], 70 + id: 'star-struck', 71 + name: 'Grinning Face with Star Eyes', 72 + native: '🤩', 73 + short_names: [ 74 + 'star-struck', 75 + 'grinning_face_with_star_eyes', 76 + ], 77 + skin: null, 78 + unified: '1f929', 79 + }, 80 + filter: 'sweet lahd', 81 + }, 82 + ], 83 + skips: [ 84 + 'hahahahh', 85 + ], 86 + }; 87 + 88 + export const v2: Settings = { 89 + autoselectorVersion: 'FAVIOLI_LEGACY', 90 + frequentlyUsed: [], 91 + customEmojiIds: [], 92 + features: { 93 + enableAutoselectorIncludeCountryFlags: false, 94 + enableFaviconAutofill: true, 95 + enableOverrideAll: false, 96 + enableSiteIgnore: true, 97 + }, 98 + ignoreList: [ 99 + createFavicon('hahahahh'), 100 + ], 101 + siteList: [ 102 + createFavicon('hello', emoji.infoByCode('😍')), 103 + createFavicon('goodbye', emoji.infoByCode('😃')), 104 + createFavicon('sweet lahd', emoji.infoByCode('🤩')), 105 + ], 106 + version: '2.0.0', 107 + };
+18
source/models/__tests__/__snapshots__/emoji.test.ts.snap
··· 1 + export const snapshot = {}; 2 + 3 + snapshot[`createEmoji 1`] = ` 4 + { 5 + aliases: [ 6 + "poro", 7 + ], 8 + description: "poro", 9 + emoji: "", 10 + emojiVersion: 0, 11 + group: "Custom Emojis", 12 + imageURL: "poro://poro-url", 13 + subgroup: "custom-emojis", 14 + tags: [ 15 + ], 16 + unicodeVersion: 0, 17 + } 18 + `;
+108
source/models/__tests__/emoji.test.ts
··· 1 + import { assert, assertEquals, assertRejects } from 'std/asserts'; 2 + import { describe, it } from 'std/bdd'; 3 + import { assertSpyCall, assertSpyCalls, stub } from 'std/mock'; 4 + import { assertSnapshot } from 'std/snapshot'; 5 + 6 + import browserAPI from 'browser'; 7 + 8 + import { 9 + areEqualEmojis, 10 + createEmoji, 11 + getEmoji, 12 + getEmojis, 13 + saveEmoji, 14 + } from '../emoji.ts'; 15 + 16 + it('createEmoji', async (t) => { 17 + await assertSnapshot(t, createEmoji('poro', 'poro://poro-url')); 18 + }); 19 + 20 + describe('getEmoji', () => { 21 + it('should get emoji from local Emoji DB', async () => { 22 + const emoji = await getEmoji('grinning squinting face'); 23 + assertEquals(emoji?.emoji, '😆'); 24 + }); 25 + 26 + it('should get emoji from storage.sync', async () => { 27 + const storageStub = stub(browserAPI.storage.sync, 'get', () => { 28 + return Promise.resolve({ 29 + 'Custom Emoji: poro': createEmoji('poro', 'poro://poro-url'), 30 + }); 31 + }); 32 + const emoji = await getEmoji('poro'); 33 + assertEquals(emoji?.imageURL, 'poro://poro-url'); 34 + assertEquals(emoji?.description, 'poro'); 35 + assertSpyCalls(storageStub, 1); 36 + assertSpyCall(storageStub, 0, { 37 + args: [['Custom Emoji: poro']], 38 + }); 39 + storageStub.restore(); 40 + }); 41 + }); 42 + 43 + describe('saveEmoji', () => { 44 + it('should set emoji to storage.sync', async () => { 45 + const storageStub = stub( 46 + browserAPI.storage.sync, 47 + 'set', 48 + () => Promise.resolve(), 49 + ); 50 + const emoji = createEmoji('poro', 'poro://poro-url'); 51 + await saveEmoji(emoji); 52 + assertSpyCalls(storageStub, 1); 53 + assertSpyCall(storageStub, 0, { 54 + args: [{ 'Custom Emoji: poro': emoji }], 55 + returned: Promise.resolve(), 56 + }); 57 + storageStub.restore(); 58 + }); 59 + 60 + it('should error if emoji exists in local DB', async () => { 61 + const storageStub = stub( 62 + browserAPI.storage.sync, 63 + 'set', 64 + () => Promise.resolve(), 65 + ); 66 + const emoji = createEmoji('grinning squinting face', 'poro://poro-url'); 67 + const errorMessage = 'This Emoji Already Exists!'; 68 + await assertRejects(() => saveEmoji(emoji), Error, errorMessage); 69 + storageStub.restore(); 70 + }); 71 + }); 72 + 73 + it('getEmojis', async () => { 74 + const storageStub = stub(browserAPI.storage.sync, 'get', () => { 75 + return Promise.resolve([ 76 + createEmoji('poro', 'poro://poro-url'), 77 + createEmoji('nother', 'nother://nother-custom-emoji'), 78 + ]); 79 + }); 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'); 84 + 85 + assertSpyCalls(storageStub, 1); 86 + 87 + const expectedArgs = [['Custom Emoji: nother', 'Custom Emoji: poro']]; 88 + assertSpyCall(storageStub, 0, { args: expectedArgs }); 89 + storageStub.restore(); 90 + }); 91 + 92 + describe('areEqualEmojis', async () => { 93 + const emoji_1a = await getEmoji('grinning squinting face'); 94 + const emoji_1b = await getEmoji('grinning squinting face'); 95 + const emoji_2 = await getEmoji('tractor'); 96 + const customEmoji_1a = createEmoji('poro', 'poro://poro-url'); 97 + const customEmoji_1b = createEmoji('poro', 'poro://poro-url'); 98 + 99 + if (!emoji_1a || !emoji_1b || !emoji_2) throw new Error('Bad emoji creation'); 100 + 101 + it('emoji_1a === emoji_1b', () => assert(areEqualEmojis(emoji_1a, emoji_1b))); 102 + it('emoji_1a !== emoji_2', () => assert(!areEqualEmojis(emoji_1a, emoji_2))); 103 + it('emoji_1b !== emoji_2', () => assert(!areEqualEmojis(emoji_1b, emoji_2))); 104 + it('customEmoji_1a === customEmoji_1b', () => 105 + assert(areEqualEmojis(customEmoji_1a, customEmoji_1b))); 106 + it('customEmoji_1a !== emoji_1a', () => 107 + assert(!areEqualEmojis(customEmoji_1a, emoji_1a))); 108 + });
+17
source/models/__tests__/storage_legacy.test.ts
··· 1 + import { assertEquals } from 'std/asserts'; 2 + import { describe, it } from 'std/bdd'; 3 + 4 + import { v0, v1, v2 } from '../__fixtures__/settings_fixtures.ts'; 5 + import { isSettingsV1, migrateStorageFromV1 } from '../storage_legacy.ts'; 6 + 7 + describe('migrateStorageFromV1', () => { 8 + it('should migrate from v0 to v2', () => { 9 + assertEquals(isSettingsV1(v0), true); 10 + assertEquals(migrateStorageFromV1(v0), v2); 11 + }); 12 + 13 + it('should migrate from v1 to v2', () => { 14 + assertEquals(isSettingsV1(v1), true); 15 + assertEquals(migrateStorageFromV1(v1), v2); 16 + }); 17 + });
+128
source/models/emoji.ts
··· 1 + import type { Emoji as BaseEmoji } from 'https://deno.land/x/emoji@0.2.0/types.ts'; 2 + import type { BrowserStorage } from '../hooks/use_browser_storage.ts'; 3 + 4 + import browserAPI from 'browser'; 5 + import * as emoji from 'emoji'; 6 + import { createContext } from 'preact'; 7 + 8 + const { freeze, fromEntries, keys } = Object; 9 + const { storage } = browserAPI || {}; 10 + 11 + export interface Emoji extends BaseEmoji { 12 + imageURL?: string; // Support Custom Emojis 13 + } 14 + 15 + export interface EmojiGroup { 16 + name: string; 17 + representativeEmoji: string; 18 + emojis: readonly Emoji[]; 19 + } 20 + 21 + export interface EmojiGroups { 22 + [name: string]: EmojiGroup; 23 + } 24 + 25 + export interface EmojiMap { 26 + [name: string]: Emoji; 27 + } 28 + 29 + export { emoji }; 30 + export const emojis = freeze(emoji.all()); 31 + 32 + export const DEFAULT_EMOJI = freeze(emoji.infoByCode('😀') as Emoji); 33 + export const CUSTOM_GROUP_NAME = 'Custom Emojis'; 34 + 35 + export const emojiGroups: EmojiGroups = createEmojiGroups(emojis); 36 + export const emojiGroupsArray = freeze( 37 + keys(emojiGroups).map((name) => emojiGroups[name]), 38 + ); 39 + 40 + export const CustomEmojiContext = createContext<BrowserStorage<string[]>>({ 41 + loading: true, 42 + setCache: () => {}, 43 + saveCacheToStorage: async () => {}, 44 + saveToStorageBypassCache: async () => {}, 45 + }); 46 + 47 + export function createEmoji( 48 + description: string, 49 + imageURL: string, 50 + ): Emoji { 51 + return { 52 + emoji: '', 53 + description, 54 + group: CUSTOM_GROUP_NAME, 55 + subgroup: 'custom-emojis', 56 + emojiVersion: 0, 57 + unicodeVersion: 0, 58 + tags: [], 59 + aliases: [description], 60 + imageURL, 61 + }; 62 + } 63 + 64 + export const getEmojiStorageId = (id: string) => `Custom Emoji: ${id}`; 65 + const byDescription: EmojiMap = fromEntries( 66 + emojis.map((emoji) => [emoji.description, emoji]), 67 + ); 68 + 69 + export async function getEmoji(desc: string): Promise<Emoji | undefined> { 70 + if (byDescription[desc]) return byDescription[desc]; 71 + const storageID = getEmojiStorageId(desc); 72 + const resp = await storage.sync.get([storageID]); 73 + return resp[storageID] as Emoji; 74 + } 75 + 76 + export async function getEmojis(descs: string[]): Promise<Emoji[]> { 77 + const localEmojis: Emoji[] = []; 78 + const customDescIds: string[] = []; 79 + descs.forEach((desc: string) => { 80 + if (byDescription[desc]) localEmojis.push(byDescription[desc]); 81 + else customDescIds.push(getEmojiStorageId(desc)); 82 + }); 83 + const customDescs = await storage.sync.get(customDescIds) as Emoji[]; 84 + return localEmojis.concat(customDescs); 85 + } 86 + 87 + export async function saveEmoji(emojiToSave: Emoji): Promise<void> { 88 + const desc: string = emojiToSave.description; 89 + if (byDescription[desc]) throw new Error('This Emoji Already Exists!'); 90 + await storage.sync.set({ [getEmojiStorageId(desc)]: emojiToSave }); 91 + } 92 + 93 + export function areEqualEmojis(emoji1?: Emoji, emoji2?: Emoji): boolean { 94 + if (!emoji1 || !emoji2) return false; 95 + if (emoji1.imageURL && (emoji1.imageURL === emoji2.imageURL)) return true; 96 + if (emoji1.emoji && (emoji1.emoji === emoji2.emoji)) return true; 97 + return false; 98 + } 99 + 100 + function createEmojiGroups(emojis: readonly Emoji[]): EmojiGroups { 101 + const emojiGroups: { 102 + [name: string]: { 103 + name: string; 104 + representativeEmoji: string; 105 + emojis: Emoji[]; 106 + }; 107 + } = {}; 108 + 109 + emojis.forEach((emoji) => { 110 + if (!emojiGroups[emoji.group]) { 111 + emojiGroups[emoji.group] = { 112 + name: emoji.group, 113 + emojis: [emoji], 114 + representativeEmoji: emoji.emoji, 115 + }; 116 + } else { 117 + emojiGroups[emoji.group].emojis.push(emoji); 118 + } 119 + }); 120 + 121 + emojiGroups[CUSTOM_GROUP_NAME] = { 122 + name: CUSTOM_GROUP_NAME, 123 + emojis: [], 124 + representativeEmoji: '*', 125 + }; 126 + 127 + return freeze(emojiGroups); 128 + }
+17
source/models/favicon.ts
··· 1 + import type { Emoji } from './emoji.ts'; 2 + 3 + export interface Favicon { 4 + // String (inc RegExp string) representing the url to match 5 + matcher: string; 6 + /** 7 + * Unique ID representing favicon (emoji.description) 8 + * We store emojis by ID and retrieve from storage when we want to use. 9 + * This allows us to save custom emoji image data and access on demand, 10 + * saving us space in chrome storage. 11 + */ 12 + emojiId?: string; 13 + } 14 + 15 + export function createFavicon(matcher = '', emoji?: Emoji): Favicon { 16 + return { matcher, emojiId: emoji?.description }; 17 + }
+51
source/models/settings.ts
··· 1 + import type { BrowserStorage } from '../hooks/use_browser_storage.ts'; 2 + import type { AutoselectorVersion } from '../utilities/favicon_autoselector.ts'; 3 + 4 + import { createContext } from 'preact'; 5 + 6 + import manifest from '../manifest.json' assert { type: 'json' }; 7 + import { AUTOSELECTOR_VERSION } from '../utilities/favicon_autoselector.ts'; 8 + import { Favicon } from './favicon.ts'; 9 + 10 + export const SETTINGS_KEY = 'settings'; 11 + 12 + export interface Settings { 13 + version: string; 14 + autoselectorVersion: AutoselectorVersion; 15 + 16 + siteList: Favicon[]; 17 + ignoreList: Favicon[]; 18 + frequentlyUsed: Favicon[]; 19 + customEmojiIds: string[]; 20 + 21 + features: { 22 + enableAutoselectorIncludeCountryFlags: boolean; 23 + enableFaviconAutofill: boolean; 24 + enableSiteIgnore: boolean; 25 + enableOverrideAll: boolean; 26 + }; 27 + } 28 + 29 + export const DEFAULT_SETTINGS: Settings = { 30 + version: manifest.version, 31 + autoselectorVersion: AUTOSELECTOR_VERSION.UNICODE_12, 32 + 33 + siteList: [], 34 + ignoreList: [], 35 + customEmojiIds: [], 36 + frequentlyUsed: [], 37 + 38 + features: { 39 + enableAutoselectorIncludeCountryFlags: false, 40 + enableFaviconAutofill: false, 41 + enableSiteIgnore: false, 42 + enableOverrideAll: false, 43 + }, 44 + }; 45 + 46 + export const SettingsContext = createContext<BrowserStorage<Settings>>({ 47 + loading: true, 48 + setCache: () => {}, 49 + saveCacheToStorage: async () => {}, 50 + saveToStorageBypassCache: async () => {}, 51 + });
+77
source/models/storage_legacy.ts
··· 1 + import type { Favicon } from './favicon.ts'; 2 + import type { Settings } from './settings.ts'; 3 + 4 + import { AUTOSELECTOR_VERSION } from '../utilities/favicon_autoselector.ts'; 5 + import { emoji } from './emoji.ts'; 6 + import { createFavicon } from './favicon.ts'; 7 + import { DEFAULT_SETTINGS } from './settings.ts'; 8 + 9 + /** 10 + * Storage from Favioli V1 11 + * Includes tools for migration 12 + */ 13 + export interface SettingsV1 { 14 + flagReplaced: boolean; 15 + overrideAll: boolean; 16 + overrides: EmojiV1[]; 17 + skips: string[]; 18 + } 19 + 20 + export interface EmojiV1 { 21 + emoji: string | { 22 + colons: string; 23 + emoticons: string[]; 24 + id: string; 25 + name: string; 26 + native: string; 27 + short_names: string[]; 28 + skin: null; 29 + unified: string; 30 + }; 31 + filter: string; 32 + } 33 + 34 + export const LEGACY_STORAGE_KEYS = [ 35 + 'flagReplaced', 36 + 'overrideAll', 37 + 'overrides', 38 + 'skips', 39 + ]; 40 + 41 + export function isSettingsV1( 42 + settings: unknown, 43 + ): settings is SettingsV1 { 44 + if (typeof settings !== 'object' || settings == null) return false; 45 + return ( 46 + 'flagReplaced' in settings || 47 + 'overrideAll' in settings || 48 + 'overrides' in settings || 49 + 'skips' in settings 50 + ); 51 + } 52 + 53 + export function migrateStorageFromV1(legacySettings: SettingsV1): Settings { 54 + const settings = { ...DEFAULT_SETTINGS }; 55 + 56 + settings.features = { 57 + enableAutoselectorIncludeCountryFlags: false, 58 + enableFaviconAutofill: true, 59 + enableOverrideAll: legacySettings.overrideAll, 60 + enableSiteIgnore: Boolean(legacySettings?.skips?.length), 61 + }; 62 + 63 + settings.siteList = (legacySettings?.overrides || []) 64 + .map((legacyFavicon): Favicon => { 65 + const emojiInput = typeof legacyFavicon.emoji === 'string' 66 + ? emoji.infoByCode(legacyFavicon.emoji) 67 + : emoji.infoByCode(legacyFavicon.emoji.native); 68 + return createFavicon(legacyFavicon.filter, emojiInput); 69 + }); 70 + 71 + settings.ignoreList = (legacySettings?.skips || []) 72 + .map((skip) => createFavicon(skip)); 73 + 74 + settings.autoselectorVersion = AUTOSELECTOR_VERSION.FAVIOLI_LEGACY; 75 + 76 + return settings; 77 + }
+8 -10
source/options.tsx
··· 1 1 /* @jsx h */ 2 - 3 - import type { Settings } from './utilities/settings.ts'; 2 + import type { Settings } from './models/settings.ts'; 4 3 5 4 import { Fragment, h, render } from 'preact'; 6 5 import { useCallback } from 'preact/hooks'; 7 6 8 7 import Header from './components/header.tsx'; 9 8 import Switch from './components/switch.tsx'; 10 - import useBrowserStorage, { 11 - StorageContext, 12 - } from './hooks/use_browser_storage.ts'; 9 + import useBrowserStorage from './hooks/use_browser_storage.ts'; 13 10 import useRoute from './hooks/use_route.ts'; 14 11 import useStatus from './hooks/use_status.ts'; 12 + import { SettingsContext } from './models/settings.ts'; 15 13 import AboutPage from './pages/about_page.tsx'; 16 14 import FaviconsPage from './pages/favicons_page.tsx'; 17 15 import SettingsPage from './pages/settings_page.tsx'; 18 16 import { t } from './utilities/i18n.ts'; 19 - import { DEFAULT_SETTINGS, STORAGE_KEYS } from './utilities/settings.ts'; 17 + import { DEFAULT_SETTINGS, SETTINGS_KEY } from './models/settings.ts'; 20 18 21 19 const App = () => { 22 20 const route = useRoute(); 23 - const storage = useBrowserStorage<Settings>(STORAGE_KEYS, DEFAULT_SETTINGS); 24 - const { error = '', loading, saveCacheToStorage } = storage; 21 + const settings = useBrowserStorage<Settings>(SETTINGS_KEY, DEFAULT_SETTINGS); 22 + const { error = '', loading, saveCacheToStorage } = settings; 25 23 const { status, save } = useStatus(error || '', saveCacheToStorage); 26 24 27 25 const saveOptions = useCallback((e: Event) => { ··· 36 34 <Header route={route} /> 37 35 <div className='page'> 38 36 <div className='page-content'> 39 - <StorageContext.Provider value={storage}> 37 + <SettingsContext.Provider value={settings}> 40 38 <Switch 41 39 value={route} 42 40 defaultCase={<FaviconsPage save={saveOptions} />} ··· 45 43 '#about': <AboutPage />, 46 44 }} 47 45 /> 48 - </StorageContext.Provider> 46 + </SettingsContext.Provider> 49 47 </div> 50 48 </div> 51 49 <div id='status'>{status}</div>
+3 -3
source/pages/about_page.tsx
··· 1 1 /* @jsx h */ 2 2 import type { BrowserStorage } from '../hooks/use_browser_storage.ts'; 3 - import type { Settings } from '../utilities/settings.ts'; 3 + import type { Settings } from '../models/settings.ts'; 4 4 5 5 import { Fragment, h } from 'preact'; 6 6 import { useContext } from 'preact/hooks'; 7 7 8 8 import Only from '../components/only.tsx'; 9 - import { StorageContext } from '../hooks/use_browser_storage.ts'; 9 + import { SettingsContext } from '../models/settings.ts'; 10 10 11 11 export default function () { 12 - const storage = useContext<BrowserStorage<Settings>>(StorageContext); 12 + const storage = useContext<BrowserStorage<Settings>>(SettingsContext); 13 13 const { cache = { version: 0 } } = storage || {}; 14 14 15 15 return (
+3 -3
source/pages/favicons_page.tsx
··· 1 1 /* @jsx h */ 2 2 import type { BrowserStorage } from '../hooks/use_browser_storage.ts'; 3 + import type { Settings } from '../models/settings.ts'; 3 4 4 5 import { Fragment, h } from 'preact'; 5 6 import { useContext, useEffect } from 'preact/hooks'; 6 7 7 - import { DEFAULT_SETTINGS, Settings } from '../utilities/settings.ts'; 8 8 import List from '../components/list.tsx'; 9 9 import Only from '../components/only.tsx'; 10 - import { StorageContext } from '../hooks/use_browser_storage.ts'; 11 10 import useListState from '../hooks/use_list_state.ts'; 11 + import { DEFAULT_SETTINGS, SettingsContext } from '../models/settings.ts'; 12 12 import { t } from '../utilities/i18n.ts'; 13 13 14 14 export interface FaviconsPageProps { ··· 18 18 } 19 19 20 20 export default function FaviconsPage({ save }: FaviconsPageProps) { 21 - const storage = useContext<BrowserStorage<Settings>>(StorageContext); 21 + const storage = useContext<BrowserStorage<Settings>>(SettingsContext); 22 22 const { siteList, ignoreList, features } = storage?.cache || DEFAULT_SETTINGS; 23 23 const { enableSiteIgnore } = features; 24 24 const siteListState = useListState(siteList);
+4 -4
source/pages/settings_page.tsx
··· 1 1 /* @jsx h */ 2 2 import type { BrowserStorage } from '../hooks/use_browser_storage.ts'; 3 + import type { Settings } from '../models/settings.ts'; 3 4 4 5 import { Fragment, h } from 'preact'; 5 6 import { useCallback, useContext } from 'preact/hooks'; 6 7 7 - import { StorageContext } from '../hooks/use_browser_storage.ts'; 8 - import { AUTOSELECTOR_VERSION } from '../utilities/autoselector.ts'; 9 - import { DEFAULT_SETTINGS, Settings } from '../utilities/settings.ts'; 10 8 import Checkbox, { Target } from '../components/checkbox.tsx'; 11 9 import Only from '../components/only.tsx'; 10 + import { DEFAULT_SETTINGS, SettingsContext } from '../models/settings.ts'; 11 + import { AUTOSELECTOR_VERSION } from '../utilities/favicon_autoselector.ts'; 12 12 import { t } from '../utilities/i18n.ts'; 13 13 14 14 export interface SettingsProps { ··· 18 18 } 19 19 20 20 const SettingsPage = ({ save }: SettingsProps) => { 21 - const storage = useContext<BrowserStorage<Settings>>(StorageContext); 21 + const storage = useContext<BrowserStorage<Settings>>(SettingsContext); 22 22 const { cache = DEFAULT_SETTINGS, setCache } = storage || {}; 23 23 const { autoselectorVersion } = cache; 24 24 const {
+40 -74
source/popup.tsx
··· 1 1 /* @jsx h */ 2 - import type { Tab } from 'browser'; 2 + import type { Settings } from './models/settings.ts'; 3 + 3 4 import { h, render } from 'preact'; 4 - import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; 5 + import { useCallback } from 'preact/hooks'; 5 6 import browserAPI from 'browser'; 6 7 7 - import Autoselector from './utilities/autoselector.ts'; 8 - import { 9 - createFaviconDataFromEmoji, 10 - getEmojiFromFavicon, 11 - } from './utilities/favicon_data.ts'; 8 + import useActiveTab from './hooks/use_active_tab.ts'; 12 9 import useBrowserStorage from './hooks/use_browser_storage.ts'; 10 + import useSelectedFavicon from './hooks/use_selected_favicon.ts'; 13 11 import useStatus from './hooks/use_status.ts'; 14 - import { 15 - DEFAULT_SETTINGS, 16 - Settings, 17 - STORAGE_KEYS, 18 - } from './utilities/settings.ts'; 19 - import { createFaviconURLFromChar } from './utilities/create_favicon_url.ts'; 20 - 21 - let autoselector: Autoselector | void; 22 - const queryOptions = { active: true }; 12 + import { DEFAULT_SETTINGS, SETTINGS_KEY } from './models/settings.ts'; 23 13 24 14 const App = () => { 25 - const storage = useBrowserStorage<Settings>(STORAGE_KEYS, DEFAULT_SETTINGS); 26 - const { cache, error = '', loading, setCache } = storage; 27 - const [currTab, setCurrTab] = useState<Tab | void>(); 28 - const { favIconUrl = '', url = '' } = currTab || {}; 29 - 30 - const autoselector = useMemo(() => { 31 - if (!cache?.autoselectorVersion) return null; 32 - const includeFlags = cache?.features?.enableAutoselectorIncludeCountryFlags; 33 - return new Autoselector(cache?.autoselectorVersion, { includeFlags }); 34 - }, [cache?.autoselectorVersion]); 35 - 36 - const [autoselectedEmoji, autoselectedURL] = useMemo(() => { 37 - if (!autoselector) return []; 38 - const favicon = autoselector.selectFavicon(url); 39 - const emoji = getEmojiFromFavicon(favicon); 40 - const faviconURL = createFaviconURLFromChar(emoji?.emoji || ''); 41 - return [emoji, faviconURL]; 42 - }, [autoselector, url]); 43 - 44 - useEffect(function updateCurrTab() { 45 - async function setup() { 46 - const [activeTab] = await browserAPI.tabs.query(queryOptions); 47 - setCurrTab(activeTab); 48 - } 49 - browserAPI.storage.onChanged.addListener(setup); 50 - browserAPI.tabs.onUpdated.addListener(setup); 51 - setup().catch(console.error); 52 - (() => { 53 - browserAPI.storage.onChanged.removeListener(setup); 54 - browserAPI.tabs.onUpdated.removeListener(setup); 55 - }); 56 - }, [cache]); 57 - 58 - const updateSiteList = useCallback((shouldOverride: boolean) => { 59 - if (!url) return; 60 - const { origin } = new URL(url); 61 - const siteList = (cache?.siteList || []) 62 - .filter(({ matcher }) => matcher !== origin); // Remove dupes 15 + const settings = useBrowserStorage<Settings>(SETTINGS_KEY, DEFAULT_SETTINGS); 16 + const { favIconUrl = '', url = '' } = useActiveTab() || {}; 17 + const { selectedFavicon, selectedFaviconURL } = useSelectedFavicon( 18 + url, 19 + settings.cache, 20 + ); 63 21 64 - if (shouldOverride && autoselectedEmoji) { 65 - siteList.push(createFaviconDataFromEmoji(origin, autoselectedEmoji)); 66 - } 22 + const { status, save } = useStatus( 23 + settings.error || '', 24 + useCallback(function updateSiteList(shouldAddToSiteList: boolean) { 25 + if (!url) return; 26 + const { origin } = new URL(url); 27 + const siteList = (settings.cache?.siteList || []) 28 + .filter(({ matcher }) => matcher !== origin); 67 29 68 - setCache({ siteList }, true); 69 - }, [url, cache, setCache]); 30 + if (shouldAddToSiteList && selectedFavicon) { 31 + siteList.push({ ...selectedFavicon, matcher: origin }); 32 + } 70 33 71 - const { status, save } = useStatus(error || '', updateSiteList); 72 - const addToOverrides = useCallback(() => save(true), [save]); 73 - const removeFromOverrides = useCallback(() => save(false), [save]); 74 - const goToOptions = useCallback(() => { 75 - browserAPI.runtime.openOptionsPage(); 76 - }, []); 77 - 78 - if (loading) return <div>loading...</div>; 34 + settings.setCache({ siteList }, true); 35 + }, [url, settings]), 36 + ); 79 37 80 38 return ( 81 39 <div className='popup-wrapper'> ··· 90 48 /> 91 49 </div> 92 50 <div> 93 - Autofill Favicon: 51 + Favioli Favicon: 94 52 <img 95 53 className='favicon-icon' 96 - src={autoselectedURL || ''} 54 + src={selectedFaviconURL || ''} 97 55 width={20} 98 56 height={20} 99 57 /> 100 58 </div> 101 59 </div> 102 60 <div style='padding-top: 10px; text-align: center;'> 103 - Is Autofilled?{' '} 61 + Is a Favioli Favicon?{' '} 104 62 <span style='font-weight: bold;'> 105 - {autoselectedURL === favIconUrl ? 'Yes!' : 'No!'} 63 + {selectedFaviconURL === favIconUrl ? 'Yes!' : 'No!'} 106 64 </span> 107 65 </div> 108 - <button onClick={addToOverrides}>Override Favicon</button> 109 - <button onClick={removeFromOverrides}>Remove Override</button> 110 - <button onClick={goToOptions}>Options</button> 66 + <button onClick={useCallback(() => save(true), [save])}> 67 + Override Favicon 68 + </button> 69 + <button onClick={useCallback(() => save(false), [save])}> 70 + Remove Override 71 + </button> 72 + <button 73 + onClick={useCallback(() => browserAPI.runtime.openOptionsPage(), [])} 74 + > 75 + Options 76 + </button> 111 77 <div id='status' style='text-align: center;'>{status}</div> 112 78 </div> 113 79 );
-60
source/utilities/__tests__/autoselector.test.ts
··· 1 - import { assertEquals } from 'asserts'; 2 - import { it } from 'bdd'; 3 - 4 - import { 5 - createFaviconDataFromEmoji, 6 - getEmojiFromFavicon, 7 - } from '../favicon_data.ts'; 8 - import Autoselector, { AUTOSELECTOR_VERSION } from '../autoselector.ts'; 9 - 10 - const { FAVIOLI_LEGACY, UNICODE_12, UNICODE_11, UNICODE_09 } = 11 - AUTOSELECTOR_VERSION; 12 - 13 - it('Should select emoji', () => { 14 - const autoselector = new Autoselector(AUTOSELECTOR_VERSION.UNICODE_12); 15 - const emoji = autoselector.selectFavicon('https://favioli.com'); 16 - assertEquals( 17 - emoji, 18 - createFaviconDataFromEmoji( 19 - 'https://favioli.com', 20 - getEmojiFromFavicon(emoji), 21 - ), 22 - ); 23 - }); 24 - 25 - it('Should select different emojis for different sets', () => { 26 - const legacyEmoji = new Autoselector(FAVIOLI_LEGACY) 27 - .selectFavicon('https://favioli.com'); 28 - 29 - const unicode11Emoji = new Autoselector(UNICODE_11) 30 - .selectFavicon('https://favioli.com'); 31 - 32 - const unicode12Emoji = new Autoselector(UNICODE_12) 33 - .selectFavicon('https://favioli.com'); 34 - 35 - assertEquals(getEmojiFromFavicon(legacyEmoji)?.emoji, '😥'); 36 - assertEquals(getEmojiFromFavicon(unicode12Emoji)?.emoji, '🚜'); 37 - assertEquals(getEmojiFromFavicon(unicode11Emoji)?.emoji, '👄'); 38 - }); 39 - 40 - it('Should default to no flags', () => { 41 - const includingFlags = new Autoselector(UNICODE_09, { includeFlags: true }) 42 - .selectFavicon('http://bpev.me'); 43 - 44 - const withNoFlags = new Autoselector(UNICODE_09) 45 - .selectFavicon('http://bpev.me'); 46 - 47 - assertEquals(getEmojiFromFavicon(includingFlags)?.emoji, '🇬🇲'); 48 - assertEquals(getEmojiFromFavicon(withNoFlags)?.emoji, '🦆'); 49 - }); 50 - 51 - it('Should give the same emoji for the same domain', () => { 52 - const autoselector = new Autoselector(UNICODE_12); 53 - assertEquals( 54 - getEmojiFromFavicon(autoselector.selectFavicon('https://favioli.com')) 55 - ?.emoji, 56 - getEmojiFromFavicon( 57 - autoselector.selectFavicon('http://favioli.com/lala/blah?hehe=hoho'), 58 - )?.emoji, 59 - ); 60 - });
+47
source/utilities/__tests__/favicon_autoselector.test.ts
··· 1 + import { assertEquals, assertStrictEquals } from 'std/asserts'; 2 + import { it } from 'std/bdd'; 3 + 4 + import Autoselector, { AUTOSELECTOR_VERSION } from '../favicon_autoselector.ts'; 5 + 6 + const { FAVIOLI_LEGACY, UNICODE_12, UNICODE_11, UNICODE_09 } = 7 + AUTOSELECTOR_VERSION; 8 + 9 + it('Should select favicon', () => { 10 + const autoselector = new Autoselector(AUTOSELECTOR_VERSION.UNICODE_12); 11 + const favicon = autoselector.selectFavicon('https://favioli.com'); 12 + assertStrictEquals(favicon.emojiId, 'tractor'); 13 + assertStrictEquals(favicon.matcher, 'https://favioli.com'); 14 + }); 15 + 16 + it('Should select different emojis for different sets', () => { 17 + const url = 'https://favioli.com'; 18 + 19 + const legacyFavicon = new Autoselector(FAVIOLI_LEGACY).selectFavicon(url); 20 + assertEquals(legacyFavicon.emojiId, 'sad but relieved face'); 21 + 22 + const unicode11Favicon = new Autoselector(UNICODE_11).selectFavicon(url); 23 + assertEquals(unicode11Favicon.emojiId, 'mouth'); 24 + 25 + const unicode12Favicon = new Autoselector(UNICODE_12).selectFavicon(url); 26 + assertEquals(unicode12Favicon.emojiId, 'tractor'); 27 + }); 28 + 29 + it('Should default to no flags', () => { 30 + const url = 'http://bpev.me'; 31 + 32 + const noFlags = new Autoselector(UNICODE_09).selectFavicon(url); 33 + assertEquals(noFlags.emojiId, 'duck'); 34 + 35 + const hasFlags = new Autoselector(UNICODE_09, { includeFlags: true }) 36 + .selectFavicon(url); 37 + assertEquals(hasFlags.emojiId, 'flag: Gambia'); 38 + }); 39 + 40 + it('Should give the same emoji for the same domain', () => { 41 + const autoselect = new Autoselector(UNICODE_12); 42 + 43 + assertEquals( 44 + autoselect.selectFavicon('https://favioli.com').emojiId, 45 + autoselect.selectFavicon('http://favioli.com/lala/blah?hehe=hoho').emojiId, 46 + ); 47 + });
source/utilities/__tests__/favicon_selector.test.ts

This is a binary file and will not be displayed.

-108
source/utilities/__tests__/fixtures/settings_data.ts
··· 1 - import type { Settings, SettingsV1 } from '../../settings.ts'; 2 - 3 - import * as emoji from 'emoji'; 4 - import { createFaviconDataFromEmoji } from '../../favicon_data.ts'; 5 - 6 - export const v0: SettingsV1 = { 7 - 'flagReplaced': true, 8 - 'overrideAll': false, 9 - 'overrides': [ 10 - { 11 - 'emoji': '😍', 12 - 'filter': 'hello', 13 - }, 14 - { 15 - 'emoji': '😃', 16 - 'filter': 'goodbye', 17 - }, 18 - { 19 - 'emoji': '🤩', 20 - 'filter': 'sweet lahd', 21 - }, 22 - ], 23 - 'skips': [ 24 - 'hahahahh', 25 - ], 26 - }; 27 - 28 - export const v1: SettingsV1 = { 29 - 'flagReplaced': true, 30 - 'overrideAll': false, 31 - 'overrides': [ 32 - { 33 - 'emoji': { 34 - 'colons': ':heart_eyes:', 35 - 'emoticons': [], 36 - 'id': 'heart_eyes', 37 - 'name': 'Smiling Face with Heart-Shaped Eyes', 38 - 'native': '😍', 39 - 'short_names': [ 40 - 'heart_eyes', 41 - ], 42 - 'skin': null, 43 - 'unified': '1f60d', 44 - }, 45 - 'filter': 'hello', 46 - }, 47 - { 48 - 'emoji': { 49 - 'colons': ':smiley:', 50 - 'emoticons': [ 51 - '=)', 52 - '=-)', 53 - ], 54 - 'id': 'smiley', 55 - 'name': 'Smiling Face with Open Mouth', 56 - 'native': '😃', 57 - 'short_names': [ 58 - 'smiley', 59 - ], 60 - 'skin': null, 61 - 'unified': '1f603', 62 - }, 63 - 'filter': 'goodbye', 64 - }, 65 - { 66 - 'emoji': { 67 - 'colons': ':star-struck:', 68 - 'emoticons': [], 69 - 'id': 'star-struck', 70 - 'name': 'Grinning Face with Star Eyes', 71 - 'native': '🤩', 72 - 'short_names': [ 73 - 'star-struck', 74 - 'grinning_face_with_star_eyes', 75 - ], 76 - 'skin': null, 77 - 'unified': '1f929', 78 - }, 79 - 'filter': 'sweet lahd', 80 - }, 81 - ], 82 - 'skips': [ 83 - 'hahahahh', 84 - ], 85 - }; 86 - 87 - export const v2: Settings = { 88 - 'autoselectorVersion': 'FAVIOLI_LEGACY', 89 - 'emojiDatabase': { 90 - 'customEmojis': {}, 91 - 'frequentlyUsed': [], 92 - }, 93 - 'features': { 94 - 'enableAutoselectorIncludeCountryFlags': false, 95 - 'enableFaviconAutofill': true, 96 - 'enableOverrideAll': false, 97 - 'enableSiteIgnore': true, 98 - }, 99 - 'ignoreList': [ 100 - createFaviconDataFromEmoji('hahahahh'), 101 - ], 102 - 'siteList': [ 103 - createFaviconDataFromEmoji('hello', emoji.infoByCode('😍')), 104 - createFaviconDataFromEmoji('goodbye', emoji.infoByCode('😃')), 105 - createFaviconDataFromEmoji('sweet lahd', emoji.infoByCode('🤩')), 106 - ], 107 - 'version': '2.0.0', 108 - };
+38
source/utilities/__tests__/parse_version.ts
··· 1 + import { assertEquals } from 'std/asserts'; 2 + import { describe, it } from 'std/bdd'; 3 + 4 + describe('parseVersion', () => { 5 + it('should parse version with descriptor', () => { 6 + assertEquals(parseVersion('2.0.0-beta-1'), { 7 + major: 2, 8 + minor: 0, 9 + patch: 0, 10 + descriptor: 'beta-1', 11 + }); 12 + }); 13 + 14 + it('should parse version without descriptor', () => { 15 + assertEquals(parseVersion('1.0.3'), { 16 + major: 1, 17 + minor: 0, 18 + patch: 3, 19 + descriptor: '', 20 + }); 21 + }); 22 + 23 + it('should error if no version', () => { 24 + try { 25 + parseVersion(''); 26 + } catch (e) { 27 + assertEquals(e.message, 'No Version Detected'); 28 + } 29 + }); 30 + 31 + it('should error if invalid version', () => { 32 + try { 33 + parseVersion('51234'); 34 + } catch (e) { 35 + assertEquals(e.message, 'Error Parsing Version 51234'); 36 + } 37 + }); 38 + });
+2 -2
source/utilities/__tests__/predicates.test.ts
··· 1 - import { assertStrictEquals } from 'asserts'; 2 - import { it } from 'bdd'; 1 + import { assertStrictEquals } from 'std/asserts'; 2 + import { it } from 'std/bdd'; 3 3 4 4 import { isRegexString } from '../predicates.ts'; 5 5
-53
source/utilities/__tests__/settings.test.ts
··· 1 - import { assertEquals } from 'asserts'; 2 - import { describe, it } from 'bdd'; 3 - 4 - import { isV1Settings, migrateFromV1, parseVersion } from '../settings.ts'; 5 - import { v0, v1, v2 } from './fixtures/settings_data.ts'; 6 - 7 - describe('parseVersion', () => { 8 - it('should parse version with descriptor', () => { 9 - assertEquals(parseVersion('2.0.0-beta-1'), { 10 - major: 2, 11 - minor: 0, 12 - patch: 0, 13 - descriptor: 'beta-1', 14 - }); 15 - }); 16 - 17 - it('should parse version without descriptor', () => { 18 - assertEquals(parseVersion('1.0.3'), { 19 - major: 1, 20 - minor: 0, 21 - patch: 3, 22 - descriptor: '', 23 - }); 24 - }); 25 - 26 - it('should error if no version', () => { 27 - try { 28 - parseVersion(''); 29 - } catch (e) { 30 - assertEquals(e.message, 'No Version Detected'); 31 - } 32 - }); 33 - 34 - it('should error if invalid version', () => { 35 - try { 36 - parseVersion('51234'); 37 - } catch (e) { 38 - assertEquals(e.message, 'Error Parsing Version 51234'); 39 - } 40 - }); 41 - }); 42 - 43 - describe('migrateFromV1', () => { 44 - it('should migrate from v0 to v2', () => { 45 - assertEquals(isV1Settings(v0), true); 46 - assertEquals(migrateFromV1(v0), v2); 47 - }); 48 - 49 - it('should migrate from v1 to v2', () => { 50 - assertEquals(isV1Settings(v1), true); 51 - assertEquals(migrateFromV1(v1), v2); 52 - }); 53 - });
+14 -11
source/utilities/autoselector.ts source/utilities/favicon_autoselector.ts
··· 1 - import * as emoji from 'emoji'; 1 + import type { Favicon } from '../models/favicon.ts'; 2 + import type { Emoji } from '../models/emoji.ts'; 3 + 2 4 import LEGACY_EMOJI_SET from '../config/legacy_autoselect_set.ts'; 3 - import { 4 - createFaviconDataFromEmoji, 5 - FaviconData, 6 - } from '../utilities/favicon_data.ts'; 7 - import { Emoji } from './emoji.ts'; 5 + import { createFavicon } from '../models/favicon.ts'; 6 + import { emoji, emojis } from '../models/emoji.ts'; 8 7 9 8 export const AUTOSELECTOR_VERSION = Object.freeze({ 10 9 UNICODE_12: 'UNICODE_12', ··· 17 16 FAVIOLI_LEGACY: 'FAVIOLI_LEGACY', 18 17 }); 19 18 19 + export type AutoselectorVersion = string; 20 + 21 + const { UNICODE_12, FAVIOLI_LEGACY } = AUTOSELECTOR_VERSION; 22 + 20 23 /** 21 24 * For selecting random favicon from a set. Currently, only select from 22 25 * Emoji set, so it remains more static (read: CUSTOM EMOJIS ARE NOT AUTOGEN'd) ··· 42 45 [versionId: string]: Emoji[]; 43 46 } = {}; 44 47 45 - version = AUTOSELECTOR_VERSION.UNICODE_12; 48 + version: AutoselectorVersion = UNICODE_12; 46 49 includeFlags = false; 47 50 48 51 get favicons() { ··· 51 54 52 55 let next; 53 56 54 - if (this.version === AUTOSELECTOR_VERSION.FAVIOLI_LEGACY) { 57 + if (this.version === FAVIOLI_LEGACY) { 55 58 next = LEGACY_EMOJI_SET.map((str) => emoji.infoByCode(str)); 56 59 } 57 60 ··· 70 73 throw new Error('Invalid Autoselector Version'); 71 74 } 72 75 73 - selectFavicon(url: string): FaviconData { 76 + selectFavicon(url: string): Favicon { 74 77 let hostname = ''; 75 78 try { 76 79 hostname = (new URL(url)).host; ··· 80 83 81 84 const index = Math.abs(sdbm(hostname || url)) % this.favicons.length; 82 85 const emoji = this.favicons[index] || this.favicons[0]; 83 - return createFaviconDataFromEmoji(url, emoji); 86 + return createFavicon(url, emoji); 84 87 } 85 88 } 86 89 ··· 88 91 if (!(unicodeVersion > 0)) { 89 92 throw new Error(`invalid unicode version ${unicodeVersion}`); 90 93 } 91 - return emoji.all() 94 + return emojis 92 95 .filter((emoji: Emoji) => { 93 96 if (!includeFlags && emoji.subgroup.includes('flag')) return false; 94 97 return emoji.unicodeVersion <= unicodeVersion;
+7 -1
source/utilities/create_favicon_url.ts source/utilities/image_helpers.ts
··· 1 1 import { isFirefox } from './predicates.ts'; 2 2 3 3 export const ICON_SIZE = 256; // Larger will causes problems in Google Chrome 4 - export const STORED_IMAGE_SIZE = 50; // Larger exceeds QUOTA_BYTES_PER_ITEM 4 + export const STORED_IMAGE_SIZE = 40; // Larger exceeds QUOTA_BYTES_PER_ITEM 5 5 6 6 const VERTICAL_OFFSET = (isFirefox() ? 20 : 0); // ff is off-center 7 7 ··· 47 47 return canvas.toDataURL('image/png'); 48 48 } 49 49 50 + /** 51 + * Create a favicon image using a png, for custom images. Primarily, this means: 52 + * 1. Build into dataURL string to store in browser.storage.sync 53 + * 2. Resize image, so it will fit in browser.storage.sync (<4kb) 54 + * @reference QUOTA_BYPES_PER_ITEM 55 + */ 50 56 export function createFaviconURLFromImage(url: string): Promise<string> { 51 57 const image = new Image(); 52 58 image.src = url;
-33
source/utilities/emoji.ts
··· 1 - import type { Emoji as BaseEmoji } from 'https://deno.land/x/emoji@0.2.0/types.ts'; 2 - 3 - /** 4 - * Favioli Emoji type supports custom image and animated Emoji. 5 - */ 6 - interface Emoji extends BaseEmoji { 7 - imageURL?: string; 8 - videoURL?: string; 9 - } 10 - 11 - export type { Emoji }; 12 - 13 - export function createCustomEmoji({ description, imageURL, videoURL }: { 14 - description: string; 15 - imageURL?: string; 16 - videoURL?: string; 17 - }): Emoji { 18 - if (!imageURL && !videoURL) throw new Error('No URLs given'); 19 - 20 - return { 21 - emoji: '', 22 - description, 23 - group: 'Custom Emojis', 24 - subgroup: 'custom-emojis', 25 - emojiVersion: 0, 26 - unicodeVersion: 0, 27 - tags: [], 28 - aliases: [description], 29 - skinTones: false, 30 - imageURL, 31 - videoURL, 32 - }; 33 - }
-75
source/utilities/favicon_data.ts
··· 1 - import * as emoji from 'emoji'; 2 - import type { Emoji } from './emoji.ts'; 3 - 4 - export interface EmojiMap { 5 - [name: string]: Emoji; 6 - } 7 - 8 - export interface EmojiGroup { 9 - name: string; 10 - representativeEmoji: string; 11 - emojis: Emoji[]; 12 - } 13 - 14 - export interface EmojiGroups { 15 - [name: string]: EmojiGroup; 16 - } 17 - 18 - export const emojis = emoji.all(); 19 - export const byDescription: EmojiMap = Object.fromEntries( 20 - emojis.map((emoji) => { 21 - return [emoji.description, emoji]; 22 - }), 23 - ); 24 - 25 - export const emojiGroups: EmojiGroups = {}; 26 - emojis.forEach((emoji) => { 27 - if (!emojiGroups[emoji.group]) { 28 - emojiGroups[emoji.group] = { 29 - name: emoji.group, 30 - emojis: [emoji], 31 - representativeEmoji: emoji.emoji, 32 - }; 33 - } else { 34 - emojiGroups[emoji.group].emojis.push(emoji); 35 - } 36 - }); 37 - 38 - emojiGroups['Custom Emojis'] = { 39 - name: 'Custom Emojis', 40 - emojis: [], 41 - representativeEmoji: '*', 42 - }; 43 - 44 - export const emojiGroupsArray = Object.keys(emojiGroups).map((name) => 45 - emojiGroups[name] 46 - ); 47 - 48 - export const DEFAULT_EMOJI = Object.freeze(emoji.infoByCode('😀') as Emoji); 49 - 50 - export function getEmojiFromFavicon( 51 - favicon?: FaviconData, 52 - options?: { customEmojis?: EmojiMap }, 53 - ): Emoji | undefined { 54 - if (!favicon) return; 55 - const customEmoji = options?.customEmojis?.[favicon.id]; 56 - return customEmoji || byDescription[favicon.id]; 57 - } 58 - 59 - export function createFaviconDataFromEmoji( 60 - matcher = '', 61 - emojiInput?: Emoji, 62 - ) { 63 - const id = (emojiInput || DEFAULT_EMOJI).description; 64 - return { id, matcher }; 65 - } 66 - 67 - /** 68 - * Store by ID, and retrieve from storage when we want to use. 69 - * This allows us to save custom emoji image data and access on demand, 70 - * saving us space in chrome storage. 71 - */ 72 - export interface FaviconData { 73 - id: string; // Unique ID representing favicon (emoji.description) 74 - matcher: string; // String (inc RegExp string) representing the url to match 75 - }
+5 -4
source/utilities/favicon_helpers.ts source/utilities/append_favicon_link.ts
··· 1 - import { createFaviconURLFromChar, ICON_SIZE } from './create_favicon_url.ts'; 1 + import type { Emoji } from '../models/emoji.ts'; 2 + 3 + import { createFaviconURLFromChar, ICON_SIZE } from './image_helpers.ts'; 2 4 import { isIconLink } from './predicates.ts'; 3 - import { Emoji } from './emoji.ts'; 4 5 5 6 const head = document.getElementsByTagName('head')[0]; 6 7 let appendedFavicon: HTMLElement | null = null; ··· 10 11 } 11 12 12 13 // Given an emoji string, append it to the document head 13 - export async function appendFaviconLink( 14 + export default async function appendFaviconLink( 14 15 emoji: Emoji, 15 16 options?: Options | void, 16 17 ) { ··· 40 41 .filter(isIconLink); 41 42 } 42 43 43 - export async function doesSiteHaveFavicon() { 44 + async function doesSiteHaveFavicon() { 44 45 const iconLinkFound = getAllIconLinks() 45 46 .concat(createLink('/favicon.ico')) // Browsers fallback to favicon.ico 46 47 .map(async ({ href }: HTMLLinkElement) => {
+34
source/utilities/favicon_selector.ts
··· 1 + import type { Favicon } from '../models/favicon.ts'; 2 + import type { Settings } from '../models/settings.ts'; 3 + import type Autoselector from './favicon_autoselector.ts'; 4 + 5 + /** 6 + * Override Priority 7 + * 8 + * 1. Ignore list (always ignore if ignore list enabled) 9 + * 2. Site list (if matched in site list, user manually added) 10 + * 3. Autofill (if autofill is enabled) 11 + * 4. Ignore (autofill NOT enabled, user hasn't added to sitelist) 12 + */ 13 + export default function selectFavicon( 14 + url: string, 15 + settings: Settings, 16 + autoselector?: Autoselector, 17 + ): [Favicon | void, boolean] { 18 + const { ignoreList, siteList, features } = settings; 19 + if (features.enableSiteIgnore && ignoreList.some(listItemMatchesURL(url))) { 20 + return [undefined, false]; 21 + } 22 + 23 + const firstMatchingFavicon = siteList.filter(listItemMatchesURL(url))?.[0]; 24 + if (firstMatchingFavicon) return [firstMatchingFavicon, true]; 25 + 26 + return autoselector && features.enableFaviconAutofill 27 + ? [autoselector.selectFavicon(url), Boolean(features.enableOverrideAll)] 28 + : [undefined, false]; 29 + } 30 + 31 + function listItemMatchesURL(url: string): (favicon: Favicon) => boolean { 32 + return (favicon: Favicon): boolean => 33 + favicon?.matcher ? new RegExp(favicon.matcher || '^$').test(url) : false; 34 + }
+21
source/utilities/parse_version.ts
··· 1 + export default function parseVersion(version: string): { 2 + major: number; 3 + minor: number; 4 + patch: number; 5 + descriptor: string; 6 + } { 7 + if (!version) throw new Error('No Version Detected'); 8 + 9 + const [major, minor, patch, ...descriptors] = version.split(/\.|-/); 10 + 11 + if (major == null || minor == null || patch == null) { 12 + throw new Error(`Error Parsing Version ${version}`); 13 + } 14 + 15 + return { 16 + major: Number(major), 17 + minor: Number(minor), 18 + patch: Number(patch), 19 + descriptor: descriptors.join('-') || '', 20 + }; 21 + }
+13 -13
source/utilities/predicates.ts
··· 1 + const FIREFOX = 'FIREFOX'; 2 + const CHROME = 'CHROME'; 3 + 4 + type Browser = 'FIREFOX' | 'CHROME'; 5 + 1 6 // Checks whether a link is an icon rel 2 7 export function isIconLink(link: HTMLLinkElement): boolean { 3 8 return link.rel.toLowerCase().indexOf('icon') !== -1; ··· 10 15 filter.endsWith('/'); 11 16 } 12 17 18 + export const isChrome = (): boolean => isBrowser(CHROME); 19 + export const isFirefox = (): boolean => isBrowser(FIREFOX); 20 + 13 21 /** 14 22 * What browser is this? 15 23 * @param {string} toCheck to check 16 24 */ 17 - export function isBrowser(toCheck: 'CHROME' | 'FIREFOX'): boolean { 18 - let currentBrowser = 'CHROME'; 25 + function isBrowser(toCheck: Browser): boolean { 26 + let currentBrowser = CHROME; 19 27 try { 20 28 // Use try block, since userAgent not guaranteed to exist. 21 29 // If fail, assume Chromium 22 30 // deno-lint-ignore no-explicit-any 23 31 const userAgent: string = (navigator as any)?.userAgent || ''; 24 32 if (userAgent.indexOf('Firefox') > 0) { 25 - currentBrowser = 'FIREFOX'; 33 + currentBrowser = FIREFOX; 26 34 } 27 35 } catch (_) { 28 36 // Do nothing 29 37 } 30 38 31 39 if (!toCheck) currentBrowser; 32 - if (toCheck === 'CHROME' && currentBrowser === 'CHROME') return true; 33 - if (toCheck === 'FIREFOX' && currentBrowser === 'FIREFOX') return true; 40 + if (toCheck === CHROME && currentBrowser === CHROME) return true; 41 + if (toCheck === FIREFOX && currentBrowser === FIREFOX) return true; 34 42 return false; 35 43 } 36 - 37 - export function isChrome(): boolean { 38 - return isBrowser('CHROME'); 39 - } 40 - 41 - export function isFirefox(): boolean { 42 - return isBrowser('FIREFOX'); 43 - }
-145
source/utilities/settings.ts
··· 1 - import * as emoji from 'emoji'; 2 - import manifest from '../manifest.json' assert { type: 'json' }; 3 - import { createFaviconDataFromEmoji, FaviconData } from './favicon_data.ts'; 4 - import { Emoji } from './emoji.ts'; 5 - 6 - import { AUTOSELECTOR_VERSION } from './autoselector.ts'; 7 - 8 - export interface Settings { 9 - version: string; 10 - autoselectorVersion: string; 11 - 12 - siteList: FaviconData[]; 13 - ignoreList: FaviconData[]; 14 - 15 - emojiDatabase: { 16 - customEmojis: { 17 - [description: string]: Emoji; 18 - }; 19 - frequentlyUsed: Emoji[]; 20 - }; 21 - 22 - features: { 23 - enableAutoselectorIncludeCountryFlags: boolean; 24 - enableFaviconAutofill: boolean; 25 - enableSiteIgnore: boolean; 26 - enableOverrideAll: boolean; 27 - }; 28 - } 29 - 30 - /* Legacy Interfaces */ 31 - export interface SettingsV1 { 32 - flagReplaced: boolean; 33 - overrideAll: boolean; 34 - overrides: EmojiV1[]; 35 - skips: string[]; 36 - } 37 - 38 - export interface EmojiV1 { 39 - emoji: string | EmojiMartEmojiV1; 40 - filter: string; 41 - } 42 - 43 - export type EmojiMartEmojiV1 = { 44 - colons: string; 45 - emoticons: string[]; 46 - id: string; 47 - name: string; 48 - native: string; 49 - short_names: string[]; 50 - skin: null; 51 - unified: string; 52 - }; 53 - 54 - export const DEFAULT_SETTINGS: Settings = { 55 - version: manifest.version, 56 - autoselectorVersion: AUTOSELECTOR_VERSION.UNICODE_12, 57 - 58 - siteList: [], 59 - ignoreList: [], 60 - 61 - emojiDatabase: { 62 - customEmojis: {}, 63 - frequentlyUsed: [], 64 - }, 65 - 66 - features: { 67 - enableAutoselectorIncludeCountryFlags: false, 68 - enableFaviconAutofill: false, 69 - enableSiteIgnore: false, 70 - enableOverrideAll: false, 71 - }, 72 - }; 73 - 74 - export const LEGACY_STORAGE_KEYS = [ 75 - 'flagReplaced', 76 - 'overrideAll', 77 - 'overrides', 78 - 'skips', 79 - ]; 80 - export const STORAGE_KEYS = Object.freeze( 81 - Object.keys(DEFAULT_SETTINGS) 82 - // Legacy settings for migration purposes 83 - .concat(LEGACY_STORAGE_KEYS), 84 - ); 85 - 86 - export function parseVersion(version: string): { 87 - major: number; 88 - minor: number; 89 - patch: number; 90 - descriptor: string; 91 - } { 92 - if (!version) throw new Error('No Version Detected'); 93 - 94 - const [major, minor, patch, ...descriptors] = version.split(/\.|-/); 95 - 96 - if (major == null || minor == null || patch == null) { 97 - throw new Error(`Error Parsing Version ${version}`); 98 - } 99 - 100 - return { 101 - major: Number(major), 102 - minor: Number(minor), 103 - patch: Number(patch), 104 - descriptor: descriptors.join('-') || '', 105 - }; 106 - } 107 - 108 - export function isV1Settings( 109 - settings: Settings | SettingsV1, 110 - ): settings is SettingsV1 { 111 - if ( 112 - 'flagReplaced' in settings || 113 - 'overrideAll' in settings || 114 - 'overrides' in settings || 115 - 'skips' in settings 116 - ) { 117 - return true; 118 - } else return false; 119 - } 120 - 121 - export function migrateFromV1(legacySettings: SettingsV1): Settings { 122 - const settings = { ...DEFAULT_SETTINGS }; 123 - 124 - settings.features = { 125 - enableAutoselectorIncludeCountryFlags: false, 126 - enableFaviconAutofill: true, 127 - enableOverrideAll: legacySettings.overrideAll, 128 - enableSiteIgnore: Boolean(legacySettings?.skips?.length), 129 - }; 130 - 131 - settings.siteList = (legacySettings?.overrides || []) 132 - .map((legacyFavicon): FaviconData => { 133 - const emojiInput = typeof legacyFavicon.emoji === 'string' 134 - ? emoji.infoByCode(legacyFavicon.emoji) 135 - : emoji.infoByCode(legacyFavicon.emoji.native); 136 - return createFaviconDataFromEmoji(legacyFavicon.filter, emojiInput); 137 - }); 138 - 139 - settings.ignoreList = (legacySettings?.skips || []) 140 - .map((skip) => createFaviconDataFromEmoji(skip)); 141 - 142 - settings.autoselectorVersion = AUTOSELECTOR_VERSION.FAVIOLI_LEGACY; 143 - 144 - return settings; 145 - }