diff --git a/app/components/stream-feed/create-post.js b/app/components/stream-feed/create-post.js index d28e4f3bb0..3b1df5113c 100644 --- a/app/components/stream-feed/create-post.js +++ b/app/components/stream-feed/create-post.js @@ -14,6 +14,9 @@ import matches from 'client/utils/elements-match'; const FILE_UPLOAD_LIMIT = 20; const LINK_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; +const INDICES = { + users: ['id', 'slug', 'name', 'avatar'], +}; export default Component.extend({ classNameBindings: ['isExpanded:is-expanded'], @@ -29,6 +32,11 @@ export default Component.extend({ maxLength: 9000, _usableMedia: null, embedUrl: null, + isUserSearchOpen: false, + previousUserSearchTimeoutId: null, + previousContent: null, + currentlyEdtitingUserTag: null, + users: [], ajax: service(), store: service(), @@ -36,6 +44,7 @@ export default Component.extend({ fileQueue: service(), notify: service(), raven: service(), + algolia: service(), canPost: or('contentPresent', 'uploadsReady', 'embedUrl'), uploadsReady: and('uploadsPresent', 'queueFinished'), @@ -242,6 +251,24 @@ export default Component.extend({ set(this, 'uploads', uploads); }, + usersTask: task(function* (query, options = {}) { + const index = yield get(this, 'algolia.getIndex').perform('users'); + if (isEmpty(index)) { + return {}; + } + return yield index.search(query, { + attributesToRetrieve: INDICES.users, + hitsPerPage: 4, + queryLanguages: ['en', 'ja'], + naturalLanguages: ['en', 'ja'], + attributesToHighlight: [], + responseFields: ['hits', 'hitsPerPage', 'nbHits', 'nbPages', 'offset', 'page'], + removeStopWords: false, + removeWordsIfNoResults: 'allOptional', + ...options + }); + }).restartable(), + actions: { createPost(component, event) { const { metaKey, ctrlKey } = event; @@ -312,6 +339,102 @@ export default Component.extend({ this.set('embedUrl', undefined); } invoke(this, 'processLinks', this.get('content'), true); + }, + + autocompleteUserTag(selectedTag) { + const currentlyEditingUserTag = this.get('currentlyEdtitingUserTag') + const textarea = document.getElementById('create-post-textarea') + + const newContent = + this.content.substring(0, currentlyEditingUserTag.index) + + '@' + + this.content.substring(currentlyEditingUserTag.index).replace(currentlyEditingUserTag.tag, selectedTag) + + this.set('isUserSearchOpen', false) + textarea.value = newContent + textarea.selectionEnd = newContent.indexOf(' ', currentlyEditingUserTag.index) + this.set('previousContent', newContent); + }, + + autocompleteUserTagOnTab(_, event) { + const textarea = document.getElementById('create-post-textarea') + + if (this.get('currentlyEdtitingUserTag') !== null) event.preventDefault(); + + const user = this.get('users').at(0) + if (user === undefined) { + textarea.value = textarea.value + ' ' + this.set('previousContent', textarea.value); + return + } + + const selectedTag = user.tag + this.send("autocompleteUserTag", selectedTag) + }, + + closeUserTagSearch() { + this.set('isUserSearchOpen', false) + }, + + userTagSearch(content) { + const stringDiffIndex = (s1, s2) => { + let i = 0; + while (s1[i] === s2[i] && i <= Math.max(s1.length, s2.length)) i++; + return i + } + + const previousContent = this.get('previousContent') + + if (previousContent) { + const index = stringDiffIndex(previousContent, content) + const didTypeWhiteSpace = [' ', '\n'].includes(content.at(index)) && content.length > previousContent.length + + if (!didTypeWhiteSpace) { + const tagRegex = /(@[a-zA-Z])\w+/g + const tagMatches = content.matchAll(tagRegex) + + // Assume no tag is being edited each keystroke + this.set('currentlyEdtitingUserTag', null) + + for (const tagMatch of tagMatches) { + const tag = tagMatch[0] + const isEditingBeforeTagEnd = tagMatch.index + tag.length >= index + const isEditingAfterTagStart = index > tagMatch.index + const isEditingThisTag = isEditingAfterTagStart && isEditingBeforeTagEnd + + if (isEditingThisTag) { + this.set('currentlyEdtitingUserTag', { + tag, + index: tagMatch.index + }) + + const query = tag.substring(1) + + this.usersTask.perform(query).then(response => { + const records = get(response, 'hits') || []; + const users = records.map(record => ({ + name: record.name, + tag: record.slug ?? record.id, + avatar: record.avatar?.tiny, + })) + set(this, 'users', users); + set(this, 'isUserSearchOpen', true); + + if (this.get('previousUserSearchTimeoutId') !== null) clearTimeout(this.get('previousUserSearchTimeoutId')) + this.set('previousUserSearchTimeoutId', setTimeout(() => { + this.set('isUserSearchOpen', false); + }, 5000)); + }).catch(error => { + get(this, 'raven').captureException(error); + }); + } + } + } + } + + const didEditTag = this.get('currentlyEdtitingUserTag') !== null + if (!didEditTag) this.set('isUserSearchOpen', false); + this.set('previousContent', content); } } }); diff --git a/app/components/stream-feed/items/post/comment-box.js b/app/components/stream-feed/items/post/comment-box.js index 3add221ff7..a8d24417ee 100644 --- a/app/components/stream-feed/items/post/comment-box.js +++ b/app/components/stream-feed/items/post/comment-box.js @@ -10,6 +10,9 @@ import errorMessages from 'client/utils/error-messages'; import isFileValid from 'client/utils/is-file-valid'; const LINK_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; +const INDICES = { + users: ['id', 'slug', 'name', 'avatar'], +}; export default Component.extend({ classNames: ['comment-box'], @@ -18,12 +21,18 @@ export default Component.extend({ embedUrl: undefined, accept: 'image/jpg, image/jpeg, image/png, image/gif', dropzoneDisabled: notEmpty('upload'), + isUserSearchOpen: false, + previousUserSearchTimeoutId: null, + previousContent: null, + currentlyEdtitingUserTag: null, + users: [], ajax: service(), notify: service(), store: service(), fileQueue: service(), raven: service(), + algolia: service(), init() { this._super(...arguments); @@ -71,6 +80,24 @@ export default Component.extend({ }); }).restartable(), + usersTask: task(function* (query, options = {}) { + const index = yield get(this, 'algolia.getIndex').perform('users'); + if (isEmpty(index)) { + return {}; + } + return yield index.search(query, { + attributesToRetrieve: INDICES.users, + hitsPerPage: 4, + queryLanguages: ['en', 'ja'], + naturalLanguages: ['en', 'ja'], + attributesToHighlight: [], + responseFields: ['hits', 'hitsPerPage', 'nbHits', 'nbPages', 'offset', 'page'], + removeStopWords: false, + removeWordsIfNoResults: 'allOptional', + ...options + }); + }).restartable(), + actions: { submit(component, event, content) { if (isEmpty(content) === true && isEmpty(get(this, 'upload')) === true) { return; } @@ -136,6 +163,103 @@ export default Component.extend({ this.set('embedUrl', undefined); } invoke(this, 'processLinks', this.get('content'), true); + }, + + autocompleteUserTag(selectedTag, elementId) { + const currentlyEditingUserTag = this.get('currentlyEdtitingUserTag') + const textarea = document.getElementById(elementId) + + const newContent = + this.content.substring(0, currentlyEditingUserTag.index) + + '@' + + this.content.substring(currentlyEditingUserTag.index).replace(currentlyEditingUserTag.tag, selectedTag) + + this.set('isUserSearchOpen', false) + textarea.value = newContent + textarea.selectionEnd = newContent.indexOf(' ', currentlyEditingUserTag.index) + this.set('previousContent', newContent); + }, + + autocompleteUserTagOnTab(_, event) { + const elementId = event.target.id + const textarea = event.target + + if (this.get('currentlyEdtitingUserTag') !== null) event.preventDefault(); + + const user = this.get('users').at(0) + if (user === undefined) { + textarea.value = textarea.value + ' ' + this.set('previousContent', textarea.value); + return + } + + const selectedTag = user.tag + this.send("autocompleteUserTag", selectedTag, elementId) + }, + + closeUserTagSearch() { + this.set('isUserSearchOpen', false) + }, + + userTagSearch(content) { + const stringDiffIndex = (s1, s2) => { + let i = 0; + while (s1[i] === s2[i] && i <= Math.max(s1.length, s2.length)) i++; + return i + } + + const previousContent = this.get('previousContent') + + if (previousContent) { + const index = stringDiffIndex(previousContent, content) + const didTypeWhiteSpace = [' ', '\n'].includes(content.at(index)) && content.length > previousContent.length + + if (!didTypeWhiteSpace) { + const tagRegex = /(@[a-zA-Z])\w+/g + const tagMatches = content.matchAll(tagRegex) + + // Assume no tag is being edited each keystroke + this.set('currentlyEdtitingUserTag', null) + + for (const tagMatch of tagMatches) { + const tag = tagMatch[0] + const isEditingBeforeTagEnd = tagMatch.index + tag.length >= index + const isEditingAfterTagStart = index > tagMatch.index + const isEditingThisTag = isEditingAfterTagStart && isEditingBeforeTagEnd + + if (isEditingThisTag) { + this.set('currentlyEdtitingUserTag', { + tag, + index: tagMatch.index + }) + + const query = tag.substring(1) + + this.usersTask.perform(query).then(response => { + const records = get(response, 'hits') || []; + const users = records.map(record => ({ + name: record.name, + tag: record.slug ?? record.id, + avatar: record.avatar?.tiny, + })) + set(this, 'users', users); + set(this, 'isUserSearchOpen', true); + + if (this.get('previousUserSearchTimeoutId') !== null) clearTimeout(this.get('previousUserSearchTimeoutId')) + this.set('previousUserSearchTimeoutId', setTimeout(() => { + this.set('isUserSearchOpen', false); + }, 5000)); + }).catch(error => { + get(this, 'raven').captureException(error); + }); + } + } + } + } + + const didEditTag = this.get('currentlyEdtitingUserTag') !== null + if (!didEditTag) this.set('isUserSearchOpen', false); + this.set('previousContent', content); } } }); diff --git a/app/styles/layout/_feeds.scss b/app/styles/layout/_feeds.scss index 0bfa43290b..123f2596a7 100644 --- a/app/styles/layout/_feeds.scss +++ b/app/styles/layout/_feeds.scss @@ -1008,6 +1008,44 @@ color: $input-text-color; } +.userTagPopover { + display: flex; + flex-direction: column; + gap: 8px; + background-color: $secondary-background-color; + color: $white; + border-radius: 0.25rem; + padding: 4px; + + .userTagSearchResult { + display: grid; + grid-template-columns: 40px auto; + column-gap: 12px; + padding: 6px 10px; + border-radius: inherit; + + &:hover { + background-color: lighten($secondary-background-color, 10); + } + + .userAvatar img { + border-radius: 100%; + } + + .userName { + line-height: 1; + } + + .userTag { + color: $body-link-color; + + &::before { + content: '@'; + } + } + } +} + .stream-content { @include word-break(); overflow: hidden; diff --git a/app/templates/components/stream-feed/create-post.hbs b/app/templates/components/stream-feed/create-post.hbs index f87e24a2d8..8acdc446d8 100644 --- a/app/templates/components/stream-feed/create-post.hbs +++ b/app/templates/components/stream-feed/create-post.hbs @@ -22,16 +22,43 @@ {{#file-dropzone name="uploads" as |dropzone uploadQueue|}} {{! editor }} {{expanding-textarea content - update=(action (queue (action (mut content)) (action "processLinks"))) + update=(action (queue (action (mut content)) (action "processLinks") (action "userTagSearch"))) keyEvents=(hash 13=(action "createPost") + 9=(action "autocompleteUserTagOnTab") + 27=(action "closeUserTagSearch") ) onpaste=(action "paste") autofocus=true maxlength=maxLength class="stream-content-editor" + id="create-post-textarea" }} + {{#if (and users isUserSearchOpen)}} +