Add emoji picker for DMs
This commit is contained in:
parent
3c4507dafa
commit
821d36b116
1
internal/webserver/static/icons/emoji-insert.svg
Normal file
1
internal/webserver/static/icons/emoji-insert.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" class="r-4qtqp9 r-yyyyoo r-dnmrzs r-bnwqim r-lrvibr r-m6rgpd r-z80fyv r-19wmn03" style="color: rgb(29, 155, 240);"><g><path d="M8 9.5C8 8.119 8.672 7 9.5 7S11 8.119 11 9.5 10.328 12 9.5 12 8 10.881 8 9.5zm6.5 2.5c.828 0 1.5-1.119 1.5-2.5S15.328 7 14.5 7 13 8.119 13 9.5s.672 2.5 1.5 2.5zM12 16c-2.224 0-3.021-2.227-3.051-2.316l-1.897.633c.05.15 1.271 3.684 4.949 3.684s4.898-3.533 4.949-3.684l-1.896-.638c-.033.095-.83 2.322-3.053 2.322zm10.25-4.001c0 5.652-4.598 10.25-10.25 10.25S1.75 17.652 1.75 12 6.348 1.75 12 1.75 22.25 6.348 22.25 12zm-2 0c0-4.549-3.701-8.25-8.25-8.25S3.75 7.451 3.75 12s3.701 8.25 8.25 8.25 8.25-3.701 8.25-8.25z"></path></g></svg>
|
After Width: | Height: | Size: 737 B |
1
internal/webserver/static/icons/emoji-react.svg
Normal file
1
internal/webserver/static/icons/emoji-react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" class="r-4qtqp9 r-yyyyoo r-dnmrzs r-bnwqim r-lrvibr r-m6rgpd r-z80fyv r-19wmn03 r-qazpri"><g><path d="M17 12v3h-2.998v2h3v3h2v-3h3v-2h-3.001v-3H17zm-5 6.839c-3.871-2.34-6.053-4.639-7.127-6.609-1.112-2.04-1.031-3.7-.479-4.82.561-1.13 1.667-1.84 2.91-1.91 1.222-.06 2.68.51 3.892 2.16l.806 1.09.805-1.09c1.211-1.65 2.668-2.22 3.89-2.16 1.242.07 2.347.78 2.908 1.91.334.677.49 1.554.321 2.59h2.011c.153-1.283-.039-2.469-.539-3.48-.887-1.79-2.647-2.91-4.601-3.01-1.65-.09-3.367.56-4.796 2.01-1.43-1.45-3.147-2.1-4.798-2.01-1.954.1-3.714 1.22-4.601 3.01-.896 1.81-.846 4.17.514 6.67 1.353 2.48 4.003 5.12 8.382 7.67l.502.299v-2.32z"></path></g></svg>
|
After Width: | Height: | Size: 725 B |
@ -1331,6 +1331,12 @@ main {
|
|||||||
.our-message & {
|
.our-message & {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dm-message__emoji-button-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.dm-message__sender-profile-img {
|
.dm-message__sender-profile-img {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
984
internal/webserver/static/vendor/emoji-picker/database.js
vendored
Normal file
984
internal/webserver/static/vendor/emoji-picker/database.js
vendored
Normal file
@ -0,0 +1,984 @@
|
|||||||
|
function assertNonEmptyString (str) {
|
||||||
|
if (typeof str !== 'string' || !str) {
|
||||||
|
throw new Error('expected a non-empty string, got: ' + str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNumber (number) {
|
||||||
|
if (typeof number !== 'number') {
|
||||||
|
throw new Error('expected a number, got: ' + number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_VERSION_CURRENT = 1;
|
||||||
|
const DB_VERSION_INITIAL = 1;
|
||||||
|
const STORE_EMOJI = 'emoji';
|
||||||
|
const STORE_KEYVALUE = 'keyvalue';
|
||||||
|
const STORE_FAVORITES = 'favorites';
|
||||||
|
const FIELD_TOKENS = 'tokens';
|
||||||
|
const INDEX_TOKENS = 'tokens';
|
||||||
|
const FIELD_UNICODE = 'unicode';
|
||||||
|
const INDEX_COUNT = 'count';
|
||||||
|
const FIELD_GROUP = 'group';
|
||||||
|
const FIELD_ORDER = 'order';
|
||||||
|
const INDEX_GROUP_AND_ORDER = 'group-order';
|
||||||
|
const KEY_ETAG = 'eTag';
|
||||||
|
const KEY_URL = 'url';
|
||||||
|
const KEY_PREFERRED_SKINTONE = 'skinTone';
|
||||||
|
const MODE_READONLY = 'readonly';
|
||||||
|
const MODE_READWRITE = 'readwrite';
|
||||||
|
const INDEX_SKIN_UNICODE = 'skinUnicodes';
|
||||||
|
const FIELD_SKIN_UNICODE = 'skinUnicodes';
|
||||||
|
|
||||||
|
const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
|
||||||
|
const DEFAULT_LOCALE = 'en';
|
||||||
|
|
||||||
|
// like lodash's uniqBy but much smaller
|
||||||
|
function uniqBy (arr, func) {
|
||||||
|
const set = new Set();
|
||||||
|
const res = [];
|
||||||
|
for (const item of arr) {
|
||||||
|
const key = func(item);
|
||||||
|
if (!set.has(key)) {
|
||||||
|
set.add(key);
|
||||||
|
res.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqEmoji (emojis) {
|
||||||
|
return uniqBy(emojis, _ => _.unicode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialMigration (db) {
|
||||||
|
function createObjectStore (name, keyPath, indexes) {
|
||||||
|
const store = keyPath
|
||||||
|
? db.createObjectStore(name, { keyPath })
|
||||||
|
: db.createObjectStore(name);
|
||||||
|
if (indexes) {
|
||||||
|
for (const [indexName, [keyPath, multiEntry]] of Object.entries(indexes)) {
|
||||||
|
store.createIndex(indexName, keyPath, { multiEntry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
createObjectStore(STORE_KEYVALUE);
|
||||||
|
createObjectStore(STORE_EMOJI, /* keyPath */ FIELD_UNICODE, {
|
||||||
|
[INDEX_TOKENS]: [FIELD_TOKENS, /* multiEntry */ true],
|
||||||
|
[INDEX_GROUP_AND_ORDER]: [[FIELD_GROUP, FIELD_ORDER]],
|
||||||
|
[INDEX_SKIN_UNICODE]: [FIELD_SKIN_UNICODE, /* multiEntry */ true]
|
||||||
|
});
|
||||||
|
createObjectStore(STORE_FAVORITES, undefined, {
|
||||||
|
[INDEX_COUNT]: ['']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const openIndexedDBRequests = {};
|
||||||
|
const databaseCache = {};
|
||||||
|
const onCloseListeners = {};
|
||||||
|
|
||||||
|
function handleOpenOrDeleteReq (resolve, reject, req) {
|
||||||
|
// These things are almost impossible to test with fakeIndexedDB sadly
|
||||||
|
/* istanbul ignore next */
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
/* istanbul ignore next */
|
||||||
|
req.onblocked = () => reject(new Error('IDB blocked'));
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDatabase (dbName) {
|
||||||
|
const db = await new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(dbName, DB_VERSION_CURRENT);
|
||||||
|
openIndexedDBRequests[dbName] = req;
|
||||||
|
req.onupgradeneeded = e => {
|
||||||
|
// Technically there is only one version, so we don't need this `if` check
|
||||||
|
// But if an old version of the JS is in another browser tab
|
||||||
|
// and it gets upgraded in the future and we have a new DB version, well...
|
||||||
|
// better safe than sorry.
|
||||||
|
/* istanbul ignore else */
|
||||||
|
if (e.oldVersion < DB_VERSION_INITIAL) {
|
||||||
|
initialMigration(req.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleOpenOrDeleteReq(resolve, reject, req);
|
||||||
|
});
|
||||||
|
// Handle abnormal closes, e.g. "delete database" in chrome dev tools.
|
||||||
|
// No need for removeEventListener, because once the DB can no longer
|
||||||
|
// fire "close" events, it will auto-GC.
|
||||||
|
// Unfortunately cannot test in fakeIndexedDB: https://github.com/dumbmatter/fakeIndexedDB/issues/50
|
||||||
|
/* istanbul ignore next */
|
||||||
|
db.onclose = () => closeDatabase(dbName);
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatabase (dbName) {
|
||||||
|
if (!databaseCache[dbName]) {
|
||||||
|
databaseCache[dbName] = createDatabase(dbName);
|
||||||
|
}
|
||||||
|
return databaseCache[dbName]
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbPromise (db, storeName, readOnlyOrReadWrite, cb) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Use relaxed durability because neither the emoji data nor the favorites/preferred skin tone
|
||||||
|
// are really irreplaceable data. IndexedDB is just a cache in this case.
|
||||||
|
const txn = db.transaction(storeName, readOnlyOrReadWrite, { durability: 'relaxed' });
|
||||||
|
const store = typeof storeName === 'string'
|
||||||
|
? txn.objectStore(storeName)
|
||||||
|
: storeName.map(name => txn.objectStore(name));
|
||||||
|
let res;
|
||||||
|
cb(store, txn, (result) => {
|
||||||
|
res = result;
|
||||||
|
});
|
||||||
|
|
||||||
|
txn.oncomplete = () => resolve(res);
|
||||||
|
/* istanbul ignore next */
|
||||||
|
txn.onerror = () => reject(txn.error);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDatabase (dbName) {
|
||||||
|
// close any open requests
|
||||||
|
const req = openIndexedDBRequests[dbName];
|
||||||
|
const db = req && req.result;
|
||||||
|
if (db) {
|
||||||
|
db.close();
|
||||||
|
const listeners = onCloseListeners[dbName];
|
||||||
|
/* istanbul ignore else */
|
||||||
|
if (listeners) {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete openIndexedDBRequests[dbName];
|
||||||
|
delete databaseCache[dbName];
|
||||||
|
delete onCloseListeners[dbName];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDatabase (dbName) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// close any open requests
|
||||||
|
closeDatabase(dbName);
|
||||||
|
const req = indexedDB.deleteDatabase(dbName);
|
||||||
|
handleOpenOrDeleteReq(resolve, reject, req);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "close" event occurs during an abnormal shutdown, e.g. a user clearing their browser data.
|
||||||
|
// However, it doesn't occur with the normal "close" event, so we handle that separately.
|
||||||
|
// https://www.w3.org/TR/IndexedDB/#close-a-database-connection
|
||||||
|
function addOnCloseListener (dbName, listener) {
|
||||||
|
let listeners = onCloseListeners[dbName];
|
||||||
|
if (!listeners) {
|
||||||
|
listeners = onCloseListeners[dbName] = [];
|
||||||
|
}
|
||||||
|
listeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// list of emoticons that don't match a simple \W+ regex
|
||||||
|
// extracted using:
|
||||||
|
// require('emoji-picker-element-data/en/emojibase/data.json').map(_ => _.emoticon).filter(Boolean).filter(_ => !/^\W+$/.test(_))
|
||||||
|
const irregularEmoticons = new Set([
|
||||||
|
':D', 'XD', ":'D", 'O:)',
|
||||||
|
':X', ':P', ';P', 'XP',
|
||||||
|
':L', ':Z', ':j', '8D',
|
||||||
|
'XO', '8)', ':B', ':O',
|
||||||
|
':S', ":'o", 'Dx', 'X(',
|
||||||
|
'D:', ':C', '>0)', ':3',
|
||||||
|
'</3', '<3', '\\M/', ':E',
|
||||||
|
'8#'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractTokens (str) {
|
||||||
|
return str
|
||||||
|
.split(/[\s_]+/)
|
||||||
|
.map(word => {
|
||||||
|
if (!word.match(/\w/) || irregularEmoticons.has(word)) {
|
||||||
|
// for pure emoticons like :) or :-), just leave them as-is
|
||||||
|
return word.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return word
|
||||||
|
.replace(/[)(:,]/g, '')
|
||||||
|
.replace(/’/g, "'")
|
||||||
|
.toLowerCase()
|
||||||
|
}).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_SEARCH_TEXT_LENGTH = 2;
|
||||||
|
|
||||||
|
// This is an extra step in addition to extractTokens(). The difference here is that we expect
|
||||||
|
// the input to have already been run through extractTokens(). This is useful for cases like
|
||||||
|
// emoticons, where we don't want to do any tokenization (because it makes no sense to split up
|
||||||
|
// ">:)" by the colon) but we do want to lowercase it to have consistent search results, so that
|
||||||
|
// the user can type ':P' or ':p' and still get the same result.
|
||||||
|
function normalizeTokens (str) {
|
||||||
|
return str
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(_ => _.toLowerCase())
|
||||||
|
.filter(_ => _.length >= MIN_SEARCH_TEXT_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform emoji data for storage in IDB
|
||||||
|
function transformEmojiData (emojiData) {
|
||||||
|
const res = emojiData.map(({ annotation, emoticon, group, order, shortcodes, skins, tags, emoji, version }) => {
|
||||||
|
const tokens = [...new Set(
|
||||||
|
normalizeTokens([
|
||||||
|
...(shortcodes || []).map(extractTokens).flat(),
|
||||||
|
...tags.map(extractTokens).flat(),
|
||||||
|
...extractTokens(annotation),
|
||||||
|
emoticon
|
||||||
|
])
|
||||||
|
)].sort();
|
||||||
|
const res = {
|
||||||
|
annotation,
|
||||||
|
group,
|
||||||
|
order,
|
||||||
|
tags,
|
||||||
|
tokens,
|
||||||
|
unicode: emoji,
|
||||||
|
version
|
||||||
|
};
|
||||||
|
if (emoticon) {
|
||||||
|
res.emoticon = emoticon;
|
||||||
|
}
|
||||||
|
if (shortcodes) {
|
||||||
|
res.shortcodes = shortcodes;
|
||||||
|
}
|
||||||
|
if (skins) {
|
||||||
|
res.skinTones = [];
|
||||||
|
res.skinUnicodes = [];
|
||||||
|
res.skinVersions = [];
|
||||||
|
for (const { tone, emoji, version } of skins) {
|
||||||
|
res.skinTones.push(tone);
|
||||||
|
res.skinUnicodes.push(emoji);
|
||||||
|
res.skinVersions.push(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper functions that help compress the code better
|
||||||
|
|
||||||
|
function callStore (store, method, key, cb) {
|
||||||
|
store[method](key).onsuccess = e => (cb && cb(e.target.result));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIDB (store, key, cb) {
|
||||||
|
callStore(store, 'get', key, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllIDB (store, key, cb) {
|
||||||
|
callStore(store, 'getAll', key, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit (txn) {
|
||||||
|
/* istanbul ignore else */
|
||||||
|
if (txn.commit) {
|
||||||
|
txn.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// like lodash's minBy
|
||||||
|
function minBy (array, func) {
|
||||||
|
let minItem = array[0];
|
||||||
|
for (let i = 1; i < array.length; i++) {
|
||||||
|
const item = array[i];
|
||||||
|
if (func(minItem) > func(item)) {
|
||||||
|
minItem = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return minItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// return an array of results representing all items that are found in each one of the arrays
|
||||||
|
//
|
||||||
|
|
||||||
|
function findCommonMembers (arrays, uniqByFunc) {
|
||||||
|
const shortestArray = minBy(arrays, _ => _.length);
|
||||||
|
const results = [];
|
||||||
|
for (const item of shortestArray) {
|
||||||
|
// if this item is included in every array in the intermediate results, add it to the final results
|
||||||
|
if (!arrays.some(array => array.findIndex(_ => uniqByFunc(_) === uniqByFunc(item)) === -1)) {
|
||||||
|
results.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEmpty (db) {
|
||||||
|
return !(await get(db, STORE_KEYVALUE, KEY_URL))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasData (db, url, eTag) {
|
||||||
|
const [oldETag, oldUrl] = await Promise.all([KEY_ETAG, KEY_URL]
|
||||||
|
.map(key => get(db, STORE_KEYVALUE, key)));
|
||||||
|
return (oldETag === eTag && oldUrl === url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFullDatabaseScanForSingleResult (db, predicate) {
|
||||||
|
// This batching algorithm is just a perf improvement over a basic
|
||||||
|
// cursor. The BATCH_SIZE is an estimate of what would give the best
|
||||||
|
// perf for doing a full DB scan (worst case).
|
||||||
|
//
|
||||||
|
// Mini-benchmark for determining the best batch size:
|
||||||
|
//
|
||||||
|
// PERF=1 pnpm build:rollup && pnpm test:adhoc
|
||||||
|
//
|
||||||
|
// (async () => {
|
||||||
|
// performance.mark('start')
|
||||||
|
// await $('emoji-picker').database.getEmojiByShortcode('doesnotexist')
|
||||||
|
// performance.measure('total', 'start')
|
||||||
|
// console.log(performance.getEntriesByName('total').slice(-1)[0].duration)
|
||||||
|
// })()
|
||||||
|
const BATCH_SIZE = 50; // Typically around 150ms for 6x slowdown in Chrome for above benchmark
|
||||||
|
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||||||
|
let lastKey;
|
||||||
|
|
||||||
|
const processNextBatch = () => {
|
||||||
|
emojiStore.getAll(lastKey && IDBKeyRange.lowerBound(lastKey, true), BATCH_SIZE).onsuccess = e => {
|
||||||
|
const results = e.target.result;
|
||||||
|
for (const result of results) {
|
||||||
|
lastKey = result.unicode;
|
||||||
|
if (predicate(result)) {
|
||||||
|
return cb(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.length < BATCH_SIZE) {
|
||||||
|
return cb()
|
||||||
|
}
|
||||||
|
processNextBatch();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
processNextBatch();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData (db, emojiData, url, eTag) {
|
||||||
|
try {
|
||||||
|
const transformedData = transformEmojiData(emojiData);
|
||||||
|
await dbPromise(db, [STORE_EMOJI, STORE_KEYVALUE], MODE_READWRITE, ([emojiStore, metaStore], txn) => {
|
||||||
|
let oldETag;
|
||||||
|
let oldUrl;
|
||||||
|
let todo = 0;
|
||||||
|
|
||||||
|
function checkFetched () {
|
||||||
|
if (++todo === 2) { // 2 requests made
|
||||||
|
onFetched();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFetched () {
|
||||||
|
if (oldETag === eTag && oldUrl === url) {
|
||||||
|
// check again within the transaction to guard against concurrency, e.g. multiple browser tabs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// delete old data
|
||||||
|
emojiStore.clear();
|
||||||
|
// insert new data
|
||||||
|
for (const data of transformedData) {
|
||||||
|
emojiStore.put(data);
|
||||||
|
}
|
||||||
|
metaStore.put(eTag, KEY_ETAG);
|
||||||
|
metaStore.put(url, KEY_URL);
|
||||||
|
commit(txn);
|
||||||
|
}
|
||||||
|
|
||||||
|
getIDB(metaStore, KEY_ETAG, result => {
|
||||||
|
oldETag = result;
|
||||||
|
checkFetched();
|
||||||
|
});
|
||||||
|
|
||||||
|
getIDB(metaStore, KEY_URL, result => {
|
||||||
|
oldUrl = result;
|
||||||
|
checkFetched();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEmojiByGroup (db, group) {
|
||||||
|
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||||||
|
const range = IDBKeyRange.bound([group, 0], [group + 1, 0], false, true);
|
||||||
|
getAllIDB(emojiStore.index(INDEX_GROUP_AND_ORDER), range, cb);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEmojiBySearchQuery (db, query) {
|
||||||
|
const tokens = normalizeTokens(extractTokens(query));
|
||||||
|
|
||||||
|
if (!tokens.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => {
|
||||||
|
// get all results that contain all tokens (i.e. an AND query)
|
||||||
|
const intermediateResults = [];
|
||||||
|
|
||||||
|
const checkDone = () => {
|
||||||
|
if (intermediateResults.length === tokens.length) {
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDone = () => {
|
||||||
|
const results = findCommonMembers(intermediateResults, _ => _.unicode);
|
||||||
|
cb(results.sort((a, b) => a.order < b.order ? -1 : 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
const token = tokens[i];
|
||||||
|
const range = i === tokens.length - 1
|
||||||
|
? IDBKeyRange.bound(token, token + '\uffff', false, true) // treat last token as a prefix search
|
||||||
|
: IDBKeyRange.only(token); // treat all other tokens as an exact match
|
||||||
|
getAllIDB(emojiStore.index(INDEX_TOKENS), range, result => {
|
||||||
|
intermediateResults.push(result);
|
||||||
|
checkDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This could have been implemented as an IDB index on shortcodes, but it seemed wasteful to do that
|
||||||
|
// when we can already query by tokens and this will give us what we're looking for 99.9% of the time
|
||||||
|
async function getEmojiByShortcode (db, shortcode) {
|
||||||
|
const emojis = await getEmojiBySearchQuery(db, shortcode);
|
||||||
|
|
||||||
|
// In very rare cases (e.g. the shortcode "v" as in "v for victory"), we cannot search because
|
||||||
|
// there are no usable tokens (too short in this case). In that case, we have to do an inefficient
|
||||||
|
// full-database scan, which I believe is an acceptable tradeoff for not having to have an extra
|
||||||
|
// index on shortcodes.
|
||||||
|
|
||||||
|
if (!emojis.length) {
|
||||||
|
const predicate = _ => ((_.shortcodes || []).includes(shortcode.toLowerCase()));
|
||||||
|
return (await doFullDatabaseScanForSingleResult(db, predicate)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return emojis.filter(_ => {
|
||||||
|
const lowerShortcodes = (_.shortcodes || []).map(_ => _.toLowerCase());
|
||||||
|
return lowerShortcodes.includes(shortcode.toLowerCase())
|
||||||
|
})[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEmojiByUnicode (db, unicode) {
|
||||||
|
return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => (
|
||||||
|
getIDB(emojiStore, unicode, result => {
|
||||||
|
if (result) {
|
||||||
|
return cb(result)
|
||||||
|
}
|
||||||
|
getIDB(emojiStore.index(INDEX_SKIN_UNICODE), unicode, result => cb(result || null));
|
||||||
|
})
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function get (db, storeName, key) {
|
||||||
|
return dbPromise(db, storeName, MODE_READONLY, (store, txn, cb) => (
|
||||||
|
getIDB(store, key, cb)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function set (db, storeName, key, value) {
|
||||||
|
return dbPromise(db, storeName, MODE_READWRITE, (store, txn) => {
|
||||||
|
store.put(value, key);
|
||||||
|
commit(txn);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementFavoriteEmojiCount (db, unicode) {
|
||||||
|
return dbPromise(db, STORE_FAVORITES, MODE_READWRITE, (store, txn) => (
|
||||||
|
getIDB(store, unicode, result => {
|
||||||
|
store.put((result || 0) + 1, unicode);
|
||||||
|
commit(txn);
|
||||||
|
})
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopFavoriteEmoji (db, customEmojiIndex, limit) {
|
||||||
|
if (limit === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return dbPromise(db, [STORE_FAVORITES, STORE_EMOJI], MODE_READONLY, ([favoritesStore, emojiStore], txn, cb) => {
|
||||||
|
const results = [];
|
||||||
|
favoritesStore.index(INDEX_COUNT).openCursor(undefined, 'prev').onsuccess = e => {
|
||||||
|
const cursor = e.target.result;
|
||||||
|
if (!cursor) { // no more results
|
||||||
|
return cb(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addResult (result) {
|
||||||
|
results.push(result);
|
||||||
|
if (results.length === limit) {
|
||||||
|
return cb(results) // done, reached the limit
|
||||||
|
}
|
||||||
|
cursor.continue();
|
||||||
|
}
|
||||||
|
|
||||||
|
const unicodeOrName = cursor.primaryKey;
|
||||||
|
const custom = customEmojiIndex.byName(unicodeOrName);
|
||||||
|
if (custom) {
|
||||||
|
return addResult(custom)
|
||||||
|
}
|
||||||
|
// This could be done in parallel (i.e. make the cursor and the get()s parallelized),
|
||||||
|
// but my testing suggests it's not actually faster.
|
||||||
|
getIDB(emojiStore, unicodeOrName, emoji => {
|
||||||
|
if (emoji) {
|
||||||
|
return addResult(emoji)
|
||||||
|
}
|
||||||
|
// emoji not found somehow, ignore (may happen if custom emoji change)
|
||||||
|
cursor.continue();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// trie data structure for prefix searches
|
||||||
|
// loosely based on https://github.com/nolanlawson/substring-trie
|
||||||
|
|
||||||
|
const CODA_MARKER = ''; // marks the end of the string
|
||||||
|
|
||||||
|
function trie (arr, itemToTokens) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const item of arr) {
|
||||||
|
const tokens = itemToTokens(item);
|
||||||
|
for (const token of tokens) {
|
||||||
|
let currentMap = map;
|
||||||
|
for (let i = 0; i < token.length; i++) {
|
||||||
|
const char = token.charAt(i);
|
||||||
|
let nextMap = currentMap.get(char);
|
||||||
|
if (!nextMap) {
|
||||||
|
nextMap = new Map();
|
||||||
|
currentMap.set(char, nextMap);
|
||||||
|
}
|
||||||
|
currentMap = nextMap;
|
||||||
|
}
|
||||||
|
let valuesAtCoda = currentMap.get(CODA_MARKER);
|
||||||
|
if (!valuesAtCoda) {
|
||||||
|
valuesAtCoda = [];
|
||||||
|
currentMap.set(CODA_MARKER, valuesAtCoda);
|
||||||
|
}
|
||||||
|
valuesAtCoda.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = (query, exact) => {
|
||||||
|
let currentMap = map;
|
||||||
|
for (let i = 0; i < query.length; i++) {
|
||||||
|
const char = query.charAt(i);
|
||||||
|
const nextMap = currentMap.get(char);
|
||||||
|
if (nextMap) {
|
||||||
|
currentMap = nextMap;
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exact) {
|
||||||
|
const results = currentMap.get(CODA_MARKER);
|
||||||
|
return results || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
// traverse
|
||||||
|
const queue = [currentMap];
|
||||||
|
while (queue.length) {
|
||||||
|
const currentMap = queue.shift();
|
||||||
|
const entriesSortedByKey = [...currentMap.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1);
|
||||||
|
for (const [key, value] of entriesSortedByKey) {
|
||||||
|
if (key === CODA_MARKER) { // CODA_MARKER always comes first; it's the empty string
|
||||||
|
results.push(...value);
|
||||||
|
} else {
|
||||||
|
queue.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
};
|
||||||
|
|
||||||
|
return search
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredKeys$1 = [
|
||||||
|
'name',
|
||||||
|
'url'
|
||||||
|
];
|
||||||
|
|
||||||
|
function assertCustomEmojis (customEmojis) {
|
||||||
|
const isArray = customEmojis && Array.isArray(customEmojis);
|
||||||
|
const firstItemIsFaulty = isArray &&
|
||||||
|
customEmojis.length &&
|
||||||
|
(!customEmojis[0] || requiredKeys$1.some(key => !(key in customEmojis[0])));
|
||||||
|
if (!isArray || firstItemIsFaulty) {
|
||||||
|
throw new Error('Custom emojis are in the wrong format')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function customEmojiIndex (customEmojis) {
|
||||||
|
assertCustomEmojis(customEmojis);
|
||||||
|
|
||||||
|
const sortByName = (a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||||
|
|
||||||
|
//
|
||||||
|
// all()
|
||||||
|
//
|
||||||
|
const all = customEmojis.sort(sortByName);
|
||||||
|
|
||||||
|
//
|
||||||
|
// search()
|
||||||
|
//
|
||||||
|
const emojiToTokens = emoji => (
|
||||||
|
[...new Set((emoji.shortcodes || []).map(shortcode => extractTokens(shortcode)).flat())]
|
||||||
|
);
|
||||||
|
const searchTrie = trie(customEmojis, emojiToTokens);
|
||||||
|
const searchByExactMatch = _ => searchTrie(_, true);
|
||||||
|
const searchByPrefix = _ => searchTrie(_, false);
|
||||||
|
|
||||||
|
// Search by query for custom emoji. Similar to how we do this in IDB, the last token
|
||||||
|
// is treated as a prefix search, but every other one is treated as an exact match.
|
||||||
|
// Then we AND the results together
|
||||||
|
const search = query => {
|
||||||
|
const tokens = extractTokens(query);
|
||||||
|
const intermediateResults = tokens.map((token, i) => (
|
||||||
|
(i < tokens.length - 1 ? searchByExactMatch : searchByPrefix)(token)
|
||||||
|
));
|
||||||
|
return findCommonMembers(intermediateResults, _ => _.name).sort(sortByName)
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// byShortcode, byName
|
||||||
|
//
|
||||||
|
const shortcodeToEmoji = new Map();
|
||||||
|
const nameToEmoji = new Map();
|
||||||
|
for (const customEmoji of customEmojis) {
|
||||||
|
nameToEmoji.set(customEmoji.name.toLowerCase(), customEmoji);
|
||||||
|
for (const shortcode of (customEmoji.shortcodes || [])) {
|
||||||
|
shortcodeToEmoji.set(shortcode.toLowerCase(), customEmoji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byShortcode = shortcode => shortcodeToEmoji.get(shortcode.toLowerCase());
|
||||||
|
const byName = name => nameToEmoji.get(name.toLowerCase());
|
||||||
|
|
||||||
|
return {
|
||||||
|
all,
|
||||||
|
search,
|
||||||
|
byShortcode,
|
||||||
|
byName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirefoxContentScript = typeof wrappedJSObject !== 'undefined';
|
||||||
|
|
||||||
|
// remove some internal implementation details, i.e. the "tokens" array on the emoji object
|
||||||
|
// essentially, convert the emoji from the version stored in IDB to the version used in-memory
|
||||||
|
function cleanEmoji (emoji) {
|
||||||
|
if (!emoji) {
|
||||||
|
return emoji
|
||||||
|
}
|
||||||
|
// if inside a Firefox content script, need to clone the emoji object to prevent Firefox from complaining about
|
||||||
|
// cross-origin object. See: https://github.com/nolanlawson/emoji-picker-element/issues/356
|
||||||
|
/* istanbul ignore if */
|
||||||
|
if (isFirefoxContentScript) {
|
||||||
|
emoji = structuredClone(emoji);
|
||||||
|
}
|
||||||
|
delete emoji.tokens;
|
||||||
|
if (emoji.skinTones) {
|
||||||
|
const len = emoji.skinTones.length;
|
||||||
|
emoji.skins = Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
emoji.skins[i] = {
|
||||||
|
tone: emoji.skinTones[i],
|
||||||
|
unicode: emoji.skinUnicodes[i],
|
||||||
|
version: emoji.skinVersions[i]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
delete emoji.skinTones;
|
||||||
|
delete emoji.skinUnicodes;
|
||||||
|
delete emoji.skinVersions;
|
||||||
|
}
|
||||||
|
return emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
function warnETag (eTag) {
|
||||||
|
if (!eTag) {
|
||||||
|
console.warn('emoji-picker-element is more efficient if the dataSource server exposes an ETag header.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredKeys = [
|
||||||
|
'annotation',
|
||||||
|
'emoji',
|
||||||
|
'group',
|
||||||
|
'order',
|
||||||
|
'tags',
|
||||||
|
'version'
|
||||||
|
];
|
||||||
|
|
||||||
|
function assertEmojiData (emojiData) {
|
||||||
|
if (!emojiData ||
|
||||||
|
!Array.isArray(emojiData) ||
|
||||||
|
!emojiData[0] ||
|
||||||
|
(typeof emojiData[0] !== 'object') ||
|
||||||
|
requiredKeys.some(key => (!(key in emojiData[0])))) {
|
||||||
|
throw new Error('Emoji data is in the wrong format')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStatus (response, dataSource) {
|
||||||
|
if (Math.floor(response.status / 100) !== 2) {
|
||||||
|
throw new Error('Failed to fetch: ' + dataSource + ': ' + response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getETag (dataSource) {
|
||||||
|
const response = await fetch(dataSource, { method: 'HEAD' });
|
||||||
|
assertStatus(response, dataSource);
|
||||||
|
const eTag = response.headers.get('etag');
|
||||||
|
warnETag(eTag);
|
||||||
|
return eTag
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getETagAndData (dataSource) {
|
||||||
|
const response = await fetch(dataSource);
|
||||||
|
assertStatus(response, dataSource);
|
||||||
|
const eTag = response.headers.get('etag');
|
||||||
|
warnETag(eTag);
|
||||||
|
const emojiData = await response.json();
|
||||||
|
assertEmojiData(emojiData);
|
||||||
|
return [eTag, emojiData]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: including these in blob-util.ts causes typedoc to generate docs for them,
|
||||||
|
// even with --excludePrivate ¯\_(ツ)_/¯
|
||||||
|
/** @private */
|
||||||
|
/**
|
||||||
|
* Convert an `ArrayBuffer` to a binary string.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* var myString = blobUtil.arrayBufferToBinaryString(arrayBuff)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param buffer - array buffer
|
||||||
|
* @returns binary string
|
||||||
|
*/
|
||||||
|
function arrayBufferToBinaryString(buffer) {
|
||||||
|
var binary = '';
|
||||||
|
var bytes = new Uint8Array(buffer);
|
||||||
|
var length = bytes.byteLength;
|
||||||
|
var i = -1;
|
||||||
|
while (++i < length) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return binary;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert a binary string to an `ArrayBuffer`.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* var myBuffer = blobUtil.binaryStringToArrayBuffer(binaryString)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param binary - binary string
|
||||||
|
* @returns array buffer
|
||||||
|
*/
|
||||||
|
function binaryStringToArrayBuffer(binary) {
|
||||||
|
var length = binary.length;
|
||||||
|
var buf = new ArrayBuffer(length);
|
||||||
|
var arr = new Uint8Array(buf);
|
||||||
|
var i = -1;
|
||||||
|
while (++i < length) {
|
||||||
|
arr[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate a checksum based on the stringified JSON
|
||||||
|
async function jsonChecksum (object) {
|
||||||
|
const inString = JSON.stringify(object);
|
||||||
|
let inBuffer = binaryStringToArrayBuffer(inString);
|
||||||
|
|
||||||
|
// this does not need to be cryptographically secure, SHA-1 is fine
|
||||||
|
const outBuffer = await crypto.subtle.digest('SHA-1', inBuffer);
|
||||||
|
const outBinString = arrayBufferToBinaryString(outBuffer);
|
||||||
|
const res = btoa(outBinString);
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForUpdates (db, dataSource) {
|
||||||
|
// just do a simple HEAD request first to see if the eTags match
|
||||||
|
let emojiData;
|
||||||
|
let eTag = await getETag(dataSource);
|
||||||
|
if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
|
||||||
|
const eTagAndData = await getETagAndData(dataSource);
|
||||||
|
eTag = eTagAndData[0];
|
||||||
|
emojiData = eTagAndData[1];
|
||||||
|
if (!eTag) {
|
||||||
|
eTag = await jsonChecksum(emojiData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (await hasData(db, dataSource, eTag)) ; else {
|
||||||
|
if (!emojiData) {
|
||||||
|
const eTagAndData = await getETagAndData(dataSource);
|
||||||
|
emojiData = eTagAndData[1];
|
||||||
|
}
|
||||||
|
await loadData(db, emojiData, dataSource, eTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDataForFirstTime (db, dataSource) {
|
||||||
|
let [eTag, emojiData] = await getETagAndData(dataSource);
|
||||||
|
if (!eTag) {
|
||||||
|
// Handle lack of support for ETag or Access-Control-Expose-Headers
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
|
||||||
|
eTag = await jsonChecksum(emojiData);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadData(db, emojiData, dataSource, eTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Database {
|
||||||
|
constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) {
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
this.locale = locale;
|
||||||
|
this._dbName = `emoji-picker-element-${this.locale}`;
|
||||||
|
this._db = undefined;
|
||||||
|
this._lazyUpdate = undefined;
|
||||||
|
this._custom = customEmojiIndex(customEmoji);
|
||||||
|
|
||||||
|
this._clear = this._clear.bind(this);
|
||||||
|
this._ready = this._init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _init () {
|
||||||
|
const db = this._db = await openDatabase(this._dbName);
|
||||||
|
|
||||||
|
addOnCloseListener(this._dbName, this._clear);
|
||||||
|
const dataSource = this.dataSource;
|
||||||
|
const empty = await isEmpty(db);
|
||||||
|
|
||||||
|
if (empty) {
|
||||||
|
await loadDataForFirstTime(db, dataSource);
|
||||||
|
} else { // offline-first - do an update asynchronously
|
||||||
|
this._lazyUpdate = checkForUpdates(db, dataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ready () {
|
||||||
|
const checkReady = async () => {
|
||||||
|
if (!this._ready) {
|
||||||
|
this._ready = this._init();
|
||||||
|
}
|
||||||
|
return this._ready
|
||||||
|
};
|
||||||
|
await checkReady();
|
||||||
|
// There's a possibility of a race condition where the element gets added, removed, and then added again
|
||||||
|
// with a particular timing, which would set the _db to undefined.
|
||||||
|
// We *could* do a while loop here, but that seems excessive and could lead to an infinite loop.
|
||||||
|
if (!this._db) {
|
||||||
|
await checkReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmojiByGroup (group) {
|
||||||
|
assertNumber(group);
|
||||||
|
await this.ready();
|
||||||
|
return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmojiBySearchQuery (query) {
|
||||||
|
assertNonEmptyString(query);
|
||||||
|
await this.ready();
|
||||||
|
const customs = this._custom.search(query);
|
||||||
|
const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(cleanEmoji);
|
||||||
|
return [
|
||||||
|
...customs,
|
||||||
|
...natives
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmojiByShortcode (shortcode) {
|
||||||
|
assertNonEmptyString(shortcode);
|
||||||
|
await this.ready();
|
||||||
|
const custom = this._custom.byShortcode(shortcode);
|
||||||
|
if (custom) {
|
||||||
|
return custom
|
||||||
|
}
|
||||||
|
return cleanEmoji(await getEmojiByShortcode(this._db, shortcode))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmojiByUnicodeOrName (unicodeOrName) {
|
||||||
|
assertNonEmptyString(unicodeOrName);
|
||||||
|
await this.ready();
|
||||||
|
const custom = this._custom.byName(unicodeOrName);
|
||||||
|
if (custom) {
|
||||||
|
return custom
|
||||||
|
}
|
||||||
|
return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPreferredSkinTone () {
|
||||||
|
await this.ready();
|
||||||
|
return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPreferredSkinTone (skinTone) {
|
||||||
|
assertNumber(skinTone);
|
||||||
|
await this.ready();
|
||||||
|
return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone)
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementFavoriteEmojiCount (unicodeOrName) {
|
||||||
|
assertNonEmptyString(unicodeOrName);
|
||||||
|
await this.ready();
|
||||||
|
return incrementFavoriteEmojiCount(this._db, unicodeOrName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopFavoriteEmoji (limit) {
|
||||||
|
assertNumber(limit);
|
||||||
|
await this.ready();
|
||||||
|
return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(cleanEmoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
set customEmoji (customEmojis) {
|
||||||
|
this._custom = customEmojiIndex(customEmojis);
|
||||||
|
}
|
||||||
|
|
||||||
|
get customEmoji () {
|
||||||
|
return this._custom.all
|
||||||
|
}
|
||||||
|
|
||||||
|
async _shutdown () {
|
||||||
|
await this.ready(); // reopen if we've already been closed/deleted
|
||||||
|
try {
|
||||||
|
await this._lazyUpdate; // allow any lazy updates to process before closing/deleting
|
||||||
|
} catch (err) { /* ignore network errors (offline-first) */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear references to IDB, e.g. during a close event
|
||||||
|
_clear () {
|
||||||
|
// We don't need to call removeEventListener or remove the manual "close" listeners.
|
||||||
|
// The memory leak tests prove this is unnecessary. It's because:
|
||||||
|
// 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed
|
||||||
|
// 2) we clear the manual close listeners in databaseLifecycle.js.
|
||||||
|
this._db = this._ready = this._lazyUpdate = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close () {
|
||||||
|
await this._shutdown();
|
||||||
|
await closeDatabase(this._dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete () {
|
||||||
|
await this._shutdown();
|
||||||
|
await deleteDatabase(this._dbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Database as default };
|
1
internal/webserver/static/vendor/emoji-picker/emoji-data.json
vendored
Normal file
1
internal/webserver/static/vendor/emoji-picker/emoji-data.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1667
internal/webserver/static/vendor/emoji-picker/picker.js
vendored
Normal file
1667
internal/webserver/static/vendor/emoji-picker/picker.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
internal/webserver/static/vendor/emoji-picker/readme.txt
vendored
Normal file
12
internal/webserver/static/vendor/emoji-picker/readme.txt
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
To rebuild:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/nolanlawson/emoji-picker-element.git
|
||||||
|
cd emoji-picker-element
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `database.js` and `picker.js` are needed.
|
||||||
|
|
||||||
|
`emoji-data.json` can be found here: https://cdn.jsdelivr.net/npm/emoji-picker-element-data@1.6.1/en/emojibase/
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<div class="messages-page">
|
<div class="messages-page">
|
||||||
|
<script type="module" src="/static/vendor/emoji-picker/picker.js"></script>
|
||||||
{{template "chat-list" .}}
|
{{template "chat-list" .}}
|
||||||
{{template "chat-view" .}}
|
{{template "chat-view" .}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -67,6 +67,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dm-message__emoji-button-container">
|
||||||
|
<div class="dm-message__emoji-button button" onclick="show_emoji_picker(console.log)">
|
||||||
|
<img class="svg-icon" src="/static/icons/emoji-react.svg" width="24" height="24"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dm-message__reactions">
|
<div class="dm-message__reactions">
|
||||||
{{range $message.Reactions}}
|
{{range $message.Reactions}}
|
||||||
@ -155,6 +160,23 @@
|
|||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-on:htmx:after-request="composer.innerText = ''; realInput.value = ''; "
|
hx-on:htmx:after-request="composer.innerText = ''; realInput.value = ''; "
|
||||||
>
|
>
|
||||||
|
<img class="svg-icon button" src="/static/icons/emoji-insert.svg" width="24" height="24" onclick="
|
||||||
|
var carat = composer.innerText.length;
|
||||||
|
if (composer.contains(window.getSelection().anchorNode)) {
|
||||||
|
carat = window.getSelection().anchorOffset;
|
||||||
|
}
|
||||||
|
show_emoji_picker(function(emoji) {
|
||||||
|
composer.innerText = composer.innerText.substring(0, carat) + emoji.unicode + composer.innerText.substring(carat);
|
||||||
|
composer.oninput(); // force-update the `realInput`
|
||||||
|
let range = document.createRange();
|
||||||
|
range.setStart(composer.childNodes[0], carat+emoji.unicode.length);
|
||||||
|
range.setEnd(composer.childNodes[0], carat+emoji.unicode.length);
|
||||||
|
let selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
composer.focus();
|
||||||
|
});
|
||||||
|
"/>
|
||||||
<span id="composer" role="textbox" contenteditable oninput="realInput.value = this.innerText"></span>
|
<span id="composer" role="textbox" contenteditable oninput="realInput.value = this.innerText"></span>
|
||||||
<input id="realInput" type="hidden" name="text" value="" />
|
<input id="realInput" type="hidden" name="text" value="" />
|
||||||
<input type="submit" />
|
<input type="submit" />
|
||||||
@ -187,6 +209,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dialog id="emoji_popup" onmousedown="event.button == 0 && event.target==this && close_emoji_picker()"></dialog>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/**
|
/**
|
||||||
* When new messages are loaded in (via polling), they should be scrolled into view.
|
* When new messages are loaded in (via polling), they should be scrolled into view.
|
||||||
@ -250,6 +274,27 @@
|
|||||||
panel.classList.add("unhidden");
|
panel.classList.add("unhidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the emoji-react button is clicked, show the emoji picker
|
||||||
|
*/
|
||||||
|
function show_emoji_picker(emoji_callback) {
|
||||||
|
const PickerElement = customElements.get("emoji-picker"); // Not copied into the namespace by default
|
||||||
|
const picker = new PickerElement();
|
||||||
|
picker.addEventListener('emoji-click', function(emoji_event) {
|
||||||
|
close_emoji_picker();
|
||||||
|
emoji_callback(emoji_event.detail)
|
||||||
|
});
|
||||||
|
emoji_popup.appendChild(picker);
|
||||||
|
emoji_popup.showModal();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Callback function to close the emoji picker
|
||||||
|
*/
|
||||||
|
function close_emoji_picker() {
|
||||||
|
emoji_popup.close();
|
||||||
|
emoji_popup.innerHTML = ""; // remove the picker
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user