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:
Claire
2026-07-23 07:52:59 +02:00
committed by GitHub
42 changed files with 1645 additions and 924 deletions
+3 -3
View File
@@ -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;
}
@@ -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 {
+1 -1
View File
@@ -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;
}
@@ -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 -1
View File
@@ -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 {
+2 -1
View File
@@ -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,
+26 -19
View File
@@ -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?
+8 -8
View File
@@ -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
+29 -56
View File
@@ -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
View File
@@ -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",
+28 -3
View File
@@ -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)
+89 -59
View File
@@ -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