Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to search for user tags on comment and post inputs #2185

Open
wants to merge 1 commit into
base: the-future
Choose a base branch
from
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
123 changes: 123 additions & 0 deletions app/components/stream-feed/create-post.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -29,13 +32,19 @@ export default Component.extend({
maxLength: 9000,
_usableMedia: null,
embedUrl: null,
isUserSearchOpen: false,
previousUserSearchTimeoutId: null,
previousContent: null,
currentlyEdtitingUserTag: null,
users: [],

ajax: service(),
store: service(),
queryCache: service(),
fileQueue: service(),
notify: service(),
raven: service(),
algolia: service(),

canPost: or('contentPresent', 'uploadsReady', 'embedUrl'),
uploadsReady: and('uploadsPresent', 'queueFinished'),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
});
124 changes: 124 additions & 0 deletions app/components/stream-feed/items/post/comment-box.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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);
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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);
}
}
});
38 changes: 38 additions & 0 deletions app/styles/layout/_feeds.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading