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
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