aboutsummaryrefslogtreecommitdiff
path: root/src/components/emoji_input/suggestor.js
blob: 0d268f856c0e15c5312e8916e954fcbac77b4eb7 (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
132
133
134
/**
 * 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.
 *   (state.instance.emoji + 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 => {
    const firstChar = input[0]
    if (firstChar === ':' && data.emoji) {
      return emojiCurry(input)
    }
    if (firstChar === '@' && usersCurry) {
      return usersCurry(input)
    }
    return []
  }
}

export const suggestEmoji = emojis => input => {
  const noPrefix = input.toLowerCase().substr(1)
  return emojis
    .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
    .sort((a, b) => {
      let aScore = 0
      let bScore = 0

      // An exact match always wins
      aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
      bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0

      // Prioritize custom emoji a lot
      aScore += a.imageUrl ? 100 : 0
      bScore += b.imageUrl ? 100 : 0

      // Prioritize prefix matches somewhat
      aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
      bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0

      // Sort by length
      aScore -= a.displayText.length
      bScore -= b.displayText.length

      // Break ties alphabetically
      const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5

      return bScore - aScore + alphabetically
    })
}

export const suggestUsers = ({ dispatch, state }) => {
  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) => {
      clearTimeout(timeout)
      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)

      /* taking only 20 results so that sorting is a bit cheaper, we display
       * only 5 anyway. could be inaccurate, but we ideally we should query
       * backend anyway
       */
    ).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(({ screen_name, name, profile_image_url_original }) => ({
      displayText: screen_name,
      detailText: name,
      imageUrl: profile_image_url_original,
      replacement: '@' + screen_name + ' '
    }))
    /* eslint-enable camelcase */

    suggestions = newSuggestions || []
    return suggestions
  }
}