aboutsummaryrefslogtreecommitdiff
path: root/src/components/emoji_input/suggestor.js
blob: adaa879e3c7bd16d5ca40074fbe8a8385b17ea53 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/**
 * suggest - generates a suggestor function to be used by emoji-input
 * data: object providing source information for specific types of suggestions:
 * data.emoji - optional, an array of all emoji available i.e.
 *   (getters.standardEmojiList + state.instance.customEmoji)
 * data.users - optional, an array of all known users
 * updateUsersList - optional, a function to search and append to users
 *
 * Depending on data present one or both (or none) can be present, so if field
 * doesn't support user linking you can just provide only emoji.
 */

export default data => {
  const emojiCurry = suggestEmoji(data.emoji)
  const usersCurry = data.store && suggestUsers(data.store)
  return (input, nameKeywordLocalizer) => {
    const firstChar = input[0]
    if (firstChar === ':' && data.emoji) {
      return emojiCurry(input, nameKeywordLocalizer)
    }
    if (firstChar === '@' && usersCurry) {
      return usersCurry(input)
    }
    return []
  }
}

export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => {
  const noPrefix = input.toLowerCase().substr(1)
  return emojis
    .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
    .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length)
    .map(k => {
      let score = 0

      // An exact match always wins
      score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0)

      // Prioritize custom emoji a lot
      score += k.imageUrl ? 100 : 0

      // Prioritize prefix matches somewhat
      score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0)

      // Sort by length
      score -= k.displayText.length

      k.score = score
      return k
    })
    .sort((a, b) => {
      // Break ties alphabetically
      const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5

      return b.score - a.score + alphabetically
    })
}

export const suggestUsers = ({ dispatch, state }) => {
  // Keep some persistent values in closure, most importantly for the
  // custom debounce to work. Lodash debounce does not return a promise.
  let suggestions = []
  let previousQuery = ''
  let timeout = null
  let cancelUserSearch = null

  const userSearch = (query) => dispatch('searchUsers', { query })
  const debounceUserSearch = (query) => {
    cancelUserSearch && cancelUserSearch()
    return new Promise((resolve, reject) => {
      timeout = setTimeout(() => {
        userSearch(query).then(resolve).catch(reject)
      }, 300)
      cancelUserSearch = () => {
        clearTimeout(timeout)
        resolve([])
      }
    })
  }

  return async input => {
    const noPrefix = input.toLowerCase().substr(1)
    if (previousQuery === noPrefix) return suggestions

    suggestions = []
    previousQuery = noPrefix
    // Fetch more and wait, don't fetch if there's the 2nd @ because
    // the backend user search can't deal with it.
    // Reference semantics make it so that we get the updated data after
    // the await.
    if (!noPrefix.includes('@')) {
      await debounceUserSearch(noPrefix)
    }

    const newSuggestions = state.users.users.filter(
      user =>
        user.screen_name.toLowerCase().startsWith(noPrefix) ||
        user.name.toLowerCase().startsWith(noPrefix)
    ).slice(0, 20).sort((a, b) => {
      let aScore = 0
      let bScore = 0

      // Matches on screen name (i.e. user@instance) makes a priority
      aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
      bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0

      // Matches on name takes second priority
      aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
      bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0

      const diff = (bScore - aScore) * 10

      // Then sort alphabetically
      const nameAlphabetically = a.name > b.name ? 1 : -1
      const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1

      return diff + nameAlphabetically + screenNameAlphabetically
      /* eslint-disable camelcase */
    }).map((user) => ({
      user,
      displayText: user.screen_name_ui,
      detailText: user.name,
      imageUrl: user.profile_image_url_original,
      replacement: '@' + user.screen_name + ' '
    }))
    /* eslint-enable camelcase */

    suggestions = newSuggestions || []
    return suggestions
  }
}