Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/emoji-resources/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"build:watch": "compile ui",
"_phase:build": "compile ui",
"_phase:format": "format src",
"test": "jest --passWithNoTests --silent",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:validate": "compile validate"
},
"devDependencies": {
Expand Down
81 changes: 81 additions & 0 deletions plugins/emoji-resources/src/__tests__/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import type { EmojiWithGroup } from '@hcengineering/emoji'
import { emojiMatchesSearch, searchEmojisForSuggestion } from '../search'

function unicode (emoji: string, label: string, shortcodes: string[], tags: string[] = []): EmojiWithGroup {
return { emoji, label, shortcodes, tags, hexcode: label, key: 'smileys-people' } as any
}

function custom (shortcode: string): EmojiWithGroup {
return { _id: shortcode, shortcode, image: `${shortcode}-blob`, key: 'custom' } as any
}

const sob = unicode('😭', 'loudly crying face', ['sob'], ['cry', 'sad'])
const joy = unicode('😂', 'face with tears of joy', ['joy'], ['face', 'joy', 'laugh', 'tear'])
const joystick = unicode('🕹️', 'joystick', ['joystick'], ['game', 'video game'])
const catJoy = unicode('😹', 'cat with tears of joy', ['joy_cat'], ['cat', 'joy'])
const thumbsUp = unicode('👍', 'thumbs up', ['+1', 'thumbsup'], ['+1', 'hand', 'up'])
const partyParrot = custom('party_parrot')

const emojis = [sob, catJoy, joystick, joy, thumbsUp, partyParrot]

describe('searchEmojisForSuggestion', () => {
it('requires at least two characters after the colon', () => {
expect(searchEmojisForSuggestion(emojis, '')).toEqual([])
expect(searchEmojisForSuggestion(emojis, 'j')).toEqual([])
expect(searchEmojisForSuggestion(emojis, 'jo').length).toBeGreaterThan(0)
})

it('ignores text that cannot be a shortcode, like emoticons', () => {
expect(searchEmojisForSuggestion(emojis, '-)')).toEqual([])
expect(searchEmojisForSuggestion(emojis, 'jo)')).toEqual([])
expect(searchEmojisForSuggestion(emojis, '//example')).toEqual([])
})

it('lists exact shortcode matches first, then shortcode prefixes, then other matches', () => {
expect(searchEmojisForSuggestion(emojis, 'joy')).toEqual([joy, catJoy, joystick])
expect(searchEmojisForSuggestion(emojis, 'jo')).toEqual([catJoy, joystick, joy])
})

it('matches tags and labels like the emoji picker', () => {
expect(searchEmojisForSuggestion(emojis, 'cry')).toEqual([sob])
expect(searchEmojisForSuggestion(emojis, 'tears')).toEqual([catJoy, joy])
})

it('is case insensitive and supports shortcodes with + and _', () => {
expect(searchEmojisForSuggestion(emojis, 'JOY')[0]).toBe(joy)
expect(searchEmojisForSuggestion(emojis, '+1')[0]).toBe(thumbsUp)
expect(searchEmojisForSuggestion(emojis, 'joy_')).toEqual([catJoy])
})

it('finds custom emojis by shortcode', () => {
expect(searchEmojisForSuggestion(emojis, 'party')).toEqual([partyParrot])
expect(searchEmojisForSuggestion(emojis, 'parrot')).toEqual([partyParrot])
})

it('limits the number of results', () => {
expect(searchEmojisForSuggestion(emojis, 'jo', 2)).toEqual([catJoy, joystick])
})
})

describe('emojiMatchesSearch', () => {
it('matches shortcode and tag prefixes and label substrings', () => {
expect(emojiMatchesSearch(joystick, 'joy')).toBe(true)
expect(emojiMatchesSearch(joystick, 'vid')).toBe(true)
expect(emojiMatchesSearch(sob, 'crying')).toBe(true)
expect(emojiMatchesSearch(sob, 'ob')).toBe(false)
})
})
5 changes: 3 additions & 2 deletions plugins/emoji-resources/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Resources } from '@hcengineering/platform'
import EmojiPopup from './components/EmojiPopup.svelte'
import SettingsEmojiTable from './components/settings/SettingsEmojiTable.svelte'
import WorkbenchExtension from './components/WorkbenchExtension.svelte'
import { getCustomEmoji, getEmojiByEmoticon, getEmojiByShortCode, parseTextWithEmojis } from './utils'
import { getCustomEmoji, getEmojiByEmoticon, getEmojiByShortCode, parseTextWithEmojis, searchEmojis } from './utils'

export { default as EmojiPresenter } from './components/EmojiPresenter.svelte'

Expand All @@ -18,6 +18,7 @@ export default async (): Promise<Resources> => ({
GetEmojiByEmoticon: getEmojiByEmoticon,
GetEmojiByShortCode: getEmojiByShortCode,
GetCustomEmoji: getCustomEmoji,
ParseTextWithEmojis: parseTextWithEmojis
ParseTextWithEmojis: parseTextWithEmojis,
SearchEmojis: searchEmojis
}
})
64 changes: 64 additions & 0 deletions plugins/emoji-resources/src/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { isCustomEmoji, type EmojiWithGroup } from '@hcengineering/emoji'

/** Minimal number of characters after ':' before emoji suggestions are shown */
export const minEmojiSuggestionQueryLength = 2

const defaultEmojiSuggestionLimit = 10
const shortcodeQueryRegex = /^[\p{L}\p{N}_+-]+$/u

/**
* Same matching rules as the emoji picker search.
*/
export function emojiMatchesSearch (emoji: EmojiWithGroup, search: string): boolean {
const query = search.toLowerCase()
if (isCustomEmoji(emoji)) {
return emoji.shortcode.toLowerCase().includes(query)
}
return (
(emoji.tags?.some((tag: string) => tag.toLowerCase().startsWith(query)) ?? false) ||
(emoji.shortcodes?.some((shortcode: string) => shortcode.toLowerCase().startsWith(query)) ?? false) ||
emoji.label.toLowerCase().includes(query)
)
}

function getMatchRank (emoji: EmojiWithGroup, query: string): number {
const shortcodes = isCustomEmoji(emoji) ? [emoji.shortcode] : (emoji.shortcodes ?? [])
const lowerShortcodes = shortcodes.map((s) => s.toLowerCase())
if (lowerShortcodes.includes(query)) return 0
if (lowerShortcodes.some((s) => s.startsWith(query))) return 1
return 2
}

/**
* Returns emojis matching the text typed after ':' for the inline emoji suggestion popup.
* Returns an empty list for queries that are too short or cannot be a shortcode (e.g. emoticons like ':-)'),
* exact and prefix shortcode matches are listed first.
*/
export function searchEmojisForSuggestion (
emojis: EmojiWithGroup[],
query: string,
limit: number = defaultEmojiSuggestionLimit
): EmojiWithGroup[] {
if (query.length < minEmojiSuggestionQueryLength || !shortcodeQueryRegex.test(query)) return []
const lowerQuery = query.toLowerCase()
return emojis
.filter((emoji) => emojiMatchesSearch(emoji, lowerQuery))
.map((emoji, index) => ({ emoji, index, rank: getMatchRank(emoji, lowerQuery) }))
.sort((a, b) => (a.rank !== b.rank ? a.rank - b.rank : a.index - b.index))
.slice(0, limit)
.map(({ emoji }) => emoji)
}
13 changes: 2 additions & 11 deletions plugins/emoji-resources/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
import { writable, derived, get } from 'svelte/store'
import { isCustomEmoji, type EmojiWithGroup } from '@hcengineering/emoji'
import { getCurrentAccount } from '@hcengineering/core'
import { emojiMatchesSearch } from './search'

export const unicodeEmojiStore = writable<EmojiWithGroup[]>([])
export const customEmojiStore = writable<EmojiWithGroup[]>([])
export const searchEmoji = writable<string>('')

export const resultEmojis = derived([unicodeEmojiStore, customEmojiStore, searchEmoji], ([unicode, custom, search]) => {
return search !== ''
? unicode.concat(custom).filter((emoji) => {
if (isCustomEmoji(emoji)) {
return emoji.shortcode.toLowerCase().includes(search.toLowerCase())
}
return (
(emoji.tags?.some((tag: string) => tag.toLowerCase().startsWith(search.toLowerCase())) ?? false) ||
(emoji.shortcodes?.some((shortcode: string) => shortcode.toLowerCase().startsWith(search.toLowerCase())) ??
false) ||
emoji.label.toLowerCase().includes(search.toLowerCase())
)
})
? unicode.concat(custom).filter((emoji) => emojiMatchesSearch(emoji, search))
: unicode.concat(custom)
})

Expand Down
17 changes: 14 additions & 3 deletions plugins/emoji-resources/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@hcengineering/emoji'
import { emojiCategories } from './types'
import { unicodeEmojiStore, customEmojiStore, getSkinTone } from './store'
import { searchEmojisForSuggestion } from './search'
import { get } from 'svelte/store'
import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui'

Expand Down Expand Up @@ -114,10 +115,20 @@ function findEmoji (
const emojis = get(unicodeEmojiStore).concat(get(customEmojiStore))
const matchEmoji = emojis.find(predicate)
if (matchEmoji === undefined) return undefined
if (isCustomEmoji(matchEmoji)) return matchEmoji
return applySkinTone(matchEmoji, skinTone)
}

function applySkinTone (emoji: ExtendedEmoji, skinTone?: number): ExtendedEmoji {
if (isCustomEmoji(emoji)) return emoji
if (skinTone === undefined) skinTone = getSkinTone()
if (skinTone === 0 || matchEmoji.skins === undefined) return matchEmoji
return matchEmoji.skins[skinTone - 1]
if (skinTone === 0 || emoji.skins === undefined) return emoji
return emoji.skins[skinTone - 1]
}

export function searchEmojis (query: string, limit?: number): ExtendedEmoji[] {
const emojis = get(unicodeEmojiStore).concat(get(customEmojiStore))
const skinTone = getSkinTone()
return searchEmojisForSuggestion(emojis, query, limit).map((emoji) => applySkinTone(emoji, skinTone))
}

export function getEmojiSkins (emoji: ExtendedEmoji): Emoji.Emoji[] | undefined {
Expand Down
3 changes: 2 additions & 1 deletion plugins/emoji/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ export const emojiPlugin = plugin(emojiId, {
(shortcode: string | undefined, skinTone?: number) => ExtendedEmoji | undefined
>,
GetCustomEmoji: '' as Resource<(shortcode: string | undefined, skinTone?: number) => CustomEmoji | undefined>,
ParseTextWithEmojis: '' as Resource<(text: string) => ParsedTextWithEmojis>
ParseTextWithEmojis: '' as Resource<(text: string) => ParsedTextWithEmojis>,
SearchEmojis: '' as Resource<(query: string, limit?: number) => ExtendedEmoji[]>
}
})

Expand Down
2 changes: 2 additions & 0 deletions plugins/emoji/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export const emojiRegex = new RegExp(`(?:^|\\s)(${EMOJI_REGEX.source})$`)
export const emojiGlobalRegex = new RegExp(EMOJI_REGEX.source, EMOJI_REGEX.flags + 'g')

export const emoticonRegex = new RegExp(`(?:^|\\s)(${EMOTICON_REGEX.source})$`)
// Emoticon followed by the whitespace that has just been typed
export const emoticonBeforeSpaceRegex = new RegExp(`(?:^|\\s)(${EMOTICON_REGEX.source})\\s$`)
export const emoticonGlobalRegex = new RegExp(EMOTICON_REGEX.source, EMOTICON_REGEX.flags + 'g')

export const shortcodeRegex = new RegExp(`(?:^|\\s)(${SHORTCODE_REGEX.source})$`)
Expand Down
Loading
Loading