Merge pull request #3593 from glitch-soc/glitch-soc/merge-4.6
Merge upstream changes up to 35fce54cc2 into stable-4.6
This commit is contained in:
+3
-3
@@ -428,7 +428,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.25.1)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
@@ -672,8 +672,8 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
|
||||
@@ -6,6 +6,8 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||
before_action :destroy_previous_subscriptions, only: :create, if: :prior_subscriptions?
|
||||
after_action :update_session_with_subscription, only: :create
|
||||
|
||||
skip_forgery_protection only: :destroy
|
||||
|
||||
def create
|
||||
@push_subscription = ::Web::PushSubscription.create!(web_push_subscription_params)
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ const fetchComposeSuggestionsTags = throttle((dispatch, token) => {
|
||||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
export function fetchComposeSuggestions(token) {
|
||||
return (dispatch, getState) => {
|
||||
return (dispatch) => {
|
||||
switch (token[0]) {
|
||||
case ':':
|
||||
void fetchComposeSuggestionsEmojis(dispatch, token);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { textAtCursorMatchesToken } from './utils';
|
||||
|
||||
describe('textAtCursorMatchesToken', () => {
|
||||
test.concurrent.for([
|
||||
[
|
||||
['#hashtag', 7, ['#']],
|
||||
[1, '#hashtag'],
|
||||
],
|
||||
[
|
||||
['#hash tag', 8, ['#']],
|
||||
[1, '#hash tag'],
|
||||
],
|
||||
[
|
||||
[':+1', 2, [':']],
|
||||
[1, ':+1'],
|
||||
],
|
||||
[
|
||||
[':-1', 2, [':']],
|
||||
[1, ':-1'],
|
||||
],
|
||||
[
|
||||
['#ハッシュタグ', 6, ['#']],
|
||||
[1, '#ハッシュタグ'],
|
||||
],
|
||||
[
|
||||
['#ハッシュ タグ', 7, ['#']],
|
||||
[1, '#ハッシュ タグ'],
|
||||
],
|
||||
] as const)(
|
||||
'textAtCursorMatchesToken(%s) is %o',
|
||||
([input, expected], { expect }) => {
|
||||
expect(
|
||||
textAtCursorMatchesToken(input[0], input[1], Array.from(input[2])),
|
||||
).toStrictEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { WORD } from '../../utils/hashtags';
|
||||
|
||||
export const textAtCursorMatchesToken = (
|
||||
str: string,
|
||||
caretPosition: number,
|
||||
searchTokens: string[],
|
||||
) => {
|
||||
let word: string;
|
||||
|
||||
const regex = new RegExp(
|
||||
`[${searchTokens.join('')}${WORD}+-]+(\\s[${WORD}]+)?$`,
|
||||
'iu',
|
||||
);
|
||||
const left = str.slice(0, caretPosition).search(regex);
|
||||
const right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
word = word.trim();
|
||||
|
||||
if (word.length < 3 || (word[0] && !searchTokens.includes(word[0]))) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
@@ -12,31 +12,7 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosugg
|
||||
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||
import { LocalCustomEmojiProvider } from './emoji/context';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
let right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
import { textAtCursorMatchesToken } from './autosuggest/utils';
|
||||
|
||||
export default class AutosuggestInput extends ImmutablePureComponent {
|
||||
|
||||
|
||||
@@ -13,31 +13,7 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosugg
|
||||
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||
import { LocalCustomEmojiProvider } from './emoji/context';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
let right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 3 || ['@', '@', ':', '#', '#'].indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
import { textAtCursorMatchesToken } from './autosuggest/utils';
|
||||
|
||||
const AutosuggestTextarea = forwardRef(({
|
||||
value,
|
||||
@@ -64,7 +40,7 @@ const AutosuggestTextarea = forwardRef(({
|
||||
const tokenStartRef = useRef(0);
|
||||
|
||||
const handleChange = useCallback((e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, ['@', '@', ':', '#', '#']);
|
||||
|
||||
if (token !== null && lastTokenRef.current !== token) {
|
||||
tokenStartRef.current = tokenStart;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
search,
|
||||
loadEmojiByHexcode,
|
||||
testClear,
|
||||
testGet,
|
||||
@@ -15,10 +14,11 @@ import {
|
||||
} from './database';
|
||||
|
||||
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
||||
const factory = unicodeEmojiFactory();
|
||||
return {
|
||||
...unicodeEmojiFactory(),
|
||||
tags: ['test', 'emoji'],
|
||||
...factory,
|
||||
...data,
|
||||
tags: data.tags ?? factory.tokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,108 +32,6 @@ describe('emoji database', () => {
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(async () => {
|
||||
await putEmojiData([], 'en');
|
||||
});
|
||||
|
||||
test('test no query tokens', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await expect(search({ query: ' ', locale: 'en' })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test('unicode results', async () => {
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
hexcode: 'unicode_hex',
|
||||
label: 'Party Popper',
|
||||
shortcodes: ['party_popper'],
|
||||
unicode: '🎉',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'unicode_hex',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom results', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [customEmojiFactory({ shortcode: 'party_custom' })],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_custom',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcode results', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await putLegacyShortcodes({
|
||||
test: ['legacy_smile'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'legacy', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('full custom emoji search', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'arrow' }),
|
||||
customEmojiFactory({ shortcode: 'party_parrot' }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await search({ query: 'arro', locale: 'en' });
|
||||
expect(result).toContainEqual(
|
||||
// Test for ordinary IDB search
|
||||
expect.objectContaining({
|
||||
shortcode: 'arrow',
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
// Test for manual iteration search
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_parrot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('limit test', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'limit' }),
|
||||
customEmojiFactory({ shortcode: 'limit_extra' }),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'limit', locale: 'en', limit: 1 }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
shortcode: 'limit',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putEmojiData', () => {
|
||||
test('adds to loaded locales', async () => {
|
||||
const { loadedLocales } = await testGet();
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { Database } from './db-schema';
|
||||
import { importEmojiData } from './loader';
|
||||
import { localeToSegmenter, toSupportedLocale } from './locale';
|
||||
import {
|
||||
extractTokens,
|
||||
skinHexcodeToEmoji,
|
||||
transformCustomEmojiData,
|
||||
transformEmojiData,
|
||||
} from './normalize';
|
||||
import type { AnyEmojiData, CacheKey, CustomEmojiData } from './types';
|
||||
import type { CacheKey } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const loadedLocales = new Set<Locale>();
|
||||
@@ -43,223 +42,22 @@ const loadDB = (() => {
|
||||
return loadPromise;
|
||||
})();
|
||||
|
||||
type ScoreMap = Map<string, AnyEmojiData & { score: number }>;
|
||||
|
||||
export async function search({
|
||||
query,
|
||||
locale: localeString,
|
||||
limit = 0,
|
||||
}: {
|
||||
query: string;
|
||||
locale: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
performance.mark('emoji-search-start');
|
||||
|
||||
// Get the locale, and extract tokens from the query.
|
||||
const locale = await toLoadedLocale(localeString);
|
||||
const segmenter = localeToSegmenter(locale);
|
||||
const queryTokens = extractTokens(query, segmenter);
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
log('no tokens extracted from query "%s"', query);
|
||||
return [];
|
||||
}
|
||||
const lastToken = queryTokens.at(-1);
|
||||
if (!lastToken) {
|
||||
throw new Error('Missing tokens from query');
|
||||
}
|
||||
|
||||
log('searching for tokens %o in locale %s', queryTokens, locale);
|
||||
|
||||
// Create an array of emoji results
|
||||
export async function rawSearch(query: string, locale: Locale, prefix = true) {
|
||||
await toLoadedLocale(locale);
|
||||
const db = await loadDB();
|
||||
const resultArrays: ScoreMap[] = [];
|
||||
const existingCustomShortcodes = new Set<string>();
|
||||
|
||||
for (let i = 0; i < queryTokens.length; i++) {
|
||||
const token = queryTokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
// Only query the range for the last token to allow partial matches.
|
||||
const range =
|
||||
i === queryTokens.length - 1
|
||||
? IDBKeyRange.lowerBound(token)
|
||||
: IDBKeyRange.only(token);
|
||||
|
||||
const [unicodeResults, customResults, shortcodeResults] = await Promise.all(
|
||||
[
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
db.getAllFromIndex('shortcodes', 'shortcodes', range),
|
||||
],
|
||||
);
|
||||
const resultMap: ScoreMap = new Map();
|
||||
|
||||
for (const emoji of unicodeResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
resultMap.set(emoji.hexcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
for (const emoji of customResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
existingCustomShortcodes.add(emoji.shortcode);
|
||||
resultMap.set(emoji.shortcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
for (const shortcodeResult of shortcodeResults) {
|
||||
if (resultMap.has(shortcodeResult.hexcode)) {
|
||||
continue;
|
||||
}
|
||||
const emoji = await db.get(locale, shortcodeResult.hexcode);
|
||||
if (!emoji) {
|
||||
continue;
|
||||
}
|
||||
// Score the emoji with the legacy shortcode, even though it's not part of the emoji.
|
||||
const score = getScoreForEmoji(
|
||||
{
|
||||
...emoji,
|
||||
shortcodes: [...shortcodeResult.shortcodes, ...emoji.shortcodes],
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
resultMap.set(emoji.hexcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
log('found %d results for token "%s"', resultMap.size, token);
|
||||
resultArrays.push(resultMap);
|
||||
}
|
||||
|
||||
// Utilize maps to find the intersection of all result sets.
|
||||
const results = Array.from(
|
||||
resultArrays
|
||||
.reduce((prev, curr) => {
|
||||
const intersection: ScoreMap = new Map();
|
||||
for (const [code, emoji] of prev) {
|
||||
if (curr.has(code)) {
|
||||
intersection.set(code, emoji);
|
||||
}
|
||||
}
|
||||
return intersection;
|
||||
})
|
||||
.values(),
|
||||
);
|
||||
|
||||
// If there are no results, try a cursor-based custom emoji search instead.
|
||||
if (results.length === 0 || results.length < limit) {
|
||||
const customEmojisFound = await fullCustomSearch(
|
||||
query,
|
||||
existingCustomShortcodes,
|
||||
);
|
||||
if (customEmojisFound.length > 0) {
|
||||
log(
|
||||
'cursor search found %d results for "%s"',
|
||||
customEmojisFound.length,
|
||||
query,
|
||||
);
|
||||
results.push(...customEmojisFound);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score, descending.
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
|
||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||
log(
|
||||
'search for "%s" in locale %s returned %d results and took %dms',
|
||||
query,
|
||||
locale,
|
||||
results.length,
|
||||
time.duration,
|
||||
);
|
||||
if (limit > 0) {
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function getScoreForEmoji(
|
||||
emoji: AnyEmojiData,
|
||||
query: string,
|
||||
checkTokens = true,
|
||||
) {
|
||||
const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label;
|
||||
if (id === query) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let index = 1;
|
||||
const searchTokens = [id];
|
||||
if (checkTokens) {
|
||||
// Check shortcodes before tokens as they are more important.
|
||||
if ('shortcodes' in emoji) {
|
||||
searchTokens.push(...emoji.shortcodes);
|
||||
}
|
||||
searchTokens.push(...emoji.tokens);
|
||||
}
|
||||
for (const token of searchTokens) {
|
||||
const tokenIndex = token.indexOf(query);
|
||||
if (tokenIndex !== -1) {
|
||||
return index + tokenIndex / token.length;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fullCustomSearch(query: string, existing = new Set<string>()) {
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction('custom', 'readonly');
|
||||
const foundEmojis = new Set<string>();
|
||||
|
||||
// First iterate over chunks of 1,000 custom emoji keys and find any matches.
|
||||
const chunkSize = 1_000;
|
||||
let lastKey: string | null = null;
|
||||
let keys: string[] = [];
|
||||
do {
|
||||
const keyRange = lastKey ? IDBKeyRange.lowerBound(lastKey, true) : null;
|
||||
keys = await trx.store.getAllKeys(keyRange, chunkSize);
|
||||
|
||||
if (keys.length === 0) {
|
||||
break;
|
||||
}
|
||||
log('cursor search got batch of %d emojis', keys.length);
|
||||
lastKey = keys.at(-1) ?? null;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!foundEmojis.has(key) && !existing.has(key) && key.includes(query)) {
|
||||
foundEmojis.add(key);
|
||||
}
|
||||
}
|
||||
} while (keys.length === chunkSize);
|
||||
|
||||
// Next get the full emojis for all matches.
|
||||
const emojis = await Promise.all(
|
||||
foundEmojis.keys().map((key) => trx.store.get(key)),
|
||||
);
|
||||
const results: (CustomEmojiData & { score: number })[] = [];
|
||||
for (const emoji of emojis) {
|
||||
if (emoji) {
|
||||
const score = getScoreForEmoji(emoji, query, false);
|
||||
if (score && score > 0) {
|
||||
results.push({
|
||||
score,
|
||||
...emoji,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
const range = prefix
|
||||
? IDBKeyRange.lowerBound(query)
|
||||
: IDBKeyRange.only(query);
|
||||
const [unicodeResults, customResults, shortcodeResults] = await Promise.all([
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
db.getAllFromIndex('shortcodes', 'shortcodes', range),
|
||||
]);
|
||||
return {
|
||||
unicodeResults,
|
||||
customResults,
|
||||
shortcodeResults,
|
||||
};
|
||||
}
|
||||
|
||||
export async function putEmojiData(emojis: CompactEmoji[], locale: Locale) {
|
||||
@@ -369,6 +167,9 @@ export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||
}
|
||||
|
||||
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||
if (shortcodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const db = await loadDB();
|
||||
const sortedCodes = shortcodes.toSorted();
|
||||
const results = await db.getAll(
|
||||
@@ -378,6 +179,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
|
||||
}
|
||||
|
||||
export async function loadCustomEmojiKeys(
|
||||
query?: string | null,
|
||||
chunkSize = 1_000,
|
||||
) {
|
||||
const db = await loadDB();
|
||||
const keyRange = query ? IDBKeyRange.lowerBound(query, true) : null;
|
||||
return db.getAllKeys('custom', keyRange, chunkSize);
|
||||
}
|
||||
|
||||
export async function loadAllCustomEmoji() {
|
||||
const db = await loadDB();
|
||||
const cacheValue = await db.get('etags', 'custom');
|
||||
|
||||
@@ -64,7 +64,7 @@ type Transaction<Mode extends IDBTransactionMode = 'versionchange'> =
|
||||
export type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const DATABASE_NAME = 'mastodon-emoji';
|
||||
const SCHEMA_VERSION = 4;
|
||||
const SCHEMA_VERSION = 5;
|
||||
|
||||
export async function openEmojiDB() {
|
||||
try {
|
||||
|
||||
@@ -60,7 +60,7 @@ export function transformEmojiData(
|
||||
...extract(label),
|
||||
...(normalizedEmoticons ?? []),
|
||||
]),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
];
|
||||
|
||||
const res: UnicodeEmojiData = {
|
||||
tokens,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/flavours/glitch/store/typed_functions';
|
||||
import { createLimitedCache } from '@/flavours/glitch/utils/cache';
|
||||
|
||||
import { search } from './search';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const log = emojiLogger('picker');
|
||||
@@ -37,7 +38,6 @@ export async function emojiMartSearch(
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const { search } = await import('./database');
|
||||
const results = await search({ query, locale, limit });
|
||||
const legacyResults = results.map((emoji) =>
|
||||
'shortcode' in emoji
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { CompactEmoji } from 'emojibase';
|
||||
|
||||
import { unicodeEmojiFactory, customEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
putCustomEmojiData,
|
||||
putLegacyShortcodes,
|
||||
testGet,
|
||||
testClear,
|
||||
} from './database';
|
||||
import { search } from './search';
|
||||
|
||||
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
||||
const factory = unicodeEmojiFactory();
|
||||
return {
|
||||
...factory,
|
||||
...data,
|
||||
tags: data.tags ?? factory.tokens,
|
||||
};
|
||||
}
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(async () => {
|
||||
await testGet(); // Loads the database schema.
|
||||
await putEmojiData([], 'en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testClear();
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
test('test no query tokens', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await expect(search({ query: ' ', locale: 'en' })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test('unicode results', async () => {
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
hexcode: 'unicode_hex',
|
||||
label: 'Party Popper',
|
||||
shortcodes: ['party_popper'],
|
||||
unicode: '🎉',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'unicode_hex',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom results', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [customEmojiFactory({ shortcode: 'party_custom' })],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_custom',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcode results', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await putLegacyShortcodes({
|
||||
test: ['legacy_smile'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'legacy', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('full custom emoji search', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'arrow' }),
|
||||
customEmojiFactory({ shortcode: 'party_parrot' }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await search({ query: 'arro', locale: 'en' });
|
||||
expect(result).toContainEqual(
|
||||
// Test for ordinary IDB search
|
||||
expect.objectContaining({
|
||||
shortcode: 'arrow',
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
// Test for manual iteration search
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_parrot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('limit test', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'limit' }),
|
||||
customEmojiFactory({ shortcode: 'limit_extra' }),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'limit', locale: 'en', limit: 1 }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
shortcode: 'limit',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('prefix matches', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'sob_other' }),
|
||||
customEmojiFactory({ shortcode: 'meow_sob' }),
|
||||
],
|
||||
});
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
label: 'loudly crying face',
|
||||
hexcode: '1F62D',
|
||||
shortcodes: ['loudly_crying_face'],
|
||||
tags: ['bawling', 'cry', 'sad', 'sob', 'tear', 'tears', 'unhappy'],
|
||||
emoticon: ":'o",
|
||||
unicode: '😭',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
|
||||
const results = await search({ query: 'sob', locale: 'en' });
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ shortcode: 'sob_other' }),
|
||||
expect.objectContaining({ shortcode: 'meow_sob' }),
|
||||
expect.objectContaining({ hexcode: '1F62D' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('shortcode matches', async () => {
|
||||
await putLegacyShortcodes({ '1F62D': 'sob' });
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
label: 'loudly crying face',
|
||||
hexcode: '1F62D',
|
||||
shortcodes: ['loudly_crying_face'],
|
||||
tags: ['bawling', 'cry', 'sad', 'sob', 'tear', 'tears', 'unhappy'],
|
||||
emoticon: ":'o",
|
||||
unicode: '😭',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
await putCustomEmojiData({
|
||||
emojis: [customEmojiFactory({ shortcode: 'sob_other' })],
|
||||
});
|
||||
|
||||
const results = await search({ query: 'sob', locale: 'en' });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ hexcode: '1F62D' }),
|
||||
expect.objectContaining({ shortcode: 'sob_other' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { log } from 'debug';
|
||||
import type { ArrayValues, KeysOfUnion } from 'type-fest';
|
||||
|
||||
import {
|
||||
loadCustomEmojiKeys,
|
||||
loadEmojiByHexcode,
|
||||
rawSearch,
|
||||
searchCustomEmojisByShortcodes,
|
||||
} from './database';
|
||||
import { localeToSegmenter, toSupportedLocale } from './locale';
|
||||
import { extractTokens } from './normalize';
|
||||
import type { AnyEmojiData, CustomEmojiData } from './types';
|
||||
|
||||
/*
|
||||
Emoji search logic:
|
||||
1. When provided a string, extract all tokens and iterate over each.
|
||||
2. For each token, do an IDB lookup to get any matches on emojis and shortcodes.
|
||||
3. Score each emoji found (see below).
|
||||
4. Create a final list of emojis that is the intersection of all token results, with the scores combined.
|
||||
5. If not enough emoji are found, do a cursor search on custom emojis.
|
||||
6. Sort and return the emoji by ranked score, stopping when the optional limit is reached.
|
||||
|
||||
Scoring functions as follows:
|
||||
- Go over every scoreRanking field in order and extract score data.
|
||||
- Scores prefer exact match, then prefix match, and lastly substring match.
|
||||
- When sorting, prefer identifier fields first, then score, then category, and lastly prioritize Unicode emojis.
|
||||
*/
|
||||
|
||||
const scoreRanking = [
|
||||
'label',
|
||||
'shortcode',
|
||||
'emoticons',
|
||||
'shortcodes',
|
||||
'tokens',
|
||||
] as const satisfies KeysOfUnion<AnyEmojiData>[];
|
||||
type ScoreRankingKeys = ArrayValues<typeof scoreRanking>;
|
||||
type ScoreRanking = Record<ScoreRankingKeys, number>;
|
||||
|
||||
const identifierFields = new Set<ScoreRankingKeys>([
|
||||
'label',
|
||||
'shortcode',
|
||||
'emoticons',
|
||||
'shortcodes',
|
||||
]);
|
||||
|
||||
interface BestRank {
|
||||
categoryWeight: number;
|
||||
score: number;
|
||||
fieldWeight: number;
|
||||
}
|
||||
type ScoredEmoji = AnyEmojiData & { scores: ScoreRanking };
|
||||
type RankedEmoji = AnyEmojiData & { rank: BestRank };
|
||||
type ScoreMap = Map<string, ScoredEmoji>;
|
||||
|
||||
export async function search({
|
||||
query: rawQuery,
|
||||
locale: localeString,
|
||||
limit = 0,
|
||||
}: {
|
||||
query: string;
|
||||
locale: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
performance.mark('emoji-search-start');
|
||||
|
||||
// Get the locale, and extract tokens from the query.
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const segmenter = localeToSegmenter(locale);
|
||||
const query = rawQuery.toLowerCase();
|
||||
const queryTokens = extractTokens(query, segmenter);
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
log('no tokens extracted from query "%s"', query);
|
||||
return [];
|
||||
}
|
||||
const lastToken = queryTokens.at(-1);
|
||||
if (!lastToken) {
|
||||
throw new Error('Missing tokens from query');
|
||||
}
|
||||
|
||||
log('searching for tokens %o in locale %s', queryTokens, locale);
|
||||
|
||||
// Create an array of emoji results
|
||||
const resultArrays: ScoreMap[] = [];
|
||||
|
||||
for (let i = 0; i < queryTokens.length; i++) {
|
||||
const token = queryTokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
// Only query the range for the last token to allow partial matches.
|
||||
const { unicodeResults, customResults, shortcodeResults } = await rawSearch(
|
||||
token,
|
||||
locale,
|
||||
i === queryTokens.length - 1,
|
||||
);
|
||||
const resultMap: ScoreMap = new Map();
|
||||
const checkedSet = new Set<string>();
|
||||
|
||||
// Score unicode results.
|
||||
for (const emoji of unicodeResults) {
|
||||
if (checkedSet.has(emoji.hexcode)) {
|
||||
continue;
|
||||
}
|
||||
checkedSet.add(emoji.hexcode);
|
||||
const scores = getScoreForEmoji(emoji, token);
|
||||
if (scores) {
|
||||
resultMap.set(emoji.hexcode, { ...emoji, scores });
|
||||
}
|
||||
}
|
||||
|
||||
// Score custom emojis.
|
||||
for (const emoji of customResults) {
|
||||
if (checkedSet.has(emoji.shortcode)) {
|
||||
continue;
|
||||
}
|
||||
checkedSet.add(emoji.shortcode);
|
||||
const scores = getScoreForEmoji(emoji, token);
|
||||
if (scores) {
|
||||
resultMap.set(emoji.shortcode, { ...emoji, scores });
|
||||
}
|
||||
}
|
||||
|
||||
// Score based on legacy shortcodes, using the higher score if there's a match.
|
||||
for (const shortcodeResult of shortcodeResults) {
|
||||
const emoji =
|
||||
resultMap.get(shortcodeResult.hexcode) ??
|
||||
(await loadEmojiByHexcode(shortcodeResult.hexcode, locale));
|
||||
if (!emoji || !('hexcode' in emoji)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newScores = getScoreForEmoji(
|
||||
{
|
||||
...emoji,
|
||||
shortcodes: shortcodeResult.shortcodes,
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (!newScores) {
|
||||
continue;
|
||||
}
|
||||
const oldScores = resultMap.get(emoji.hexcode)?.scores;
|
||||
const scores = oldScores
|
||||
? combineEmojiScores(oldScores, newScores)
|
||||
: newScores;
|
||||
resultMap.set(emoji.hexcode, {
|
||||
...emoji,
|
||||
shortcodes: [...shortcodeResult.shortcodes, ...emoji.shortcodes],
|
||||
scores,
|
||||
});
|
||||
}
|
||||
|
||||
log('found %d results for token "%s"', resultMap.size, token);
|
||||
resultArrays.push(resultMap);
|
||||
}
|
||||
|
||||
// Iterate over all maps, getting a combined score for emojis that exist in all results.
|
||||
const allEmojiIds = resultArrays.reduce((prev, map) => {
|
||||
if (prev.size === 0) {
|
||||
return new Set(map.keys());
|
||||
}
|
||||
return new Set(map.keys()).intersection(prev);
|
||||
}, new Set<string>());
|
||||
|
||||
const finalMap: ScoreMap = new Map();
|
||||
for (const resultArray of resultArrays) {
|
||||
for (const [id, emoji] of resultArray.entries()) {
|
||||
if (!allEmojiIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
const existingEmoji = finalMap.get(id);
|
||||
if (!existingEmoji) {
|
||||
finalMap.set(id, emoji);
|
||||
} else {
|
||||
finalMap.set(id, {
|
||||
...existingEmoji,
|
||||
scores: combineEmojiScores(existingEmoji.scores, emoji.scores),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const mixedResults = Array.from(finalMap.values());
|
||||
|
||||
// If there are no results, try a cursor-based custom emoji search instead.
|
||||
if (mixedResults.length === 0 || mixedResults.length < limit) {
|
||||
const customEmojisFound = await fullCustomSearch(query, allEmojiIds);
|
||||
if (customEmojisFound.length > 0) {
|
||||
log(
|
||||
'cursor search found %d results for "%s"',
|
||||
customEmojisFound.length,
|
||||
query,
|
||||
);
|
||||
mixedResults.push(...customEmojisFound);
|
||||
}
|
||||
}
|
||||
|
||||
const rankedEmojis = mixedResults.map(({ scores, ...emoji }) => ({
|
||||
...emoji,
|
||||
rank: getBestRank(scores),
|
||||
}));
|
||||
|
||||
const results: RankedEmoji[] = [];
|
||||
const resultEmojis = new Set<string>();
|
||||
for (const result of rankedEmojis.toSorted(compareRankedEmoji)) {
|
||||
const id = getIdentifier(result);
|
||||
if (resultEmojis.has(id)) {
|
||||
continue;
|
||||
}
|
||||
results.push(result);
|
||||
resultEmojis.add(id);
|
||||
if (limit > 0 && results.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||
log(
|
||||
'search for "%s" in locale %s returned %d results and took %dms',
|
||||
query,
|
||||
locale,
|
||||
mixedResults.length,
|
||||
time.duration,
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
function hasField(
|
||||
emoji: AnyEmojiData,
|
||||
field: string,
|
||||
): field is keyof typeof emoji {
|
||||
return Object.hasOwn(emoji, field);
|
||||
}
|
||||
|
||||
function getIdentifier(emoji: AnyEmojiData) {
|
||||
return 'shortcode' in emoji ? emoji.shortcode : emoji.hexcode;
|
||||
}
|
||||
|
||||
// Creates ranked scores for a given emoji.
|
||||
function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
|
||||
const scores = Object.fromEntries(
|
||||
scoreRanking.map((field) => [field, -1]),
|
||||
) as ScoreRanking;
|
||||
let hasScore = false;
|
||||
|
||||
for (const field of scoreRanking) {
|
||||
if (hasField(emoji, field)) {
|
||||
const value = emoji[field] as string | string[] | undefined;
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
const tokens = Array.isArray(value) ? value : [value];
|
||||
const score = getScoreForEmojiTokens(
|
||||
tokens.map((token) => token.toLowerCase()),
|
||||
query,
|
||||
);
|
||||
|
||||
if (score >= 0) {
|
||||
scores[field] = score;
|
||||
hasScore = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasScore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
function combineEmojiScores(a: ScoreRanking, b: ScoreRanking): ScoreRanking {
|
||||
const scores = Object.fromEntries(
|
||||
scoreRanking.map((field) => [field, -1]),
|
||||
) as ScoreRanking;
|
||||
|
||||
for (const rank of scoreRanking) {
|
||||
if (a[rank] === -1) {
|
||||
scores[rank] = b[rank];
|
||||
} else if (b[rank] === -1) {
|
||||
scores[rank] = a[rank];
|
||||
} else {
|
||||
scores[rank] = Math.min(a[rank], b[rank]);
|
||||
}
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
// Compares two scored emojis by getting the best rank.
|
||||
function compareRankedEmoji(a: RankedEmoji, b: RankedEmoji): number {
|
||||
const rankA = a.rank;
|
||||
const rankB = b.rank;
|
||||
|
||||
// Identifier matches always outrank token matches, regardless of score.
|
||||
if (rankA.categoryWeight !== rankB.categoryWeight) {
|
||||
return rankA.categoryWeight - rankB.categoryWeight;
|
||||
}
|
||||
// Within the same category, compare the scores directly.
|
||||
if (rankA.score !== rankB.score) {
|
||||
return rankA.score - rankB.score;
|
||||
}
|
||||
// If equal, compare field weights.
|
||||
if (rankA.fieldWeight !== rankB.fieldWeight) {
|
||||
return rankA.fieldWeight - rankB.fieldWeight;
|
||||
}
|
||||
|
||||
// Lastly prioritize Unicode emojis.
|
||||
const aIsCustom = hasField(a, 'shortcode');
|
||||
const bIsCustom = hasField(b, 'shortcode');
|
||||
if (aIsCustom !== bIsCustom) {
|
||||
return aIsCustom ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Extracts the best rank for a score ranking.
|
||||
function getBestRank(scores: ScoreRanking): BestRank {
|
||||
let best: BestRank | null = null;
|
||||
|
||||
// Use the index to determine field weight.
|
||||
for (const [fieldWeight, field] of scoreRanking.entries()) {
|
||||
const score = scores[field];
|
||||
if (score < 0) {
|
||||
continue;
|
||||
}
|
||||
// Also weight identifier fields over other fields.
|
||||
const categoryWeight = identifierFields.has(field) ? 0 : 1;
|
||||
if (
|
||||
!best ||
|
||||
categoryWeight < best.categoryWeight ||
|
||||
(categoryWeight === best.categoryWeight &&
|
||||
(score < best.score ||
|
||||
(score === best.score && fieldWeight < best.fieldWeight)))
|
||||
) {
|
||||
best = { categoryWeight, score, fieldWeight };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
best ?? {
|
||||
categoryWeight: 2,
|
||||
score: Infinity,
|
||||
fieldWeight: scoreRanking.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getScoreForEmojiTokens(tokens: string[], query: string) {
|
||||
let lowestScore = -1;
|
||||
|
||||
for (const token of tokens) {
|
||||
let score = -1;
|
||||
// Priority: exact match, prefix match, substring,
|
||||
if (token === query) {
|
||||
score = 0;
|
||||
} else if (token.startsWith(query)) {
|
||||
score = 1 + query.length / token.length;
|
||||
} else if (token.includes(query)) {
|
||||
score = 2 + query.length / token.length;
|
||||
} // TODO: Add fuzzy search if needed
|
||||
|
||||
if (score >= 0 && (score < lowestScore || lowestScore < 0)) {
|
||||
lowestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return lowestScore;
|
||||
}
|
||||
|
||||
async function fullCustomSearch(query: string, existing = new Set<string>()) {
|
||||
const foundEmojis = new Set<string>();
|
||||
|
||||
// First iterate over chunks of 1,000 custom emoji keys and find any matches.
|
||||
const chunkSize = 1_000;
|
||||
let lastKey: string | null = null;
|
||||
let keys: string[] = [];
|
||||
do {
|
||||
keys = await loadCustomEmojiKeys(lastKey, chunkSize);
|
||||
|
||||
if (keys.length === 0) {
|
||||
break;
|
||||
}
|
||||
log('cursor search got batch of %d emojis', keys.length);
|
||||
lastKey = keys.at(-1) ?? null;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!foundEmojis.has(key) && !existing.has(key) && key.includes(query)) {
|
||||
foundEmojis.add(key);
|
||||
}
|
||||
}
|
||||
} while (keys.length === chunkSize);
|
||||
|
||||
// Next get the full emojis for all matches.
|
||||
const emojis = await searchCustomEmojisByShortcodes(Array.from(foundEmojis));
|
||||
const results: (CustomEmojiData & { scores: ScoreRanking })[] = [];
|
||||
for (const emoji of emojis) {
|
||||
const scores = getScoreForEmoji(emoji, query);
|
||||
if (scores) {
|
||||
results.push({
|
||||
scores,
|
||||
...emoji,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
+6
-6
@@ -61,18 +61,18 @@ export const NotificationFollowRequest: React.FC<{
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.reject)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onReject}
|
||||
/>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.authorize)}
|
||||
icon='check'
|
||||
iconComponent={CheckIcon}
|
||||
onClick={onAuthorize}
|
||||
/>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.reject)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onReject}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function statusLists(state = initialState, action) {
|
||||
else if (fetchQuotes.pending.match(action))
|
||||
return state.setIn(['quotes', 'isLoading'], true).setIn(['quotes', 'statusId'], action.meta.arg.statusId);
|
||||
else if (fetchQuotes.rejected.match(action))
|
||||
return state.setIn(['quotes', 'isLoading', false]).setIn(['quotes', 'statusId'], action.meta.arg.statusId);
|
||||
return state.setIn(['quotes', 'isLoading'], false).setIn(['quotes', 'statusId'], action.meta.arg.statusId);
|
||||
else
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
|
||||
const ALPHA = '\\p{L}\\p{M}';
|
||||
const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
|
||||
export const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
|
||||
|
||||
const buildHashtagPatternRegex = () => {
|
||||
try {
|
||||
|
||||
@@ -572,7 +572,7 @@ const fetchComposeSuggestionsTags = throttle((dispatch, token) => {
|
||||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
export function fetchComposeSuggestions(token) {
|
||||
return (dispatch, getState) => {
|
||||
return (dispatch) => {
|
||||
switch (token[0]) {
|
||||
case ':':
|
||||
void fetchComposeSuggestionsEmojis(dispatch, token);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { textAtCursorMatchesToken } from './utils';
|
||||
|
||||
describe('textAtCursorMatchesToken', () => {
|
||||
test.concurrent.for([
|
||||
[
|
||||
['#hashtag', 7, ['#']],
|
||||
[1, '#hashtag'],
|
||||
],
|
||||
[
|
||||
['#hash tag', 8, ['#']],
|
||||
[1, '#hash tag'],
|
||||
],
|
||||
[
|
||||
[':+1', 2, [':']],
|
||||
[1, ':+1'],
|
||||
],
|
||||
[
|
||||
[':-1', 2, [':']],
|
||||
[1, ':-1'],
|
||||
],
|
||||
[
|
||||
['#ハッシュタグ', 6, ['#']],
|
||||
[1, '#ハッシュタグ'],
|
||||
],
|
||||
[
|
||||
['#ハッシュ タグ', 7, ['#']],
|
||||
[1, '#ハッシュ タグ'],
|
||||
],
|
||||
] as const)(
|
||||
'textAtCursorMatchesToken(%s) is %o',
|
||||
([input, expected], { expect }) => {
|
||||
expect(
|
||||
textAtCursorMatchesToken(input[0], input[1], Array.from(input[2])),
|
||||
).toStrictEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { WORD } from '../../utils/hashtags';
|
||||
|
||||
export const textAtCursorMatchesToken = (
|
||||
str: string,
|
||||
caretPosition: number,
|
||||
searchTokens: string[],
|
||||
) => {
|
||||
let word: string;
|
||||
|
||||
const regex = new RegExp(
|
||||
`[${searchTokens.join('')}${WORD}+-]+(\\s[${WORD}]+)?$`,
|
||||
'iu',
|
||||
);
|
||||
const left = str.slice(0, caretPosition).search(regex);
|
||||
const right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
word = word.trim();
|
||||
|
||||
if (word.length < 3 || (word[0] && !searchTokens.includes(word[0]))) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
@@ -12,31 +12,7 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosugg
|
||||
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||
import { LocalCustomEmojiProvider } from './emoji/context';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
let right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
import { textAtCursorMatchesToken } from './autosuggest/utils';
|
||||
|
||||
export default class AutosuggestInput extends ImmutablePureComponent {
|
||||
|
||||
|
||||
@@ -13,31 +13,7 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosugg
|
||||
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||
import { LocalCustomEmojiProvider } from './emoji/context';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
let right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 3 || ['@', '@', ':', '#', '#'].indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
import { textAtCursorMatchesToken } from './autosuggest/utils';
|
||||
|
||||
const AutosuggestTextarea = forwardRef(({
|
||||
value,
|
||||
@@ -64,7 +40,7 @@ const AutosuggestTextarea = forwardRef(({
|
||||
const tokenStartRef = useRef(0);
|
||||
|
||||
const handleChange = useCallback((e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, ['@', '@', ':', '#', '#']);
|
||||
|
||||
if (token !== null && lastTokenRef.current !== token) {
|
||||
tokenStartRef.current = tokenStart;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
search,
|
||||
loadEmojiByHexcode,
|
||||
testClear,
|
||||
testGet,
|
||||
@@ -15,10 +14,11 @@ import {
|
||||
} from './database';
|
||||
|
||||
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
||||
const factory = unicodeEmojiFactory();
|
||||
return {
|
||||
...unicodeEmojiFactory(),
|
||||
tags: ['test', 'emoji'],
|
||||
...factory,
|
||||
...data,
|
||||
tags: data.tags ?? factory.tokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,108 +32,6 @@ describe('emoji database', () => {
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(async () => {
|
||||
await putEmojiData([], 'en');
|
||||
});
|
||||
|
||||
test('test no query tokens', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await expect(search({ query: ' ', locale: 'en' })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test('unicode results', async () => {
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
hexcode: 'unicode_hex',
|
||||
label: 'Party Popper',
|
||||
shortcodes: ['party_popper'],
|
||||
unicode: '🎉',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'unicode_hex',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom results', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [customEmojiFactory({ shortcode: 'party_custom' })],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_custom',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcode results', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await putLegacyShortcodes({
|
||||
test: ['legacy_smile'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'legacy', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('full custom emoji search', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'arrow' }),
|
||||
customEmojiFactory({ shortcode: 'party_parrot' }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await search({ query: 'arro', locale: 'en' });
|
||||
expect(result).toContainEqual(
|
||||
// Test for ordinary IDB search
|
||||
expect.objectContaining({
|
||||
shortcode: 'arrow',
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
// Test for manual iteration search
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_parrot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('limit test', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'limit' }),
|
||||
customEmojiFactory({ shortcode: 'limit_extra' }),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'limit', locale: 'en', limit: 1 }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
shortcode: 'limit',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putEmojiData', () => {
|
||||
test('adds to loaded locales', async () => {
|
||||
const { loadedLocales } = await testGet();
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { Database } from './db-schema';
|
||||
import { importEmojiData } from './loader';
|
||||
import { localeToSegmenter, toSupportedLocale } from './locale';
|
||||
import {
|
||||
extractTokens,
|
||||
skinHexcodeToEmoji,
|
||||
transformCustomEmojiData,
|
||||
transformEmojiData,
|
||||
} from './normalize';
|
||||
import type { AnyEmojiData, CacheKey, CustomEmojiData } from './types';
|
||||
import type { CacheKey } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const loadedLocales = new Set<Locale>();
|
||||
@@ -43,223 +42,22 @@ const loadDB = (() => {
|
||||
return loadPromise;
|
||||
})();
|
||||
|
||||
type ScoreMap = Map<string, AnyEmojiData & { score: number }>;
|
||||
|
||||
export async function search({
|
||||
query,
|
||||
locale: localeString,
|
||||
limit = 0,
|
||||
}: {
|
||||
query: string;
|
||||
locale: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
performance.mark('emoji-search-start');
|
||||
|
||||
// Get the locale, and extract tokens from the query.
|
||||
const locale = await toLoadedLocale(localeString);
|
||||
const segmenter = localeToSegmenter(locale);
|
||||
const queryTokens = extractTokens(query, segmenter);
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
log('no tokens extracted from query "%s"', query);
|
||||
return [];
|
||||
}
|
||||
const lastToken = queryTokens.at(-1);
|
||||
if (!lastToken) {
|
||||
throw new Error('Missing tokens from query');
|
||||
}
|
||||
|
||||
log('searching for tokens %o in locale %s', queryTokens, locale);
|
||||
|
||||
// Create an array of emoji results
|
||||
export async function rawSearch(query: string, locale: Locale, prefix = true) {
|
||||
await toLoadedLocale(locale);
|
||||
const db = await loadDB();
|
||||
const resultArrays: ScoreMap[] = [];
|
||||
const existingCustomShortcodes = new Set<string>();
|
||||
|
||||
for (let i = 0; i < queryTokens.length; i++) {
|
||||
const token = queryTokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
// Only query the range for the last token to allow partial matches.
|
||||
const range =
|
||||
i === queryTokens.length - 1
|
||||
? IDBKeyRange.lowerBound(token)
|
||||
: IDBKeyRange.only(token);
|
||||
|
||||
const [unicodeResults, customResults, shortcodeResults] = await Promise.all(
|
||||
[
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
db.getAllFromIndex('shortcodes', 'shortcodes', range),
|
||||
],
|
||||
);
|
||||
const resultMap: ScoreMap = new Map();
|
||||
|
||||
for (const emoji of unicodeResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
resultMap.set(emoji.hexcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
for (const emoji of customResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
existingCustomShortcodes.add(emoji.shortcode);
|
||||
resultMap.set(emoji.shortcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
for (const shortcodeResult of shortcodeResults) {
|
||||
if (resultMap.has(shortcodeResult.hexcode)) {
|
||||
continue;
|
||||
}
|
||||
const emoji = await db.get(locale, shortcodeResult.hexcode);
|
||||
if (!emoji) {
|
||||
continue;
|
||||
}
|
||||
// Score the emoji with the legacy shortcode, even though it's not part of the emoji.
|
||||
const score = getScoreForEmoji(
|
||||
{
|
||||
...emoji,
|
||||
shortcodes: [...shortcodeResult.shortcodes, ...emoji.shortcodes],
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
resultMap.set(emoji.hexcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
log('found %d results for token "%s"', resultMap.size, token);
|
||||
resultArrays.push(resultMap);
|
||||
}
|
||||
|
||||
// Utilize maps to find the intersection of all result sets.
|
||||
const results = Array.from(
|
||||
resultArrays
|
||||
.reduce((prev, curr) => {
|
||||
const intersection: ScoreMap = new Map();
|
||||
for (const [code, emoji] of prev) {
|
||||
if (curr.has(code)) {
|
||||
intersection.set(code, emoji);
|
||||
}
|
||||
}
|
||||
return intersection;
|
||||
})
|
||||
.values(),
|
||||
);
|
||||
|
||||
// If there are no results, try a cursor-based custom emoji search instead.
|
||||
if (results.length === 0 || results.length < limit) {
|
||||
const customEmojisFound = await fullCustomSearch(
|
||||
query,
|
||||
existingCustomShortcodes,
|
||||
);
|
||||
if (customEmojisFound.length > 0) {
|
||||
log(
|
||||
'cursor search found %d results for "%s"',
|
||||
customEmojisFound.length,
|
||||
query,
|
||||
);
|
||||
results.push(...customEmojisFound);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score, descending.
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
|
||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||
log(
|
||||
'search for "%s" in locale %s returned %d results and took %dms',
|
||||
query,
|
||||
locale,
|
||||
results.length,
|
||||
time.duration,
|
||||
);
|
||||
if (limit > 0) {
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function getScoreForEmoji(
|
||||
emoji: AnyEmojiData,
|
||||
query: string,
|
||||
checkTokens = true,
|
||||
) {
|
||||
const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label;
|
||||
if (id === query) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let index = 1;
|
||||
const searchTokens = [id];
|
||||
if (checkTokens) {
|
||||
// Check shortcodes before tokens as they are more important.
|
||||
if ('shortcodes' in emoji) {
|
||||
searchTokens.push(...emoji.shortcodes);
|
||||
}
|
||||
searchTokens.push(...emoji.tokens);
|
||||
}
|
||||
for (const token of searchTokens) {
|
||||
const tokenIndex = token.indexOf(query);
|
||||
if (tokenIndex !== -1) {
|
||||
return index + tokenIndex / token.length;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fullCustomSearch(query: string, existing = new Set<string>()) {
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction('custom', 'readonly');
|
||||
const foundEmojis = new Set<string>();
|
||||
|
||||
// First iterate over chunks of 1,000 custom emoji keys and find any matches.
|
||||
const chunkSize = 1_000;
|
||||
let lastKey: string | null = null;
|
||||
let keys: string[] = [];
|
||||
do {
|
||||
const keyRange = lastKey ? IDBKeyRange.lowerBound(lastKey, true) : null;
|
||||
keys = await trx.store.getAllKeys(keyRange, chunkSize);
|
||||
|
||||
if (keys.length === 0) {
|
||||
break;
|
||||
}
|
||||
log('cursor search got batch of %d emojis', keys.length);
|
||||
lastKey = keys.at(-1) ?? null;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!foundEmojis.has(key) && !existing.has(key) && key.includes(query)) {
|
||||
foundEmojis.add(key);
|
||||
}
|
||||
}
|
||||
} while (keys.length === chunkSize);
|
||||
|
||||
// Next get the full emojis for all matches.
|
||||
const emojis = await Promise.all(
|
||||
foundEmojis.keys().map((key) => trx.store.get(key)),
|
||||
);
|
||||
const results: (CustomEmojiData & { score: number })[] = [];
|
||||
for (const emoji of emojis) {
|
||||
if (emoji) {
|
||||
const score = getScoreForEmoji(emoji, query, false);
|
||||
if (score && score > 0) {
|
||||
results.push({
|
||||
score,
|
||||
...emoji,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
const range = prefix
|
||||
? IDBKeyRange.lowerBound(query)
|
||||
: IDBKeyRange.only(query);
|
||||
const [unicodeResults, customResults, shortcodeResults] = await Promise.all([
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
db.getAllFromIndex('shortcodes', 'shortcodes', range),
|
||||
]);
|
||||
return {
|
||||
unicodeResults,
|
||||
customResults,
|
||||
shortcodeResults,
|
||||
};
|
||||
}
|
||||
|
||||
export async function putEmojiData(emojis: CompactEmoji[], locale: Locale) {
|
||||
@@ -369,6 +167,9 @@ export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||
}
|
||||
|
||||
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||
if (shortcodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const db = await loadDB();
|
||||
const sortedCodes = shortcodes.toSorted();
|
||||
const results = await db.getAll(
|
||||
@@ -378,6 +179,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
|
||||
}
|
||||
|
||||
export async function loadCustomEmojiKeys(
|
||||
query?: string | null,
|
||||
chunkSize = 1_000,
|
||||
) {
|
||||
const db = await loadDB();
|
||||
const keyRange = query ? IDBKeyRange.lowerBound(query, true) : null;
|
||||
return db.getAllKeys('custom', keyRange, chunkSize);
|
||||
}
|
||||
|
||||
export async function loadAllCustomEmoji() {
|
||||
const db = await loadDB();
|
||||
const cacheValue = await db.get('etags', 'custom');
|
||||
|
||||
@@ -64,7 +64,7 @@ type Transaction<Mode extends IDBTransactionMode = 'versionchange'> =
|
||||
export type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const DATABASE_NAME = 'mastodon-emoji';
|
||||
const SCHEMA_VERSION = 4;
|
||||
const SCHEMA_VERSION = 5;
|
||||
|
||||
export async function openEmojiDB() {
|
||||
try {
|
||||
|
||||
@@ -60,7 +60,7 @@ export function transformEmojiData(
|
||||
...extract(label),
|
||||
...(normalizedEmoticons ?? []),
|
||||
]),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
];
|
||||
|
||||
const res: UnicodeEmojiData = {
|
||||
tokens,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/mastodon/store/typed_functions';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
|
||||
import { search } from './search';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const log = emojiLogger('picker');
|
||||
@@ -37,7 +38,6 @@ export async function emojiMartSearch(
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const { search } = await import('./database');
|
||||
const results = await search({ query, locale, limit });
|
||||
const legacyResults = results.map((emoji) =>
|
||||
'shortcode' in emoji
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { CompactEmoji } from 'emojibase';
|
||||
|
||||
import { unicodeEmojiFactory, customEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
putCustomEmojiData,
|
||||
putLegacyShortcodes,
|
||||
testGet,
|
||||
testClear,
|
||||
} from './database';
|
||||
import { search } from './search';
|
||||
|
||||
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
||||
const factory = unicodeEmojiFactory();
|
||||
return {
|
||||
...factory,
|
||||
...data,
|
||||
tags: data.tags ?? factory.tokens,
|
||||
};
|
||||
}
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(async () => {
|
||||
await testGet(); // Loads the database schema.
|
||||
await putEmojiData([], 'en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testClear();
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
test('test no query tokens', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await expect(search({ query: ' ', locale: 'en' })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test('unicode results', async () => {
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
hexcode: 'unicode_hex',
|
||||
label: 'Party Popper',
|
||||
shortcodes: ['party_popper'],
|
||||
unicode: '🎉',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'unicode_hex',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom results', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [customEmojiFactory({ shortcode: 'party_custom' })],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_custom',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcode results', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await putLegacyShortcodes({
|
||||
test: ['legacy_smile'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'legacy', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('full custom emoji search', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'arrow' }),
|
||||
customEmojiFactory({ shortcode: 'party_parrot' }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await search({ query: 'arro', locale: 'en' });
|
||||
expect(result).toContainEqual(
|
||||
// Test for ordinary IDB search
|
||||
expect.objectContaining({
|
||||
shortcode: 'arrow',
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
// Test for manual iteration search
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_parrot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('limit test', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'limit' }),
|
||||
customEmojiFactory({ shortcode: 'limit_extra' }),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'limit', locale: 'en', limit: 1 }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
shortcode: 'limit',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('prefix matches', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'sob_other' }),
|
||||
customEmojiFactory({ shortcode: 'meow_sob' }),
|
||||
],
|
||||
});
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
label: 'loudly crying face',
|
||||
hexcode: '1F62D',
|
||||
shortcodes: ['loudly_crying_face'],
|
||||
tags: ['bawling', 'cry', 'sad', 'sob', 'tear', 'tears', 'unhappy'],
|
||||
emoticon: ":'o",
|
||||
unicode: '😭',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
|
||||
const results = await search({ query: 'sob', locale: 'en' });
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ shortcode: 'sob_other' }),
|
||||
expect.objectContaining({ shortcode: 'meow_sob' }),
|
||||
expect.objectContaining({ hexcode: '1F62D' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('shortcode matches', async () => {
|
||||
await putLegacyShortcodes({ '1F62D': 'sob' });
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
label: 'loudly crying face',
|
||||
hexcode: '1F62D',
|
||||
shortcodes: ['loudly_crying_face'],
|
||||
tags: ['bawling', 'cry', 'sad', 'sob', 'tear', 'tears', 'unhappy'],
|
||||
emoticon: ":'o",
|
||||
unicode: '😭',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
await putCustomEmojiData({
|
||||
emojis: [customEmojiFactory({ shortcode: 'sob_other' })],
|
||||
});
|
||||
|
||||
const results = await search({ query: 'sob', locale: 'en' });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ hexcode: '1F62D' }),
|
||||
expect.objectContaining({ shortcode: 'sob_other' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { log } from 'debug';
|
||||
import type { ArrayValues, KeysOfUnion } from 'type-fest';
|
||||
|
||||
import {
|
||||
loadCustomEmojiKeys,
|
||||
loadEmojiByHexcode,
|
||||
rawSearch,
|
||||
searchCustomEmojisByShortcodes,
|
||||
} from './database';
|
||||
import { localeToSegmenter, toSupportedLocale } from './locale';
|
||||
import { extractTokens } from './normalize';
|
||||
import type { AnyEmojiData, CustomEmojiData } from './types';
|
||||
|
||||
/*
|
||||
Emoji search logic:
|
||||
1. When provided a string, extract all tokens and iterate over each.
|
||||
2. For each token, do an IDB lookup to get any matches on emojis and shortcodes.
|
||||
3. Score each emoji found (see below).
|
||||
4. Create a final list of emojis that is the intersection of all token results, with the scores combined.
|
||||
5. If not enough emoji are found, do a cursor search on custom emojis.
|
||||
6. Sort and return the emoji by ranked score, stopping when the optional limit is reached.
|
||||
|
||||
Scoring functions as follows:
|
||||
- Go over every scoreRanking field in order and extract score data.
|
||||
- Scores prefer exact match, then prefix match, and lastly substring match.
|
||||
- When sorting, prefer identifier fields first, then score, then category, and lastly prioritize Unicode emojis.
|
||||
*/
|
||||
|
||||
const scoreRanking = [
|
||||
'label',
|
||||
'shortcode',
|
||||
'emoticons',
|
||||
'shortcodes',
|
||||
'tokens',
|
||||
] as const satisfies KeysOfUnion<AnyEmojiData>[];
|
||||
type ScoreRankingKeys = ArrayValues<typeof scoreRanking>;
|
||||
type ScoreRanking = Record<ScoreRankingKeys, number>;
|
||||
|
||||
const identifierFields = new Set<ScoreRankingKeys>([
|
||||
'label',
|
||||
'shortcode',
|
||||
'emoticons',
|
||||
'shortcodes',
|
||||
]);
|
||||
|
||||
interface BestRank {
|
||||
categoryWeight: number;
|
||||
score: number;
|
||||
fieldWeight: number;
|
||||
}
|
||||
type ScoredEmoji = AnyEmojiData & { scores: ScoreRanking };
|
||||
type RankedEmoji = AnyEmojiData & { rank: BestRank };
|
||||
type ScoreMap = Map<string, ScoredEmoji>;
|
||||
|
||||
export async function search({
|
||||
query: rawQuery,
|
||||
locale: localeString,
|
||||
limit = 0,
|
||||
}: {
|
||||
query: string;
|
||||
locale: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
performance.mark('emoji-search-start');
|
||||
|
||||
// Get the locale, and extract tokens from the query.
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const segmenter = localeToSegmenter(locale);
|
||||
const query = rawQuery.toLowerCase();
|
||||
const queryTokens = extractTokens(query, segmenter);
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
log('no tokens extracted from query "%s"', query);
|
||||
return [];
|
||||
}
|
||||
const lastToken = queryTokens.at(-1);
|
||||
if (!lastToken) {
|
||||
throw new Error('Missing tokens from query');
|
||||
}
|
||||
|
||||
log('searching for tokens %o in locale %s', queryTokens, locale);
|
||||
|
||||
// Create an array of emoji results
|
||||
const resultArrays: ScoreMap[] = [];
|
||||
|
||||
for (let i = 0; i < queryTokens.length; i++) {
|
||||
const token = queryTokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
// Only query the range for the last token to allow partial matches.
|
||||
const { unicodeResults, customResults, shortcodeResults } = await rawSearch(
|
||||
token,
|
||||
locale,
|
||||
i === queryTokens.length - 1,
|
||||
);
|
||||
const resultMap: ScoreMap = new Map();
|
||||
const checkedSet = new Set<string>();
|
||||
|
||||
// Score unicode results.
|
||||
for (const emoji of unicodeResults) {
|
||||
if (checkedSet.has(emoji.hexcode)) {
|
||||
continue;
|
||||
}
|
||||
checkedSet.add(emoji.hexcode);
|
||||
const scores = getScoreForEmoji(emoji, token);
|
||||
if (scores) {
|
||||
resultMap.set(emoji.hexcode, { ...emoji, scores });
|
||||
}
|
||||
}
|
||||
|
||||
// Score custom emojis.
|
||||
for (const emoji of customResults) {
|
||||
if (checkedSet.has(emoji.shortcode)) {
|
||||
continue;
|
||||
}
|
||||
checkedSet.add(emoji.shortcode);
|
||||
const scores = getScoreForEmoji(emoji, token);
|
||||
if (scores) {
|
||||
resultMap.set(emoji.shortcode, { ...emoji, scores });
|
||||
}
|
||||
}
|
||||
|
||||
// Score based on legacy shortcodes, using the higher score if there's a match.
|
||||
for (const shortcodeResult of shortcodeResults) {
|
||||
const emoji =
|
||||
resultMap.get(shortcodeResult.hexcode) ??
|
||||
(await loadEmojiByHexcode(shortcodeResult.hexcode, locale));
|
||||
if (!emoji || !('hexcode' in emoji)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newScores = getScoreForEmoji(
|
||||
{
|
||||
...emoji,
|
||||
shortcodes: shortcodeResult.shortcodes,
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (!newScores) {
|
||||
continue;
|
||||
}
|
||||
const oldScores = resultMap.get(emoji.hexcode)?.scores;
|
||||
const scores = oldScores
|
||||
? combineEmojiScores(oldScores, newScores)
|
||||
: newScores;
|
||||
resultMap.set(emoji.hexcode, {
|
||||
...emoji,
|
||||
shortcodes: [...shortcodeResult.shortcodes, ...emoji.shortcodes],
|
||||
scores,
|
||||
});
|
||||
}
|
||||
|
||||
log('found %d results for token "%s"', resultMap.size, token);
|
||||
resultArrays.push(resultMap);
|
||||
}
|
||||
|
||||
// Iterate over all maps, getting a combined score for emojis that exist in all results.
|
||||
const allEmojiIds = resultArrays.reduce((prev, map) => {
|
||||
if (prev.size === 0) {
|
||||
return new Set(map.keys());
|
||||
}
|
||||
return new Set(map.keys()).intersection(prev);
|
||||
}, new Set<string>());
|
||||
|
||||
const finalMap: ScoreMap = new Map();
|
||||
for (const resultArray of resultArrays) {
|
||||
for (const [id, emoji] of resultArray.entries()) {
|
||||
if (!allEmojiIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
const existingEmoji = finalMap.get(id);
|
||||
if (!existingEmoji) {
|
||||
finalMap.set(id, emoji);
|
||||
} else {
|
||||
finalMap.set(id, {
|
||||
...existingEmoji,
|
||||
scores: combineEmojiScores(existingEmoji.scores, emoji.scores),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const mixedResults = Array.from(finalMap.values());
|
||||
|
||||
// If there are no results, try a cursor-based custom emoji search instead.
|
||||
if (mixedResults.length === 0 || mixedResults.length < limit) {
|
||||
const customEmojisFound = await fullCustomSearch(query, allEmojiIds);
|
||||
if (customEmojisFound.length > 0) {
|
||||
log(
|
||||
'cursor search found %d results for "%s"',
|
||||
customEmojisFound.length,
|
||||
query,
|
||||
);
|
||||
mixedResults.push(...customEmojisFound);
|
||||
}
|
||||
}
|
||||
|
||||
const rankedEmojis = mixedResults.map(({ scores, ...emoji }) => ({
|
||||
...emoji,
|
||||
rank: getBestRank(scores),
|
||||
}));
|
||||
|
||||
const results: RankedEmoji[] = [];
|
||||
const resultEmojis = new Set<string>();
|
||||
for (const result of rankedEmojis.toSorted(compareRankedEmoji)) {
|
||||
const id = getIdentifier(result);
|
||||
if (resultEmojis.has(id)) {
|
||||
continue;
|
||||
}
|
||||
results.push(result);
|
||||
resultEmojis.add(id);
|
||||
if (limit > 0 && results.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||
log(
|
||||
'search for "%s" in locale %s returned %d results and took %dms',
|
||||
query,
|
||||
locale,
|
||||
mixedResults.length,
|
||||
time.duration,
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
function hasField(
|
||||
emoji: AnyEmojiData,
|
||||
field: string,
|
||||
): field is keyof typeof emoji {
|
||||
return Object.hasOwn(emoji, field);
|
||||
}
|
||||
|
||||
function getIdentifier(emoji: AnyEmojiData) {
|
||||
return 'shortcode' in emoji ? emoji.shortcode : emoji.hexcode;
|
||||
}
|
||||
|
||||
// Creates ranked scores for a given emoji.
|
||||
function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
|
||||
const scores = Object.fromEntries(
|
||||
scoreRanking.map((field) => [field, -1]),
|
||||
) as ScoreRanking;
|
||||
let hasScore = false;
|
||||
|
||||
for (const field of scoreRanking) {
|
||||
if (hasField(emoji, field)) {
|
||||
const value = emoji[field] as string | string[] | undefined;
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
const tokens = Array.isArray(value) ? value : [value];
|
||||
const score = getScoreForEmojiTokens(
|
||||
tokens.map((token) => token.toLowerCase()),
|
||||
query,
|
||||
);
|
||||
|
||||
if (score >= 0) {
|
||||
scores[field] = score;
|
||||
hasScore = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasScore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
function combineEmojiScores(a: ScoreRanking, b: ScoreRanking): ScoreRanking {
|
||||
const scores = Object.fromEntries(
|
||||
scoreRanking.map((field) => [field, -1]),
|
||||
) as ScoreRanking;
|
||||
|
||||
for (const rank of scoreRanking) {
|
||||
if (a[rank] === -1) {
|
||||
scores[rank] = b[rank];
|
||||
} else if (b[rank] === -1) {
|
||||
scores[rank] = a[rank];
|
||||
} else {
|
||||
scores[rank] = Math.min(a[rank], b[rank]);
|
||||
}
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
// Compares two scored emojis by getting the best rank.
|
||||
function compareRankedEmoji(a: RankedEmoji, b: RankedEmoji): number {
|
||||
const rankA = a.rank;
|
||||
const rankB = b.rank;
|
||||
|
||||
// Identifier matches always outrank token matches, regardless of score.
|
||||
if (rankA.categoryWeight !== rankB.categoryWeight) {
|
||||
return rankA.categoryWeight - rankB.categoryWeight;
|
||||
}
|
||||
// Within the same category, compare the scores directly.
|
||||
if (rankA.score !== rankB.score) {
|
||||
return rankA.score - rankB.score;
|
||||
}
|
||||
// If equal, compare field weights.
|
||||
if (rankA.fieldWeight !== rankB.fieldWeight) {
|
||||
return rankA.fieldWeight - rankB.fieldWeight;
|
||||
}
|
||||
|
||||
// Lastly prioritize Unicode emojis.
|
||||
const aIsCustom = hasField(a, 'shortcode');
|
||||
const bIsCustom = hasField(b, 'shortcode');
|
||||
if (aIsCustom !== bIsCustom) {
|
||||
return aIsCustom ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Extracts the best rank for a score ranking.
|
||||
function getBestRank(scores: ScoreRanking): BestRank {
|
||||
let best: BestRank | null = null;
|
||||
|
||||
// Use the index to determine field weight.
|
||||
for (const [fieldWeight, field] of scoreRanking.entries()) {
|
||||
const score = scores[field];
|
||||
if (score < 0) {
|
||||
continue;
|
||||
}
|
||||
// Also weight identifier fields over other fields.
|
||||
const categoryWeight = identifierFields.has(field) ? 0 : 1;
|
||||
if (
|
||||
!best ||
|
||||
categoryWeight < best.categoryWeight ||
|
||||
(categoryWeight === best.categoryWeight &&
|
||||
(score < best.score ||
|
||||
(score === best.score && fieldWeight < best.fieldWeight)))
|
||||
) {
|
||||
best = { categoryWeight, score, fieldWeight };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
best ?? {
|
||||
categoryWeight: 2,
|
||||
score: Infinity,
|
||||
fieldWeight: scoreRanking.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getScoreForEmojiTokens(tokens: string[], query: string) {
|
||||
let lowestScore = -1;
|
||||
|
||||
for (const token of tokens) {
|
||||
let score = -1;
|
||||
// Priority: exact match, prefix match, substring,
|
||||
if (token === query) {
|
||||
score = 0;
|
||||
} else if (token.startsWith(query)) {
|
||||
score = 1 + query.length / token.length;
|
||||
} else if (token.includes(query)) {
|
||||
score = 2 + query.length / token.length;
|
||||
} // TODO: Add fuzzy search if needed
|
||||
|
||||
if (score >= 0 && (score < lowestScore || lowestScore < 0)) {
|
||||
lowestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return lowestScore;
|
||||
}
|
||||
|
||||
async function fullCustomSearch(query: string, existing = new Set<string>()) {
|
||||
const foundEmojis = new Set<string>();
|
||||
|
||||
// First iterate over chunks of 1,000 custom emoji keys and find any matches.
|
||||
const chunkSize = 1_000;
|
||||
let lastKey: string | null = null;
|
||||
let keys: string[] = [];
|
||||
do {
|
||||
keys = await loadCustomEmojiKeys(lastKey, chunkSize);
|
||||
|
||||
if (keys.length === 0) {
|
||||
break;
|
||||
}
|
||||
log('cursor search got batch of %d emojis', keys.length);
|
||||
lastKey = keys.at(-1) ?? null;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!foundEmojis.has(key) && !existing.has(key) && key.includes(query)) {
|
||||
foundEmojis.add(key);
|
||||
}
|
||||
}
|
||||
} while (keys.length === chunkSize);
|
||||
|
||||
// Next get the full emojis for all matches.
|
||||
const emojis = await searchCustomEmojisByShortcodes(Array.from(foundEmojis));
|
||||
const results: (CustomEmojiData & { scores: ScoreRanking })[] = [];
|
||||
for (const emoji of emojis) {
|
||||
const scores = getScoreForEmoji(emoji, query);
|
||||
if (scores) {
|
||||
results.push({
|
||||
scores,
|
||||
...emoji,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
+6
-6
@@ -61,18 +61,18 @@ export const NotificationFollowRequest: React.FC<{
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.reject)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onReject}
|
||||
/>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.authorize)}
|
||||
icon='check'
|
||||
iconComponent={CheckIcon}
|
||||
onClick={onAuthorize}
|
||||
/>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.reject)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onReject}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function statusLists(state = initialState, action) {
|
||||
else if (fetchQuotes.pending.match(action))
|
||||
return state.setIn(['quotes', 'isLoading'], true).setIn(['quotes', 'statusId'], action.meta.arg.statusId);
|
||||
else if (fetchQuotes.rejected.match(action))
|
||||
return state.setIn(['quotes', 'isLoading', false]).setIn(['quotes', 'statusId'], action.meta.arg.statusId);
|
||||
return state.setIn(['quotes', 'isLoading'], false).setIn(['quotes', 'statusId'], action.meta.arg.statusId);
|
||||
else
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
|
||||
const ALPHA = '\\p{L}\\p{M}';
|
||||
const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
|
||||
export const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
|
||||
|
||||
const buildHashtagPatternRegex = () => {
|
||||
try {
|
||||
|
||||
@@ -126,11 +126,12 @@ export function unicodeEmojiFactory(
|
||||
data: Partial<UnicodeEmojiData> = {},
|
||||
): UnicodeEmojiData {
|
||||
return {
|
||||
emoticons: undefined,
|
||||
hexcode: 'test',
|
||||
label: 'Test',
|
||||
unicode: '🧪',
|
||||
shortcodes: ['test_emoji'],
|
||||
tokens: ['emoji', 'test'],
|
||||
tokens: ['test', 'emoji'],
|
||||
group: 1,
|
||||
order: 1,
|
||||
...data,
|
||||
|
||||
@@ -15,7 +15,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
private
|
||||
|
||||
def create_status
|
||||
return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity?
|
||||
return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists?
|
||||
|
||||
@status_parser = ActivityPub::Parser::StatusParser.new(
|
||||
@json,
|
||||
followers_collection: @account.followers_url,
|
||||
following_collection: @account.following_url,
|
||||
actor_uri: ActivityPub::TagManager.instance.uri_for(@account),
|
||||
object: @object
|
||||
)
|
||||
|
||||
return reject_payload! unless related_to_local_activity?
|
||||
|
||||
with_redis_lock("create:#{object_uri}") do
|
||||
Status.uncached do
|
||||
@@ -26,6 +36,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
|
||||
if @status.nil?
|
||||
process_status
|
||||
elsif @status.account_id != @account.id
|
||||
Rails.logger.debug { "Not processing #{object_uri}: authorship change is not supported" }
|
||||
return reject_payload!
|
||||
elsif @options[:delivered_to_account_id].present?
|
||||
postprocess_audience_and_deliver
|
||||
end
|
||||
@@ -34,13 +47,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
@status
|
||||
end
|
||||
|
||||
def audience_to
|
||||
as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
||||
def audience_cc
|
||||
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
|
||||
end
|
||||
delegate :audience_to, :audience_cc, to: :@status_parser
|
||||
|
||||
def process_status
|
||||
@tags = []
|
||||
@@ -87,18 +94,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
def find_existing_status
|
||||
status = status_from_uri(object_uri)
|
||||
status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
|
||||
status if status&.account_id == @account.id
|
||||
status
|
||||
end
|
||||
|
||||
def process_status_params
|
||||
@status_parser = ActivityPub::Parser::StatusParser.new(
|
||||
@json,
|
||||
followers_collection: @account.followers_url,
|
||||
following_collection: @account.following_url,
|
||||
actor_uri: ActivityPub::TagManager.instance.uri_for(@account),
|
||||
object: @object
|
||||
)
|
||||
|
||||
attachment_ids = process_attachments.take(Status::MEDIA_ATTACHMENTS_LIMIT).map(&:id)
|
||||
|
||||
@params = {
|
||||
@@ -454,8 +453,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
end
|
||||
|
||||
def related_to_local_activity?
|
||||
fetch? || followed_by_local_accounts? || requested_through_relay? ||
|
||||
responds_to_followed_account? || addresses_local_accounts?
|
||||
return true if fetch?
|
||||
|
||||
case @status_parser.visibility
|
||||
when :public, :unlisted
|
||||
followed_by_local_accounts? || requested_through_relay? || responds_to_followed_account? || addresses_local_accounts?
|
||||
when :private
|
||||
followed_by_local_accounts? || addresses_local_accounts?
|
||||
when :direct
|
||||
addresses_local_accounts?
|
||||
end
|
||||
end
|
||||
|
||||
def responds_to_followed_account?
|
||||
|
||||
@@ -168,6 +168,14 @@ class ActivityPub::Parser::StatusParser
|
||||
equals_or_includes_any?(@object['type'], ActivityPub::Activity::CONVERTED_TYPES)
|
||||
end
|
||||
|
||||
def audience_to
|
||||
as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
||||
def audience_cc
|
||||
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def quote_subpolicy(subpolicy)
|
||||
@@ -203,14 +211,6 @@ class ActivityPub::Parser::StatusParser
|
||||
@object['directMessage']
|
||||
end
|
||||
|
||||
def audience_to
|
||||
as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
||||
def audience_cc
|
||||
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
||||
def summary_language_map?
|
||||
@object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
|
||||
end
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# TODO: remove some time after 4.7.0
|
||||
module Account::Merging
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ACCOUNT_MERGING_CLASSES = {
|
||||
account_id: [
|
||||
Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite,
|
||||
Follow, FollowRequest, Block, Mute,
|
||||
AccountModerationNote, AccountPin, AccountStat, ListAccount,
|
||||
PollVote, Mention, AccountDeletionRequest, AccountNote, FollowRecommendationSuppression,
|
||||
Appeal, TagFollow, Quote, Collection, CollectionItem
|
||||
],
|
||||
from_account_id: [
|
||||
Notification, NotificationPermission, NotificationRequest
|
||||
],
|
||||
target_account_id: [
|
||||
Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin, AccountNote
|
||||
],
|
||||
reference_account_id: [CanonicalEmailBlock],
|
||||
account_warning_id: [Appeal],
|
||||
local_account_id: [SeveredRelationship],
|
||||
remote_account_id: [SeveredRelationship],
|
||||
quoted_account_id: [Quote],
|
||||
}.freeze
|
||||
|
||||
def merge_with!(other_account)
|
||||
# Since it's the same remote resource, the remote resource likely
|
||||
# already believes we are following/blocking, so it's safe to
|
||||
@@ -11,65 +33,16 @@ module Account::Merging
|
||||
# account already, therefore mass update will not work and we need
|
||||
# to check for (and skip past) uniqueness errors
|
||||
|
||||
owned_classes = [
|
||||
Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite,
|
||||
Follow, FollowRequest, Block, Mute,
|
||||
AccountModerationNote, AccountPin, AccountStat, ListAccount,
|
||||
PollVote, Mention, AccountDeletionRequest, AccountNote, FollowRecommendationSuppression,
|
||||
Appeal, TagFollow
|
||||
]
|
||||
|
||||
owned_classes.each do |klass|
|
||||
klass.where(account_id: other_account.id).reorder(nil).find_each do |record|
|
||||
record.update_attribute(:account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
ACCOUNT_MERGING_CLASSES.each do |attribute, classes|
|
||||
classes.each do |klass|
|
||||
klass.where({ attribute => other_account.id }).reorder(nil).find_each do |record|
|
||||
record.update_attribute(attribute, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
[
|
||||
Notification, NotificationPermission, NotificationRequest
|
||||
].each do |klass|
|
||||
klass.where(from_account_id: other_account.id).reorder(nil).find_each do |record|
|
||||
record.update_attribute(:from_account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
target_classes = [
|
||||
Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin,
|
||||
AccountNote
|
||||
]
|
||||
|
||||
target_classes.each do |klass|
|
||||
klass.where(target_account_id: other_account.id).reorder(nil).find_each do |record|
|
||||
record.update_attribute(:target_account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
CanonicalEmailBlock.where(reference_account_id: other_account.id).find_each do |record|
|
||||
record.update_attribute(:reference_account_id, id)
|
||||
end
|
||||
|
||||
Appeal.where(account_warning_id: other_account.id).find_each do |record|
|
||||
record.update_attribute(:account_warning_id, id)
|
||||
end
|
||||
|
||||
SeveredRelationship.about_local_account(other_account).reorder(nil).find_each do |record|
|
||||
record.update_attribute(:local_account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
|
||||
SeveredRelationship.about_remote_account(other_account).reorder(nil).find_each do |record|
|
||||
record.update_attribute(:remote_account_id, id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
next
|
||||
end
|
||||
|
||||
# Some follow relationships have moved, so the cache is stale
|
||||
Rails.cache.delete_matched("followers_hash:#{id}:*")
|
||||
Rails.cache.delete_matched("relationships:#{id}:*")
|
||||
|
||||
@@ -14,7 +14,7 @@ class REST::CredentialAccountSerializer < REST::AccountSerializer
|
||||
language: user.setting_default_language,
|
||||
note: object.note,
|
||||
fields: object.fields.map(&:to_h),
|
||||
follow_requests_count: FollowRequest.where(target_account: object).limit(40).count,
|
||||
follow_requests_count: Account.without_suspended.joins(:follow_requests).where(follow_requests: { target_account: object }).limit(40).count,
|
||||
hide_collections: object.hide_collections,
|
||||
discoverable: object.discoverable,
|
||||
indexable: object.indexable,
|
||||
|
||||
+3
-2
@@ -121,6 +121,7 @@
|
||||
"tesseract.js": "^7.0.0",
|
||||
"tiny-queue": "^0.2.1",
|
||||
"twitter-text": "3.1.0",
|
||||
"type-fest": "^5.8.0",
|
||||
"use-debounce": "^10.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-manifest-sri": "^0.2.0",
|
||||
@@ -157,7 +158,7 @@
|
||||
"@types/redux-immutable": "^4.0.3",
|
||||
"@types/requestidlecallback": "^0.3.5",
|
||||
"@vitest/browser": "^4.1.0",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"@vitest/ui": "^4.1.0",
|
||||
"chromatic": "^17.0.0",
|
||||
@@ -186,7 +187,7 @@
|
||||
"typescript": "~6.0.0",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"typescript-plugin-css-modules": "^5.2.0",
|
||||
"vitest": "^4.1.7"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18.2.7",
|
||||
|
||||
@@ -486,6 +486,30 @@ RSpec.describe ActivityPub::Activity::Create do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the status is already known' do
|
||||
let(:recipient) { Fabricate(:account) }
|
||||
|
||||
let(:object_json) do
|
||||
build_object(
|
||||
to: ActivityPub::TagManager.instance.uri_for(recipient)
|
||||
)
|
||||
end
|
||||
|
||||
let!(:status) { Fabricate(:status, uri: object_json[:id], account: sender, text: object_json[:content]) }
|
||||
|
||||
it 'keeps the status intact' do
|
||||
expect(subject.perform).to eq status
|
||||
end
|
||||
|
||||
context 'when the known status is attributed to a different actor' do
|
||||
let!(:status) { Fabricate(:status, uri: object_json[:id], account: Fabricate(:remote_account)) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(subject.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when direct' do
|
||||
let(:recipient) { Fabricate(:account) }
|
||||
|
||||
@@ -1334,7 +1358,7 @@ RSpec.describe ActivityPub::Activity::Create do
|
||||
subject.perform
|
||||
end
|
||||
|
||||
let(:object_json) { build_object }
|
||||
let(:object_json) { build_object(to: 'http://example.com/followers') }
|
||||
|
||||
it 'creates status' do
|
||||
status = sender.statuses.first
|
||||
@@ -1350,7 +1374,8 @@ RSpec.describe ActivityPub::Activity::Create do
|
||||
let!(:local_status) { Fabricate(:status) }
|
||||
let(:object_json) do
|
||||
build_object(
|
||||
inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status)
|
||||
inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status),
|
||||
cc: 'https://www.w3.org/ns/activitystreams#Public'
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1427,7 +1452,7 @@ RSpec.describe ActivityPub::Activity::Create do
|
||||
|
||||
def build_object(options = {})
|
||||
{
|
||||
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
|
||||
id: [ActivityPub::TagManager.instance.uri_for(sender), '/bar'].join,
|
||||
type: 'Note',
|
||||
content: 'Lorem ipsum',
|
||||
}.merge(options)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Account::Merging do
|
||||
let!(:account) { Fabricate(:account) }
|
||||
|
||||
describe '#merge_with!' do
|
||||
let(:other_account) { Fabricate(:account) }
|
||||
let!(:status) { Fabricate(:status, account: other_account) }
|
||||
let!(:follow) { Fabricate(:follow, account: other_account) }
|
||||
let!(:reverse_follow) { Fabricate(:follow, target_account: other_account) }
|
||||
|
||||
it 'reattributes records' do
|
||||
expect { account.merge_with!(other_account) }
|
||||
.to change { status.reload.account_id }.to(account.id)
|
||||
.and change { follow.reload.account_id }.to(account.id)
|
||||
.and change { reverse_follow.reload.target_account_id }.to(account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -39,6 +39,15 @@ RSpec.describe 'API Web Push Subscriptions' do
|
||||
describe 'DELETE /api/web/push_subscriptions/:id' do
|
||||
subject { delete api_web_push_subscription_path(token) }
|
||||
|
||||
around do |example|
|
||||
old = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = true
|
||||
|
||||
example.run
|
||||
|
||||
ActionController::Base.allow_forgery_protection = old
|
||||
end
|
||||
|
||||
context 'when the subscription exists' do
|
||||
let!(:web_push_subscription) do
|
||||
Fabricate(:web_push_subscription)
|
||||
|
||||
@@ -2924,7 +2924,7 @@ __metadata:
|
||||
"@vitejs/plugin-legacy": "npm:^8.0.0"
|
||||
"@vitejs/plugin-react": "npm:^6.0.0"
|
||||
"@vitest/browser": "npm:^4.1.0"
|
||||
"@vitest/browser-playwright": "npm:^4.1.7"
|
||||
"@vitest/browser-playwright": "npm:^4.1.10"
|
||||
"@vitest/coverage-v8": "npm:^4.1.0"
|
||||
"@vitest/ui": "npm:^4.1.0"
|
||||
arrow-key-navigation: "npm:^1.2.0"
|
||||
@@ -3014,6 +3014,7 @@ __metadata:
|
||||
tesseract.js: "npm:^7.0.0"
|
||||
tiny-queue: "npm:^0.2.1"
|
||||
twitter-text: "npm:3.1.0"
|
||||
type-fest: "npm:^5.8.0"
|
||||
typescript: "npm:~6.0.0"
|
||||
typescript-eslint: "npm:^8.55.0"
|
||||
typescript-plugin-css-modules: "npm:^5.2.0"
|
||||
@@ -3021,7 +3022,7 @@ __metadata:
|
||||
vite: "npm:^8.0.0"
|
||||
vite-plugin-manifest-sri: "npm:^0.2.0"
|
||||
vite-plugin-svgr: "npm:^5.0.0"
|
||||
vitest: "npm:^4.1.7"
|
||||
vitest: "npm:^4.1.10"
|
||||
wicg-inert: "npm:^3.1.2"
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
@@ -5234,38 +5235,38 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/browser-playwright@npm:^4.1.7":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/browser-playwright@npm:4.1.9"
|
||||
"@vitest/browser-playwright@npm:^4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/browser-playwright@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/browser": "npm:4.1.9"
|
||||
"@vitest/mocker": "npm:4.1.9"
|
||||
"@vitest/browser": "npm:4.1.10"
|
||||
"@vitest/mocker": "npm:4.1.10"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
peerDependencies:
|
||||
playwright: "*"
|
||||
vitest: 4.1.9
|
||||
vitest: 4.1.10
|
||||
peerDependenciesMeta:
|
||||
playwright:
|
||||
optional: false
|
||||
checksum: 10c0/4c042c980bb0aa950646d4d1f0f131f44156141d86f54eb96f5f69863ed05720cac7ef1a0901ab71e18929a62c41e5d64a428b2612ea708905bf423ff961dcf4
|
||||
checksum: 10c0/161530da4da9c061875c0e80c2c94e93658bf92514f2e5173c04299e17a6021feb209ef8fb5e14e880c8c8b2b9e7fc4f3ec7a346c8c4a77831864597331257fc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/browser@npm:4.1.9, @vitest/browser@npm:^4.1.0":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/browser@npm:4.1.9"
|
||||
"@vitest/browser@npm:4.1.10, @vitest/browser@npm:^4.1.0":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/browser@npm:4.1.10"
|
||||
dependencies:
|
||||
"@blazediff/core": "npm:1.9.1"
|
||||
"@vitest/mocker": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/mocker": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
magic-string: "npm:^0.30.21"
|
||||
pngjs: "npm:^7.0.0"
|
||||
sirv: "npm:^3.0.2"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
ws: "npm:^8.19.0"
|
||||
peerDependencies:
|
||||
vitest: 4.1.9
|
||||
checksum: 10c0/9ff634e56b0bd5a3727f2eec7666f97a089b92fb596d5b9e4fbe2a2e4fd988aa7a7bd2a7b98d7cf14790717db69db8f72bae6dc007f2fac613b9bb93d8e8edfb
|
||||
vitest: 4.1.10
|
||||
checksum: 10c0/61c86b8c0fcc78bd01de559914525e768dc16d565ee8a40a41d51ef6d99595c3c5e976defe32d090b3dda5f77a980caa11a2072e9c4d279cfb8d55d31c565fc7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5306,25 +5307,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/expect@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/expect@npm:4.1.9"
|
||||
"@vitest/expect@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/expect@npm:4.1.10"
|
||||
dependencies:
|
||||
"@standard-schema/spec": "npm:^1.1.0"
|
||||
"@types/chai": "npm:^5.2.2"
|
||||
"@vitest/spy": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
chai: "npm:^6.2.2"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875
|
||||
checksum: 10c0/a817ad0d9bd6a039776a7228d54fb8319c17e4af15917407f5566ac61781a8511f591d302519d6999217399915bc3c0290028189fc73f5c38f80cb01b6f19c8d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/mocker@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/mocker@npm:4.1.9"
|
||||
"@vitest/mocker@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/mocker@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/spy": "npm:4.1.9"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
estree-walker: "npm:^3.0.3"
|
||||
magic-string: "npm:^0.30.21"
|
||||
peerDependencies:
|
||||
@@ -5335,7 +5336,7 @@ __metadata:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82
|
||||
checksum: 10c0/4aa70b0df58681652e2e28093437fb2e8f4d02a6d03f5619abc266ac1c5ae5f43326148061d13ae6e071e0f6cfcf7634659af63644de8ce098a7c98949a3d1ad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5348,6 +5349,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/pretty-format@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/pretty-format@npm:4.1.10"
|
||||
dependencies:
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10c0/1a5daba730ffe23f2000bff484b4b2842f3b178d93663cb487b215516b8d3b62caa3e2bb2a3c63307b61a9fe58fb9bfff38559bc0c5e49d8aa403d6803a1d918
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/pretty-format@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/pretty-format@npm:4.1.9"
|
||||
@@ -5357,25 +5367,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/runner@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/runner@npm:4.1.9"
|
||||
"@vitest/runner@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/runner@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
pathe: "npm:^2.0.3"
|
||||
checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb
|
||||
checksum: 10c0/554b72639de9694271b99be8ae273fe12ec793093ec91cce143816cd1187d40b7138a4d9d4de4f456cfca9567de986825bff97e107c05b9eb4abc130e854286d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/snapshot@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/snapshot@npm:4.1.9"
|
||||
"@vitest/snapshot@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/snapshot@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
magic-string: "npm:^0.30.21"
|
||||
pathe: "npm:^2.0.3"
|
||||
checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995
|
||||
checksum: 10c0/e71398725f51af5fd0c07bb4b957d0f987daf9b4c564ac24cb2a4d1afde1a6939f535ac17761a32dcc41b0a1e6d4088af66dc44df89fdebebb92aabed1a92b5f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5388,10 +5398,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/spy@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/spy@npm:4.1.9"
|
||||
checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115
|
||||
"@vitest/spy@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/spy@npm:4.1.10"
|
||||
checksum: 10c0/e5c08012560af6727fd66741c5cda25560d7c5442103d0c83e4276a9b0dd90b9da6cdf823a461195229a16c6ff87768ce788a68d0fa29dea73ee285618668178
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5423,6 +5433,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/utils@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/utils@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
convert-source-map: "npm:^2.0.0"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10c0/05b0ecec6997ec22fc08377e57dbd8fa37992e05961f3a7a916d98b1ab56d15c2a87dbd83d392b628242bdc156b1705e7fa60a3bf0c54bdb51158c153e05fc5d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/utils@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/utils@npm:4.1.9"
|
||||
@@ -14009,6 +14030,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-fest@npm:^5.8.0":
|
||||
version: 5.8.0
|
||||
resolution: "type-fest@npm:5.8.0"
|
||||
dependencies:
|
||||
tagged-tag: "npm:^1.0.0"
|
||||
checksum: 10c0/c8aae118a763d550a9552a511dff6b71840a23dab4edf693cf1c4df22596942794e6f6723389bd9036a90182249d915158bacf0815a1ae87f05f901b1d5f574e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-is@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "type-is@npm:2.0.1"
|
||||
@@ -14591,17 +14621,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vitest@npm:^4.1.7":
|
||||
version: 4.1.9
|
||||
resolution: "vitest@npm:4.1.9"
|
||||
"vitest@npm:^4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "vitest@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/expect": "npm:4.1.9"
|
||||
"@vitest/mocker": "npm:4.1.9"
|
||||
"@vitest/pretty-format": "npm:4.1.9"
|
||||
"@vitest/runner": "npm:4.1.9"
|
||||
"@vitest/snapshot": "npm:4.1.9"
|
||||
"@vitest/spy": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/expect": "npm:4.1.10"
|
||||
"@vitest/mocker": "npm:4.1.10"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
"@vitest/runner": "npm:4.1.10"
|
||||
"@vitest/snapshot": "npm:4.1.10"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
es-module-lexer: "npm:^2.0.0"
|
||||
expect-type: "npm:^1.3.0"
|
||||
magic-string: "npm:^0.30.21"
|
||||
@@ -14619,12 +14649,12 @@ __metadata:
|
||||
"@edge-runtime/vm": "*"
|
||||
"@opentelemetry/api": ^1.9.0
|
||||
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
"@vitest/browser-playwright": 4.1.9
|
||||
"@vitest/browser-preview": 4.1.9
|
||||
"@vitest/browser-webdriverio": 4.1.9
|
||||
"@vitest/coverage-istanbul": 4.1.9
|
||||
"@vitest/coverage-v8": 4.1.9
|
||||
"@vitest/ui": 4.1.9
|
||||
"@vitest/browser-playwright": 4.1.10
|
||||
"@vitest/browser-preview": 4.1.10
|
||||
"@vitest/browser-webdriverio": 4.1.10
|
||||
"@vitest/coverage-istanbul": 4.1.10
|
||||
"@vitest/coverage-v8": 4.1.10
|
||||
"@vitest/ui": 4.1.10
|
||||
happy-dom: "*"
|
||||
jsdom: "*"
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -14655,7 +14685,7 @@ __metadata:
|
||||
optional: false
|
||||
bin:
|
||||
vitest: ./vitest.mjs
|
||||
checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7
|
||||
checksum: 10c0/ff07294a57f9c62f3b503f7cf88a52ee0753ed26389a49cda430387a3898f39d80af47180b0af19e27acab5bd11ae95706bd4b44ce8befc97d3ae49af6ca4fc1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user