mirror of
https://github.com/discourse/discourse.git
synced 2025-01-16 03:42:41 +08:00
DEV: unifies emoji picker (#28277)
The chat emoji picker is renamed emoji-picker, and the old emoji-picker is removed. This commit doesn't attempt to fully rework a new emoji-picker but instead tries to migrate everything to one picker (the chat one) and add small changes. Other notable changes: - all the favorite emojis code has been mixed into one service which is able to store one state per context, favorites emojis will be stored for all topics, and for each chat channel. Meaning that if you always use a specific emoji in a channel, it will only show as favorite emoji in this channel. - a lot of static code has been removed which should improve initial load perf of discourse. Initially this code was around to improve the performance of the emoji picker rendering. - the emojis are now stored, once the full list has been loaded, if you close and reopen the picker it won't have to load them again. List of components: - `<EmojiPicker />` will render a button which will open a dropdown - `<EmojiPickerContent />` represents the content of the dropdown alone, it's useful when you want to render a picker from an action which is not the default picker button - `<EmojiPickerDetached />` just a simple wrapper over `<EmojiPickerContent />` to make it easier to use it with `this.menu.show(...)` --------- Co-authored-by: Renato Atilio <renatoat@gmail.com>
This commit is contained in:
parent
d9ddc25808
commit
6740a340ca
|
@ -43,17 +43,8 @@
|
|||
{{/if}}
|
||||
|
||||
<div class="value">
|
||||
<DButton
|
||||
@action={{fn this.editValue this.data}}
|
||||
@icon="discourse-emojis"
|
||||
@label="admin.site_settings.emoji_list.add_emoji_button.label"
|
||||
class="add-emoji-button d-editor-textarea-wrapper"
|
||||
<EmojiPicker
|
||||
@label={{i18n "admin.site_settings.emoji_list.add_emoji_button.label"}}
|
||||
@didSelectEmoji={{this.emojiSelected}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmojiPicker
|
||||
@isActive={{this.emojiPickerIsActive}}
|
||||
@isEditorFocused={{this.isEditorFocused}}
|
||||
@emojiSelected={{this.emojiSelected}}
|
||||
@onEmojiPickerClose={{this.closeEmojiPicker}}
|
||||
/>
|
|
@ -1,17 +1,18 @@
|
|||
import Component from "@ember/component";
|
||||
import { action, set, setProperties } from "@ember/object";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { classNameBindings } from "@ember-decorators/component";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import { emojiUrlFor } from "discourse/lib/text";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
@classNameBindings(":value-list", ":emoji-list")
|
||||
export default class EmojiValueList extends Component {
|
||||
@service menu;
|
||||
|
||||
values = null;
|
||||
emojiPickerIsActive = false;
|
||||
isEditorFocused = false;
|
||||
|
||||
@discourseComputed("values")
|
||||
collection(values) {
|
||||
|
@ -30,13 +31,6 @@ export default class EmojiValueList extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
@action
|
||||
closeEmojiPicker() {
|
||||
this.collection.setEach("isEditing", false);
|
||||
this.set("emojiPickerIsActive", false);
|
||||
this.set("isEditorFocused", false);
|
||||
}
|
||||
|
||||
@action
|
||||
emojiSelected(code) {
|
||||
if (!this._validateInput(code)) {
|
||||
|
@ -62,9 +56,6 @@ export default class EmojiValueList extends Component {
|
|||
this.collection.addObject(newCollectionValue);
|
||||
this._saveValues();
|
||||
}
|
||||
|
||||
this.set("emojiPickerIsActive", false);
|
||||
this.set("isEditorFocused", false);
|
||||
}
|
||||
|
||||
@discourseComputed("collection")
|
||||
|
@ -94,8 +85,7 @@ export default class EmojiValueList extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
editValue(index) {
|
||||
this.closeEmojiPicker();
|
||||
editValue(index, event) {
|
||||
schedule("afterRender", () => {
|
||||
if (parseInt(index, 10) >= 0) {
|
||||
const item = this.collection[index];
|
||||
|
@ -104,12 +94,18 @@ export default class EmojiValueList extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
this.set("isEditorFocused", true);
|
||||
discourseLater(() => {
|
||||
if (this.element && !this.isDestroying && !this.isDestroyed) {
|
||||
this.set("emojiPickerIsActive", true);
|
||||
}
|
||||
}, 100);
|
||||
this.menu.show(event.target, {
|
||||
identifier: "emoji-picker",
|
||||
groupIdentifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
modalForMobile: true,
|
||||
data: {
|
||||
context: "chat",
|
||||
didSelectEmoji: (emoji) => {
|
||||
this._replaceValue(index, emoji);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -77,11 +77,3 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmojiPicker
|
||||
@isActive={{this.emojiPickerIsActive}}
|
||||
@isEditorFocused={{this.isEditorFocused}}
|
||||
@initialFilter={{this.emojiFilter}}
|
||||
@emojiSelected={{this.textManipulation.emojiSelected}}
|
||||
@onEmojiPickerClose={{this.onEmojiPickerClose}}
|
||||
/>
|
|
@ -10,6 +10,7 @@ import { translations } from "pretty-text/emoji/data";
|
|||
import { resolveCachedShortUrls } from "pretty-text/upload-short-url";
|
||||
import { Promise } from "rsvp";
|
||||
import TextareaEditor from "discourse/components/composer/textarea-editor";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { SKIP } from "discourse/lib/autocomplete";
|
||||
|
@ -27,6 +28,7 @@ import {
|
|||
initUserStatusHtml,
|
||||
renderUserStatusHtml,
|
||||
} from "discourse/lib/user-status-on-autocomplete";
|
||||
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import deprecated from "discourse-common/lib/deprecated";
|
||||
|
@ -53,8 +55,9 @@ export function onToolbarCreate(func) {
|
|||
|
||||
@classNames("d-editor")
|
||||
export default class DEditor extends Component {
|
||||
@service("emoji-store") emojiStore;
|
||||
@service emojiStore;
|
||||
@service modal;
|
||||
@service menu;
|
||||
|
||||
editorComponent = TextareaEditor;
|
||||
textManipulation;
|
||||
|
@ -62,8 +65,6 @@ export default class DEditor extends Component {
|
|||
ready = false;
|
||||
lastSel = null;
|
||||
showLink = true;
|
||||
emojiPickerIsActive = false;
|
||||
emojiFilter = "";
|
||||
isEditorFocused = false;
|
||||
processPreview = true;
|
||||
morphingOptions = {
|
||||
|
@ -347,13 +348,25 @@ export default class DEditor extends Component {
|
|||
|
||||
transformComplete: (v) => {
|
||||
if (v.code) {
|
||||
this.emojiStore.track(v.code);
|
||||
this.emojiStore.trackEmojiForContext(v.code, "topic");
|
||||
return `${v.code}:`;
|
||||
} else {
|
||||
this.textManipulation.autocomplete({ cancel: true });
|
||||
this.set("emojiPickerIsActive", true);
|
||||
this.set("emojiFilter", v.term);
|
||||
|
||||
const menuOptions = {
|
||||
identifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
modalForMobile: true,
|
||||
data: {
|
||||
didSelectEmoji: (emoji) => {
|
||||
this.textManipulation.emojiSelected(emoji);
|
||||
},
|
||||
term: v.term,
|
||||
},
|
||||
};
|
||||
|
||||
const virtualElement = virtualElementFromTextRange();
|
||||
this.menuInstance = this.menu.show(virtualElement, menuOptions);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
@ -368,9 +381,10 @@ export default class DEditor extends Component {
|
|||
}
|
||||
|
||||
if (term === "") {
|
||||
if (this.emojiStore.favorites.length) {
|
||||
const favorites = this.emojiStore.favoritesForContext("topic");
|
||||
if (favorites.length) {
|
||||
return resolve(
|
||||
this.emojiStore.favorites
|
||||
favorites
|
||||
.filter((f) => !this.site.denied_emojis?.includes(f))
|
||||
.slice(0, 5)
|
||||
);
|
||||
|
@ -515,13 +529,6 @@ export default class DEditor extends Component {
|
|||
return true;
|
||||
}
|
||||
|
||||
@action
|
||||
onEmojiPickerClose() {
|
||||
if (!(this.isDestroyed || this.isDestroying)) {
|
||||
this.set("emojiPickerIsActive", false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a toolbar event object passed to toolbar buttons.
|
||||
*
|
||||
|
@ -568,15 +575,6 @@ export default class DEditor extends Component {
|
|||
};
|
||||
}
|
||||
|
||||
@action
|
||||
emoji() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("emojiPickerIsActive", !this.emojiPickerIsActive);
|
||||
}
|
||||
|
||||
@action
|
||||
toolbarButton(button) {
|
||||
if (this.disabled) {
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
{{! DO NOT EDIT THIS FILE!!! }}
|
||||
{{! Update it by running `rake javascript:update_constants` }}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-section="smileys_&_emotion"
|
||||
{{on "click" (fn this.onCategorySelection "smileys_&_emotion")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":grinning:"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-section="people_&_body"
|
||||
{{on "click" (fn this.onCategorySelection "people_&_body")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":wave:"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-section="animals_&_nature"
|
||||
{{on "click" (fn this.onCategorySelection "animals_&_nature")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":evergreen_tree:"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-section="food_&_drink"
|
||||
{{on "click" (fn this.onCategorySelection "food_&_drink")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":hamburger:"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-section="travel_&_places"
|
||||
{{on "click" (fn this.onCategorySelection "travel_&_places")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":airplane:"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-section="activities"
|
||||
{{on "click" (fn this.onCategorySelection "activities")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":soccer:"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-section="objects"
|
||||
{{on "click" (fn this.onCategorySelection "objects")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":eyeglasses:"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-section="symbols"
|
||||
{{on "click" (fn this.onCategorySelection "symbols")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":white_check_mark:"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-section="flags"
|
||||
{{on "click" (fn this.onCategorySelection "flags")}}
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":checkered_flag:"}}
|
||||
</button>
|
|
@ -1,3 +0,0 @@
|
|||
import Component from "@ember/component";
|
||||
|
||||
export default class EmojiGroupButtons extends Component {}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,3 +0,0 @@
|
|||
import Component from "@ember/component";
|
||||
|
||||
export default class EmojiGroupSections extends Component {}
|
|
@ -1,154 +0,0 @@
|
|||
{{#if this.isActive}}
|
||||
{{! template-lint-disable no-invalid-interactive no-pointer-down-event-binding }}
|
||||
<div
|
||||
{{on "keydown" (action "keydown")}}
|
||||
class="emoji-picker {{if this.isActive 'opened'}}"
|
||||
>
|
||||
{{! template-lint-enable no-invalid-interactive no-pointer-down-event-binding }}
|
||||
<div class="emoji-picker-category-buttons">
|
||||
{{#if this.recentEmojis.length}}
|
||||
<button
|
||||
{{on "click" (fn this.onCategorySelection "recent")}}
|
||||
data-section="recent"
|
||||
type="button"
|
||||
class="btn btn-flat category-button emoji"
|
||||
>
|
||||
{{replace-emoji ":star:"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
<EmojiGroupButtons
|
||||
@onCategorySelection={{this.onCategorySelection}}
|
||||
@tagName=""
|
||||
/>
|
||||
|
||||
{{#each-in this.customEmojis as |group emojis|}}
|
||||
<button
|
||||
{{on "click" (fn this.onCategorySelection (concat "custom-" group))}}
|
||||
data-section={{concat "custom-" group}}
|
||||
type="button"
|
||||
class="btn btn-default category-button emoji"
|
||||
>
|
||||
{{replace-emoji (concat ":" (get emojis "0.code") ":")}}
|
||||
</button>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
|
||||
<div class="emoji-picker-content">
|
||||
<div class="emoji-picker-search-container">
|
||||
<Input
|
||||
class="filter"
|
||||
name="filter"
|
||||
@value={{@initialFilter}}
|
||||
placeholder={{i18n "emoji_picker.filter_placeholder"}}
|
||||
autocomplete="off"
|
||||
@type="search"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
{{on "input" (action "onFilterChange")}}
|
||||
/>
|
||||
|
||||
{{d-icon "magnifying-glass"}}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="emoji-picker-emoji-area"
|
||||
role="button"
|
||||
{{on "click" this.onEmojiSelection}}
|
||||
{{on "mouseover" this.onEmojiHover}}
|
||||
>
|
||||
<div class="results"></div>
|
||||
|
||||
<div class="emojis-container">
|
||||
{{#if this.recentEmojis.length}}
|
||||
<div class="section recent" data-section="recent">
|
||||
<div class="section-header">
|
||||
<span class="title">{{i18n "emoji_picker.recent"}}</span>
|
||||
<DButton
|
||||
@icon="trash-can"
|
||||
@action={{this.onClearRecent}}
|
||||
class="trash-recent"
|
||||
/>
|
||||
</div>
|
||||
<div class="section-group">
|
||||
{{#each this.recentEmojis as |emoji|}}
|
||||
{{replace-emoji
|
||||
(concat ":" emoji ":")
|
||||
(hash lazy=true tabIndex="0" class="recent-emoji")
|
||||
}}
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<EmojiGroupSections />
|
||||
|
||||
{{#each-in this.customEmojis as |group emojis|}}
|
||||
<div class="section" data-section="custom-{{group}}">
|
||||
<div class="section-header">
|
||||
<span class="title">
|
||||
{{i18n
|
||||
(concat "emoji_picker." group)
|
||||
translatedFallback=group
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
{{#if emojis.length}}
|
||||
<div class="section-group">
|
||||
{{#each emojis as |emoji|}}
|
||||
<span>
|
||||
<img
|
||||
title={{emoji.code}}
|
||||
width="20"
|
||||
height="20"
|
||||
loading="lazy"
|
||||
class="emoji"
|
||||
src={{emoji.src}}
|
||||
/>
|
||||
</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="emoji-picker-footer">
|
||||
<PluginOutlet
|
||||
@name="emoji-picker-footer"
|
||||
@outletArgs={{hash
|
||||
diversityScales=this.diversityScales
|
||||
hoveredEmoji=this.hoveredEmoji
|
||||
onDiversitySelection=this.onDiversitySelection
|
||||
}}
|
||||
>
|
||||
<div class="emoji-picker-emoji-info">
|
||||
{{#if this.hoveredEmoji}}
|
||||
{{replace-emoji (concat ":" this.hoveredEmoji ":")}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="emoji-picker-diversity-picker">
|
||||
{{#each this.diversityScales as |diversityScale index|}}
|
||||
<DButton
|
||||
@icon={{diversityScale.icon}}
|
||||
@title={{diversityScale.title}}
|
||||
@action={{fn this.onDiversitySelection index}}
|
||||
class={{concat-class "diversity-scale" diversityScale.name}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
</PluginOutlet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.site.mobileView}}
|
||||
<div
|
||||
role="button"
|
||||
class="emoji-picker-modal-overlay"
|
||||
{{on "click" this.onClose}}
|
||||
></div>
|
||||
{{/if}}
|
||||
{{/if}}
|
|
@ -1,467 +0,0 @@
|
|||
import Component from "@ember/component";
|
||||
import { action, computed } from "@ember/object";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { underscore } from "@ember/string";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { tagName } from "@ember-decorators/component";
|
||||
import { observes } from "@ember-decorators/object";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import {
|
||||
emojiSearch,
|
||||
extendedEmojiList,
|
||||
isSkinTonableEmoji,
|
||||
} from "pretty-text/emoji";
|
||||
import { emojiUnescape, emojiUrlFor } from "discourse/lib/text";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import discourseComputed, { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
function customEmojis() {
|
||||
const groups = [];
|
||||
for (const [code, emoji] of extendedEmojiList()) {
|
||||
groups[emoji.group] ||= [];
|
||||
groups[emoji.group].push({ code, src: emojiUrlFor(code) });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
@tagName("")
|
||||
export default class EmojiPicker extends Component {
|
||||
@service emojiStore;
|
||||
|
||||
customEmojis = customEmojis();
|
||||
recentEmojis = null;
|
||||
hoveredEmoji = null;
|
||||
isActive = false;
|
||||
usePopper = true;
|
||||
placement = "auto"; // one of popper.js' placements, see https://popper.js.org/docs/v2/constructors/#options
|
||||
initialFilter = "";
|
||||
|
||||
elements = {
|
||||
searchInput: ".emoji-picker-search-container input",
|
||||
picker: ".emoji-picker-emoji-area",
|
||||
};
|
||||
|
||||
didInsertElement() {
|
||||
super.didInsertElement(...arguments);
|
||||
this._sectionObserver = this._setupSectionObserver();
|
||||
this.appEvents.on("emoji-picker:close", this, "onClose");
|
||||
}
|
||||
|
||||
// `readOnly` may seem like a better choice here, but the computed property
|
||||
// provides caching (emojiStore.diversity is a simple getter)
|
||||
@discourseComputed("emojiStore.diversity")
|
||||
selectedDiversity(diversity) {
|
||||
return diversity;
|
||||
}
|
||||
|
||||
// didReceiveAttrs would be a better choice here, but this is sadly causing
|
||||
// too many unexpected reloads as it's triggered for other reasons than a mutation
|
||||
// of isActive
|
||||
@observes("isActive")
|
||||
_setup() {
|
||||
if (this.isActive) {
|
||||
this.onShow();
|
||||
} else {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
willDestroyElement() {
|
||||
super.willDestroyElement(...arguments);
|
||||
this._sectionObserver?.disconnect();
|
||||
this.appEvents.off("emoji-picker:close", this, "onClose");
|
||||
}
|
||||
|
||||
@action
|
||||
onShow() {
|
||||
this.set("recentEmojis", this.emojiStore.favorites);
|
||||
|
||||
schedule("afterRender", () => {
|
||||
this._applyFilter(this.initialFilter);
|
||||
|
||||
const emojiPicker = document.querySelector(".emoji-picker");
|
||||
if (!emojiPicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
|
||||
const popperAnchor = this._getPopperAnchor();
|
||||
|
||||
if (!this.site.isMobileDevice && this.usePopper && popperAnchor) {
|
||||
const modifiers = [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
},
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [5, 5],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
this.placement === "auto" &&
|
||||
window.innerWidth < popperAnchor.clientWidth * 2
|
||||
) {
|
||||
modifiers.push({
|
||||
name: "computeStyles",
|
||||
enabled: true,
|
||||
fn({ state }) {
|
||||
state.styles.popper = {
|
||||
...state.styles.popper,
|
||||
position: "fixed",
|
||||
left: `${(window.innerWidth - state.rects.popper.width) / 2}px`,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
};
|
||||
|
||||
return state;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this._popper = createPopper(popperAnchor, emojiPicker, {
|
||||
placement: this.placement,
|
||||
});
|
||||
}
|
||||
|
||||
// this is a low-tech trick to prevent appending hundreds of emojis
|
||||
// of blocking the rendering of the picker
|
||||
discourseLater(() => {
|
||||
schedule("afterRender", () => {
|
||||
if (!this.site.isMobileDevice || this.isEditorFocused) {
|
||||
emojiPicker.querySelector("input.filter")?.focus();
|
||||
|
||||
if (this._sectionObserver) {
|
||||
emojiPicker
|
||||
.querySelectorAll(".emojis-container .section .section-header")
|
||||
.forEach((p) => this._sectionObserver.observe(p));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selectedDiversity !== 0) {
|
||||
this._applyDiversity(this.selectedDiversity);
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onClose(event) {
|
||||
event?.stopPropagation();
|
||||
document.removeEventListener("click", this.handleOutsideClick);
|
||||
this.onEmojiPickerClose?.(event);
|
||||
}
|
||||
|
||||
@computed("selectedDiversity")
|
||||
get diversityScales() {
|
||||
return [
|
||||
"default",
|
||||
"light",
|
||||
"medium-light",
|
||||
"medium",
|
||||
"medium-dark",
|
||||
"dark",
|
||||
].map((name, index) => {
|
||||
return {
|
||||
name,
|
||||
title: `emoji_picker.${underscore(name)}_tone`,
|
||||
icon: index + 1 === this.selectedDiversity ? "check" : "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onClearRecent() {
|
||||
this.emojiStore.favorites = [];
|
||||
this.set("recentEmojis", []);
|
||||
}
|
||||
|
||||
@action
|
||||
onDiversitySelection(index) {
|
||||
const scale = index + 1;
|
||||
this.emojiStore.diversity = scale;
|
||||
|
||||
this._applyDiversity(scale);
|
||||
}
|
||||
|
||||
@action
|
||||
onEmojiHover(event) {
|
||||
const img = event.target;
|
||||
if (!img.classList.contains("emoji") || img.tagName !== "IMG") {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._updateEmojiPreview(event.target.title);
|
||||
}
|
||||
|
||||
@action
|
||||
onEmojiSelection(event) {
|
||||
const img = event.target;
|
||||
|
||||
if (!img.classList.contains("emoji") || img.tagName !== "IMG") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let code = event.target.title;
|
||||
code = this._codeWithDiversity(code, this.selectedDiversity);
|
||||
|
||||
this.emojiSelected(code);
|
||||
|
||||
this._trackEmojiUsage(code, {
|
||||
refresh: !img.parentNode.parentNode.classList.contains("recent"),
|
||||
});
|
||||
|
||||
if (this.site.isMobileDevice) {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onCategorySelection(sectionName, event) {
|
||||
event?.preventDefault();
|
||||
document
|
||||
.querySelector(
|
||||
`.emoji-picker-emoji-area .section[data-section="${sectionName}"]`
|
||||
)
|
||||
?.scrollIntoView();
|
||||
}
|
||||
|
||||
@action
|
||||
keydown(event) {
|
||||
const arrowKeys = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"];
|
||||
const emojis = document.querySelectorAll(".emoji-picker-emoji-area .emoji");
|
||||
|
||||
let currentEmoji;
|
||||
|
||||
if (
|
||||
event.key === "ArrowDown" &&
|
||||
this._focusedOn(this.elements.searchInput)
|
||||
) {
|
||||
this._updateEmojiPreview(emojis[0].title);
|
||||
emojis[0].focus();
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.onClose(event);
|
||||
const path = event.path || event.composedPath?.();
|
||||
|
||||
const fromChatComposer = path.find((e) =>
|
||||
e?.classList?.contains("chat-composer-container")
|
||||
);
|
||||
|
||||
const fromTopicComposer = path.find((e) =>
|
||||
e?.classList?.contains("d-editor")
|
||||
);
|
||||
|
||||
if (fromTopicComposer) {
|
||||
document.querySelector(".d-editor-input")?.focus();
|
||||
} else if (fromChatComposer) {
|
||||
document.querySelector(".chat-composer__input")?.focus();
|
||||
} else {
|
||||
document.querySelector("textarea")?.focus();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (arrowKeys.includes(event.key)) {
|
||||
if (!this._focusedOn(this.elements.picker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(emojis).find((e, index) => {
|
||||
currentEmoji = index;
|
||||
return e.isEqualNode(event.target);
|
||||
});
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
let nextEmoji = currentEmoji + 1;
|
||||
|
||||
if (nextEmoji < emojis.length) {
|
||||
this._updateEmojiPreview(emojis[nextEmoji].title);
|
||||
emojis[nextEmoji].focus();
|
||||
} else if (nextEmoji >= emojis.length) {
|
||||
this._updateEmojiPreview(emojis[0].title);
|
||||
emojis[0].focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
const previousEmoji = currentEmoji - 1;
|
||||
if (currentEmoji > 0) {
|
||||
this._updateEmojiPreview(emojis[previousEmoji].title);
|
||||
emojis[previousEmoji].focus();
|
||||
}
|
||||
}
|
||||
|
||||
const active = emojis[currentEmoji];
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
// source: https://stackoverflow.com/a/49090383/349424
|
||||
// look for same element type with
|
||||
// - higher offsetTop
|
||||
// - same offsetLeft
|
||||
const emojiBelow = [...emojis]
|
||||
.filter((c) => c.offsetTop > active.offsetTop)
|
||||
.find((c) => c.offsetLeft === active.offsetLeft);
|
||||
if (emojiBelow) {
|
||||
this._updateEmojiPreview(emojiBelow.title);
|
||||
emojiBelow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
// look for same element type with
|
||||
// - lower offsetTop
|
||||
// - same offsetLeft
|
||||
const emojiAbove = [...emojis]
|
||||
.reverse()
|
||||
.filter((c) => c.offsetTop < active.offsetTop)
|
||||
.find((c) => c.offsetLeft === active.offsetLeft);
|
||||
|
||||
if (emojiAbove) {
|
||||
this._updateEmojiPreview(emojiAbove.title);
|
||||
emojiAbove.focus();
|
||||
} else {
|
||||
this.set("hoveredEmoji", null);
|
||||
document.querySelector(this.elements.searchInput).focus();
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (!this._focusedOn(".emoji")) {
|
||||
return;
|
||||
}
|
||||
this.onEmojiSelection(event);
|
||||
this.onClose(event);
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onFilterChange(event) {
|
||||
this._applyFilter(event.target.value);
|
||||
}
|
||||
|
||||
_focusedOn(item) {
|
||||
// returns the item currently being focused on
|
||||
return document.activeElement.closest(item) ? document.activeElement : null;
|
||||
}
|
||||
|
||||
_applyFilter(filter) {
|
||||
const emojiPicker = document.querySelector(".emoji-picker");
|
||||
const results = document.querySelector(".emoji-picker-emoji-area .results");
|
||||
results.innerHTML = "";
|
||||
|
||||
if (filter) {
|
||||
results.innerHTML = emojiSearch(filter.toLowerCase(), {
|
||||
diversity: this.emojiStore.diversity,
|
||||
exclude: this.site.denied_emojis,
|
||||
})
|
||||
.map(this._replaceEmoji)
|
||||
.join("");
|
||||
|
||||
emojiPicker.classList.add("has-filter");
|
||||
results.scrollIntoView();
|
||||
} else {
|
||||
emojiPicker.classList.remove("has-filter");
|
||||
}
|
||||
}
|
||||
|
||||
_trackEmojiUsage(code, options = {}) {
|
||||
this.emojiStore.track(code);
|
||||
|
||||
if (options.refresh) {
|
||||
this.set("recentEmojis", [...this.emojiStore.favorites]);
|
||||
}
|
||||
}
|
||||
|
||||
_replaceEmoji(code) {
|
||||
const escaped = emojiUnescape(`:${escapeExpression(code)}:`, {
|
||||
lazy: true,
|
||||
tabIndex: "0",
|
||||
});
|
||||
return htmlSafe(escaped);
|
||||
}
|
||||
|
||||
_codeWithDiversity(code, selectedDiversity) {
|
||||
if (/:t\d/.test(code)) {
|
||||
return code;
|
||||
} else if (selectedDiversity > 1 && isSkinTonableEmoji(code)) {
|
||||
return `${code}:t${selectedDiversity}`;
|
||||
} else {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
_applyDiversity(diversity) {
|
||||
const emojiPickerArea = document.querySelector(".emoji-picker-emoji-area");
|
||||
emojiPickerArea?.querySelectorAll(".emoji.diversity").forEach((img) => {
|
||||
img.src = emojiUrlFor(this._codeWithDiversity(img.title, diversity));
|
||||
});
|
||||
}
|
||||
|
||||
_setupSectionObserver() {
|
||||
return new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const sectionName = entry.target.parentNode.dataset.section;
|
||||
const categoryButtons = document.querySelector(
|
||||
".emoji-picker .emoji-picker-category-buttons"
|
||||
);
|
||||
|
||||
if (!categoryButtons) {
|
||||
return;
|
||||
}
|
||||
|
||||
categoryButtons
|
||||
.querySelectorAll(".category-button")
|
||||
.forEach((b) => b.classList.remove("current"));
|
||||
|
||||
categoryButtons
|
||||
.querySelector(`.category-button[data-section="${sectionName}"]`)
|
||||
?.classList?.add("current");
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
_getPopperAnchor() {
|
||||
// .d-editor-textarea-wrapper is only for backward compatibility here
|
||||
// in new code use .emoji-picker-anchor
|
||||
return (
|
||||
document.querySelector(".emoji-picker-anchor") ??
|
||||
document.querySelector(".d-editor-textarea-wrapper")
|
||||
);
|
||||
}
|
||||
|
||||
_updateEmojiPreview(title) {
|
||||
return this.set(
|
||||
"hoveredEmoji",
|
||||
this._codeWithDiversity(title, this.selectedDiversity)
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
handleOutsideClick(event) {
|
||||
if (!event.target.closest(".emoji-picker")) {
|
||||
this.onClose(event);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,620 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { concat, fn, hash } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action, get } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { cancel, next, schedule } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { modifier as modifierFn } from "ember-modifier";
|
||||
import { eq, gt, includes, notEq } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import FilterInput from "discourse/components/filter-input";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import noop from "discourse/helpers/noop";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import withEventValue from "discourse/helpers/with-event-value";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import {
|
||||
disableBodyScroll,
|
||||
enableBodyScroll,
|
||||
} from "discourse/lib/body-scroll-lock";
|
||||
import { emojiUrlFor } from "discourse/lib/text";
|
||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { makeArray } from "discourse-common/lib/helpers";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DiversityMenu from "./diversity-menu";
|
||||
|
||||
const DEFAULT_VISIBLE_SECTIONS = ["favorites", "smileys_&_emotion"];
|
||||
const DEFAULT_LAST_SECTION = "favorites";
|
||||
|
||||
const tonableEmojiTitle = (emoji, diversity) => {
|
||||
if (!emoji.tonable || diversity === 1) {
|
||||
return `:${emoji.name}:`;
|
||||
}
|
||||
|
||||
return `:${emoji.name}:t${diversity}:`;
|
||||
};
|
||||
|
||||
const tonableEmojiUrl = (emoji, scale) => {
|
||||
if (!emoji.tonable || scale === 1) {
|
||||
return emoji.url;
|
||||
}
|
||||
|
||||
return emoji.url.split(".png")[0] + `/${scale}.png`;
|
||||
};
|
||||
|
||||
export default class EmojiPicker extends Component {
|
||||
@service emojiStore;
|
||||
@service capabilities;
|
||||
@service site;
|
||||
|
||||
@tracked filteredEmojis = null;
|
||||
@tracked scrollObserverEnabled = true;
|
||||
@tracked scrollDirection = "up";
|
||||
@tracked emojis = null;
|
||||
@tracked visibleSections = DEFAULT_VISIBLE_SECTIONS;
|
||||
@tracked lastVisibleSection = DEFAULT_LAST_SECTION;
|
||||
@tracked term = this.args.term;
|
||||
|
||||
prevYPosition = 0;
|
||||
|
||||
scrollableNode;
|
||||
|
||||
setupSectionsNavScroll = modifierFn((element) => {
|
||||
disableBodyScroll(element);
|
||||
|
||||
return () => {
|
||||
enableBodyScroll(element);
|
||||
};
|
||||
});
|
||||
|
||||
scrollListener = modifierFn((element) => {
|
||||
this.scrollableNode = element;
|
||||
disableBodyScroll(element);
|
||||
element.addEventListener("scroll", this._handleScroll);
|
||||
|
||||
return () => {
|
||||
this.scrollableNode = null;
|
||||
element.removeEventListener("scroll", this._handleScroll);
|
||||
enableBodyScroll(element);
|
||||
};
|
||||
});
|
||||
|
||||
addVisibleSections(sections) {
|
||||
this.visibleSections = makeArray(this.visibleSections)
|
||||
.concat(makeArray(sections))
|
||||
.uniq();
|
||||
}
|
||||
|
||||
get sections() {
|
||||
return !this.loading && this.emojiStore.list
|
||||
? Object.keys(this.emojiStore.list)
|
||||
: [];
|
||||
}
|
||||
|
||||
get groups() {
|
||||
const favorites = {
|
||||
favorites: this.emojiStore
|
||||
.favoritesForContext(this.args.context)
|
||||
.filter((f) => !this.site.denied_emojis?.includes(f))
|
||||
.map((emoji) => {
|
||||
return {
|
||||
name: emoji,
|
||||
group: "favorites",
|
||||
url: emojiUrlFor(emoji),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
...favorites,
|
||||
...this.emojiStore.list,
|
||||
};
|
||||
}
|
||||
|
||||
get flatEmojis() {
|
||||
if (!this.emojiStore.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let { favorites, ...rest } = this.emojiStore.list;
|
||||
return Object.values(rest).flat();
|
||||
}
|
||||
|
||||
@action
|
||||
registerFilterInput(element) {
|
||||
this.filterInput = element;
|
||||
}
|
||||
|
||||
@action
|
||||
clearFavorites() {
|
||||
this.emojiStore.resetContext(this.args.context);
|
||||
}
|
||||
|
||||
@action
|
||||
trapKeyDownEvents(event) {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" && event.target === this.filterInput) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
this.scrollableNode.querySelector(`.emoji[tabindex="0"]`)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didInputFilter(value) {
|
||||
if (!value?.length) {
|
||||
cancel(this.debouncedFilterHandler);
|
||||
this.filteredEmojis = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.debouncedFilterHandler = discourseDebounce(
|
||||
this,
|
||||
this.debouncedDidInputFilter,
|
||||
value,
|
||||
INPUT_DELAY
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
focusFilter(target) {
|
||||
target?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
debouncedDidInputFilter(filter = "") {
|
||||
filter = filter.toLowerCase();
|
||||
|
||||
this.filteredEmojis = this.flatEmojis.filter(
|
||||
(emoji) =>
|
||||
emoji.name.toLowerCase().includes(filter) ||
|
||||
emoji.search_aliases?.any((alias) =>
|
||||
alias.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
|
||||
schedule("afterRender", () => {
|
||||
if (this.scrollableNode) {
|
||||
this.scrollableNode.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onSectionsKeyDown(event) {
|
||||
if (event.key === "Enter") {
|
||||
this.didSelectEmoji(event);
|
||||
} else {
|
||||
this.didNavigateSection(event);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didNavigateSection(event) {
|
||||
const sectionsEmojis = (section) => [...section.querySelectorAll(".emoji")];
|
||||
const focusSectionsLastEmoji = (section) => {
|
||||
const emojis = sectionsEmojis(section);
|
||||
return emojis[emojis.length - 1].focus();
|
||||
};
|
||||
const focusSectionsFirstEmoji = (section) => {
|
||||
sectionsEmojis(section)[0].focus();
|
||||
};
|
||||
const currentSection = event.target.closest(".emoji-picker__section");
|
||||
const focusFilter = () => {
|
||||
this.filterInput?.focus();
|
||||
};
|
||||
const allEmojis = () => [
|
||||
...document.querySelectorAll(
|
||||
".emoji-picker__section:not(.hidden) .emoji"
|
||||
),
|
||||
];
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const nextEmoji = event.target.nextElementSibling;
|
||||
|
||||
if (nextEmoji) {
|
||||
nextEmoji.focus();
|
||||
} else {
|
||||
const nextSection = currentSection.nextElementSibling;
|
||||
if (nextSection) {
|
||||
focusSectionsFirstEmoji(nextSection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
const prevEmoji = event.target.previousElementSibling;
|
||||
|
||||
if (prevEmoji) {
|
||||
prevEmoji.focus();
|
||||
} else {
|
||||
const prevSection = currentSection.previousElementSibling;
|
||||
if (prevSection) {
|
||||
focusSectionsLastEmoji(prevSection);
|
||||
} else {
|
||||
focusFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const nextEmoji = allEmojis()
|
||||
.filter((c) => c.offsetTop > event.target.offsetTop)
|
||||
.findBy("offsetLeft", event.target.offsetLeft);
|
||||
|
||||
if (nextEmoji) {
|
||||
nextEmoji.focus();
|
||||
} else {
|
||||
// for perf reason all emojis might not be loaded at this point
|
||||
// but the first one will always be
|
||||
const nextSection = currentSection.nextElementSibling;
|
||||
if (nextSection) {
|
||||
focusSectionsFirstEmoji(nextSection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const prevEmoji = allEmojis()
|
||||
.reverse()
|
||||
.filter((c) => c.offsetTop < event.target.offsetTop)
|
||||
.findBy("offsetLeft", event.target.offsetLeft);
|
||||
|
||||
if (prevEmoji) {
|
||||
prevEmoji.focus();
|
||||
} else {
|
||||
focusFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async didSelectEmoji(event) {
|
||||
if (!event.target.classList.contains("emoji")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "click" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let emoji = event.target.dataset.emoji;
|
||||
const tonable = event.target.dataset.tonable;
|
||||
const diversity = this.emojiStore.diversity;
|
||||
if (tonable && diversity > 1) {
|
||||
emoji = `${emoji}:t${diversity}`;
|
||||
}
|
||||
|
||||
this.emojiStore.trackEmojiForContext(emoji, this.args.context);
|
||||
|
||||
this.args.didSelectEmoji?.(emoji);
|
||||
|
||||
await this.args.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didRequestSection(section) {
|
||||
this.term = "";
|
||||
this.didInputFilter(null);
|
||||
|
||||
// we disable scroll listener during requesting section
|
||||
// to avoid it from detecting another section during scroll to requested section
|
||||
this.scrollObserverEnabled = false;
|
||||
this.addVisibleSections(this._getSectionsUpTo(section));
|
||||
this.lastVisibleSection = section;
|
||||
|
||||
// iOS hack to avoid blank div when requesting section during momentum
|
||||
if (this.scrollableNode && this.capabilities.isIOS) {
|
||||
this.scrollableNode.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
next(() => {
|
||||
schedule("afterRender", () => {
|
||||
const targetEmoji = document.querySelector(
|
||||
`.emoji-picker__section[data-section="${section}"]`
|
||||
);
|
||||
targetEmoji.scrollIntoView({ block: "start" });
|
||||
|
||||
// iOS hack to avoid blank div when requesting section during momentum
|
||||
if (this.scrollableNode && this.capabilities.isIOS) {
|
||||
this.scrollableNode.style.overflow = "scroll";
|
||||
}
|
||||
|
||||
this.scrollObserverEnabled = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async loadEmojis() {
|
||||
if (this.emojiStore.list) {
|
||||
this.didInputFilter(this.term);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
this.emojiStore.list = await ajax("/emojis.json");
|
||||
|
||||
// we cant filter an empty list so have to wait for it
|
||||
this.didInputFilter(this.term);
|
||||
} catch (error) {
|
||||
popupAjaxError(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
_handleScroll(event) {
|
||||
if (!this.scrollObserverEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setScrollDirection(event.target);
|
||||
|
||||
const visibleSections = [
|
||||
...document.querySelectorAll(".emoji-picker__section"),
|
||||
].filter((sectionElement) =>
|
||||
this._isSectionVisibleInPicker(sectionElement, event.target)
|
||||
);
|
||||
|
||||
if (visibleSections?.length) {
|
||||
let sectionElement;
|
||||
|
||||
if (this.scrollDirection === "up" || this.prevYPosition < 50) {
|
||||
sectionElement = visibleSections.firstObject;
|
||||
} else {
|
||||
sectionElement = visibleSections.lastObject;
|
||||
}
|
||||
|
||||
this.lastVisibleSection = sectionElement.dataset.section;
|
||||
this.addVisibleSections(visibleSections.map((s) => s.dataset.section));
|
||||
|
||||
document
|
||||
.querySelector(".emoji-picker__section-btn.active")
|
||||
?.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "start",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_setScrollDirection(target) {
|
||||
if (target.scrollTop > this.prevYPosition) {
|
||||
this.scrollDirection = "down";
|
||||
} else {
|
||||
this.scrollDirection = "up";
|
||||
}
|
||||
|
||||
this.prevYPosition = target.scrollTop;
|
||||
}
|
||||
|
||||
_isSectionVisibleInPicker(section, picker) {
|
||||
const { bottom, height, top } = section.getBoundingClientRect();
|
||||
const containerRect = picker.getBoundingClientRect();
|
||||
|
||||
return top <= containerRect.top
|
||||
? containerRect.top - top <= height
|
||||
: bottom - containerRect.bottom <= height;
|
||||
}
|
||||
|
||||
_getSectionsUpTo(section) {
|
||||
const sections = [];
|
||||
for (const sectionNode of document.querySelectorAll(
|
||||
".emoji-picker__section"
|
||||
)) {
|
||||
const sectionName = sectionNode.dataset.section;
|
||||
sections.push(sectionNode.dataset.section);
|
||||
if (sectionName === section) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
{{! template-lint-disable no-pointer-down-event-binding }}
|
||||
<div
|
||||
class={{concatClass "emoji-picker"}}
|
||||
{{didInsert this.loadEmojis}}
|
||||
{{didInsert (if @didInsert @didInsert (noop))}}
|
||||
{{on "keydown" this.trapKeyDownEvents}}
|
||||
...attributes
|
||||
>
|
||||
<div class="emoji-picker__filter-container">
|
||||
<FilterInput
|
||||
{{didInsert (if this.site.desktopView this.focusFilter (noop))}}
|
||||
{{didInsert this.registerFilterInput}}
|
||||
@value={{this.term}}
|
||||
@filterAction={{withEventValue this.didInputFilter}}
|
||||
@icons={{hash right="magnifying-glass"}}
|
||||
@containerClass="emoji-picker__filter"
|
||||
autofocus={{true}}
|
||||
placeholder={{i18n "chat.emoji_picker.search_placeholder"}}
|
||||
/>
|
||||
|
||||
<DiversityMenu />
|
||||
|
||||
{{#if this.site.mobileView}}
|
||||
<DButton
|
||||
@icon="xmark"
|
||||
@action={{@close}}
|
||||
class="btn-transparent emoji-picker__close-btn"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="emoji-picker__content">
|
||||
<div class="emoji-picker__sections-nav" {{this.setupSectionsNavScroll}}>
|
||||
{{#each-in this.groups as |section emojis|}}
|
||||
<DButton
|
||||
class={{concatClass
|
||||
"btn-flat"
|
||||
"emoji-picker__section-btn"
|
||||
(if (eq this.lastVisibleSection section) "active")
|
||||
}}
|
||||
tabindex="-1"
|
||||
@action={{fn this.didRequestSection section}}
|
||||
data-section={{section}}
|
||||
>
|
||||
{{#if (eq section "favorites")}}
|
||||
{{replaceEmoji ":star:"}}
|
||||
{{else}}
|
||||
<img
|
||||
width="18"
|
||||
height="18"
|
||||
class="emoji"
|
||||
src={{get emojis "0.url"}}
|
||||
/>
|
||||
{{/if}}
|
||||
</DButton>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
{{#if this.sections.length}}
|
||||
<div class="emoji-picker__scrollable-content" {{this.scrollListener}}>
|
||||
<div
|
||||
class="emoji-picker__sections"
|
||||
{{on "click" this.didSelectEmoji}}
|
||||
{{on "keydown" this.onSectionsKeyDown}}
|
||||
role="button"
|
||||
>
|
||||
{{#if (notEq this.filteredEmojis null)}}
|
||||
<div class="emoji-picker__section filtered">
|
||||
{{#each this.filteredEmojis as |emoji|}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonableEmojiUrl emoji this.emojiStore.diversity}}
|
||||
tabindex="0"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonableEmojiTitle
|
||||
emoji
|
||||
this.emojiStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{else}}
|
||||
<p class="emoji-picker__no-results">
|
||||
{{i18n "chat.emoji_picker.no_results"}}
|
||||
{{replaceEmoji ":crying_cat_face:"}}
|
||||
</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each-in this.groups as |section emojis|}}
|
||||
{{#if emojis}}
|
||||
<div
|
||||
class={{concatClass
|
||||
"emoji-picker__section"
|
||||
(if (notEq this.filteredEmojis null) "hidden")
|
||||
}}
|
||||
data-section={{section}}
|
||||
role="region"
|
||||
aria-label={{i18n
|
||||
(concat "chat.emoji_picker." section)
|
||||
translatedFallback=section
|
||||
}}
|
||||
>
|
||||
<div class="emoji-picker__section-title-container">
|
||||
<h2 class="emoji-picker__section-title">
|
||||
{{i18n
|
||||
(concat "chat.emoji_picker." section)
|
||||
translatedFallback=section
|
||||
}}
|
||||
</h2>
|
||||
{{#if (eq section "favorites")}}
|
||||
<DButton
|
||||
@icon="trash-can"
|
||||
class="btn-transparent"
|
||||
@action={{this.clearFavorites}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="emoji-picker__section-emojis">
|
||||
{{! we always want the first emoji for tabbing}}
|
||||
{{#let (get emojis "0") as |emoji|}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonableEmojiUrl
|
||||
emoji
|
||||
this.emojiStore.diversity
|
||||
}}
|
||||
tabindex="0"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonableEmojiTitle
|
||||
emoji
|
||||
this.emojiStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{/let}}
|
||||
|
||||
{{#if (includes this.visibleSections section)}}
|
||||
{{#each emojis as |emoji index|}}
|
||||
{{! first emoji has already been rendered, we don't want to re render or would lose focus}}
|
||||
{{#if (gt index 0)}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonableEmojiUrl
|
||||
emoji
|
||||
this.emojiStore.diversity
|
||||
}}
|
||||
tabindex="-1"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonableEmojiTitle
|
||||
emoji
|
||||
this.emojiStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/each-in}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="spinner-container">
|
||||
<div class="spinner medium"></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import EmojiPickerContent from "discourse/components/emoji-picker/content";
|
||||
|
||||
const EmojiPickerDetached = <template>
|
||||
<EmojiPickerContent
|
||||
@close={{@close}}
|
||||
@term={{@data.term}}
|
||||
@didSelectEmoji={{@data.didSelectEmoji}}
|
||||
@context={{@data.context}}
|
||||
/>
|
||||
</template>;
|
||||
|
||||
export default EmojiPickerDetached;
|
|
@ -0,0 +1,71 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { concat, fn } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { eq } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DropdownMenu from "discourse/components/dropdown-menu";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
|
||||
export const FITZPATRICK_MODIFIERS = [
|
||||
{ scale: 1, modifier: null },
|
||||
{ scale: 2, modifier: ":t2" },
|
||||
{ scale: 3, modifier: ":t3" },
|
||||
{ scale: 4, modifier: ":t4" },
|
||||
{ scale: 5, modifier: ":t5" },
|
||||
{ scale: 6, modifier: ":t6" },
|
||||
];
|
||||
|
||||
export default class EmojiPicker extends Component {
|
||||
@service emojiStore;
|
||||
|
||||
fitzpatrickModifiers = FITZPATRICK_MODIFIERS;
|
||||
|
||||
@action
|
||||
didRequestFitzpatrickScale(scale) {
|
||||
this.emojiStore.diversity = scale;
|
||||
this.api.close();
|
||||
}
|
||||
|
||||
@action
|
||||
registerApi(api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
<template>
|
||||
<DMenu
|
||||
@contentClass="emoji-picker__diversity-menu"
|
||||
@triggerClass="emoji-picker__diversity-trigger btn-transparent"
|
||||
@onRegisterApi={{this.registerApi}}
|
||||
>
|
||||
<:trigger>
|
||||
{{#if (eq this.emojiStore.diversity 1)}}
|
||||
{{replaceEmoji ":clap:"}}
|
||||
{{else}}
|
||||
{{replaceEmoji (concat ":clap:t" this.emojiStore.diversity ":")}}
|
||||
{{/if}}
|
||||
</:trigger>
|
||||
|
||||
<:content>
|
||||
<DropdownMenu as |dropdown|>
|
||||
{{#each this.fitzpatrickModifiers as |fitzpatrick|}}
|
||||
<dropdown.item>
|
||||
<DButton
|
||||
class="btn-transparent emoji-picker__diversity-item"
|
||||
@action={{fn this.didRequestFitzpatrickScale fitzpatrick.scale}}
|
||||
data-level={{fitzpatrick.scale}}
|
||||
>
|
||||
{{#if (eq fitzpatrick.scale 1)}}
|
||||
{{replaceEmoji ":clap:"}}
|
||||
{{else}}
|
||||
{{replaceEmoji (concat ":clap:t" fitzpatrick.scale ":")}}
|
||||
{{/if}}
|
||||
</DButton>
|
||||
</dropdown.item>
|
||||
{{/each}}
|
||||
</DropdownMenu>
|
||||
</:content>
|
||||
</DMenu>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { concat } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import EmojiPickerContent from "discourse/components/emoji-picker/content";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
|
||||
export default class EmojiPicker extends Component {
|
||||
@action
|
||||
onRegisterMenu(api) {
|
||||
this.menu = api;
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return this.args.icon ?? "discourse-emojis";
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.args.context ?? "topic";
|
||||
}
|
||||
|
||||
<template>
|
||||
<DMenu
|
||||
@triggerClass={{concatClass @btnClass}}
|
||||
@contentClass="emoji-picker-content"
|
||||
@onRegisterApi={{this.onRegisterMenu}}
|
||||
@identifier="emoji-picker"
|
||||
@groupIdentifier="emoji-picker"
|
||||
@modalForMobile={{true}}
|
||||
@maxWidth={{405}}
|
||||
@onShow={{@onShow}}
|
||||
@onClose={{@onClose}}
|
||||
>
|
||||
<:trigger>
|
||||
{{#if @icon}}
|
||||
{{replaceEmoji (concat ":" @icon ":")}}
|
||||
{{else}}
|
||||
{{icon "discourse-emojis"}}
|
||||
{{/if}}
|
||||
|
||||
{{#if @label}}
|
||||
<span class="d-button-label">{{@label}}</span>
|
||||
{{else}}
|
||||
​
|
||||
{{/if}}
|
||||
</:trigger>
|
||||
|
||||
<:content>
|
||||
<EmojiPickerContent
|
||||
@close={{this.menu.close}}
|
||||
@didSelectEmoji={{@didSelectEmoji}}
|
||||
@context={{this.context}}
|
||||
/>
|
||||
</:content>
|
||||
</DMenu>
|
||||
</template>
|
||||
}
|
|
@ -7,7 +7,7 @@ import concatClass from "discourse/helpers/concat-class";
|
|||
import noop from "discourse/helpers/noop";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
|
||||
export default class DcFilterInput extends Component {
|
||||
export default class FilterInput extends Component {
|
||||
@tracked isFocused = false;
|
||||
|
||||
focusState = modifier((element) => {
|
||||
|
@ -31,7 +31,7 @@ export default class DcFilterInput extends Component {
|
|||
<div
|
||||
class={{concatClass
|
||||
@containerClass
|
||||
"dc-filter-input-container"
|
||||
"filter-input-container"
|
||||
(if this.isFocused "is-focused")
|
||||
}}
|
||||
>
|
||||
|
@ -43,7 +43,7 @@ export default class DcFilterInput extends Component {
|
|||
{{this.focusState}}
|
||||
{{on "input" (if @filterAction @filterAction (noop))}}
|
||||
@value={{@value}}
|
||||
class="dc-filter-input"
|
||||
class="filter-input"
|
||||
...attributes
|
||||
/>
|
||||
|
|
@ -2,9 +2,6 @@ import Component from "@glimmer/component";
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { scheduleOnce } from "@ember/runloop";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import EmojiPicker from "discourse/components/emoji-picker";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { emojiUnescape } from "discourse/lib/text";
|
||||
|
@ -14,16 +11,11 @@ import { i18n } from "discourse-i18n";
|
|||
|
||||
export default class UserStatusPicker extends Component {
|
||||
@tracked isFocused = false;
|
||||
@tracked emojiPickerIsActive = false;
|
||||
|
||||
get emojiHtml() {
|
||||
return emojiUnescape(escapeExpression(`:${this.args.status.emoji}:`));
|
||||
}
|
||||
|
||||
focusEmojiButton() {
|
||||
document.querySelector(".user-status-picker .btn-emoji")?.focus();
|
||||
}
|
||||
|
||||
@action
|
||||
blur() {
|
||||
this.isFocused = false;
|
||||
|
@ -32,9 +24,6 @@ export default class UserStatusPicker extends Component {
|
|||
@action
|
||||
emojiSelected(emoji) {
|
||||
this.args.status.emoji = emoji;
|
||||
this.emojiPickerIsActive = false;
|
||||
|
||||
scheduleOnce("afterRender", this, this.focusEmojiButton);
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -42,22 +31,12 @@ export default class UserStatusPicker extends Component {
|
|||
this.isFocused = true;
|
||||
}
|
||||
|
||||
@action
|
||||
onEmojiPickerOutsideClick() {
|
||||
this.emojiPickerIsActive = false;
|
||||
}
|
||||
|
||||
@action
|
||||
updateDescription(event) {
|
||||
this.args.status.description = event.target.value;
|
||||
this.args.status.emoji ||= "speech_balloon";
|
||||
}
|
||||
|
||||
@action
|
||||
toggleEmojiPicker() {
|
||||
this.emojiPickerIsActive = !this.emojiPickerIsActive;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-status-picker-wrap">
|
||||
<div
|
||||
|
@ -66,13 +45,10 @@ export default class UserStatusPicker extends Component {
|
|||
(if this.isFocused "focused")
|
||||
}}
|
||||
>
|
||||
<DButton
|
||||
{{on "focus" this.focus}}
|
||||
{{on "blur" this.blur}}
|
||||
@action={{this.toggleEmojiPicker}}
|
||||
@icon={{unless @status.emoji "discourse-emojis"}}
|
||||
@translatedLabel={{if @status.emoji (htmlSafe this.emojiHtml)}}
|
||||
class="btn-emoji btn-transparent"
|
||||
<EmojiPicker
|
||||
@icon={{@status.emoji}}
|
||||
@didSelectEmoji={{this.emojiSelected}}
|
||||
@btnClass="btn-emoji"
|
||||
/>
|
||||
|
||||
<input
|
||||
|
@ -88,12 +64,5 @@ export default class UserStatusPicker extends Component {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmojiPicker
|
||||
@isActive={{this.emojiPickerIsActive}}
|
||||
@emojiSelected={{this.emojiSelected}}
|
||||
@onEmojiPickerClose={{this.onEmojiPickerOutsideClick}}
|
||||
@placement="bottom"
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
|
|
|
@ -22,7 +22,8 @@ export default class FKControlMenu extends Component {
|
|||
<template>
|
||||
<DMenu
|
||||
@onRegisterApi={{this.registerMenuApi}}
|
||||
@triggerClass="form-kit__control-menu"
|
||||
@triggerClass="form-kit__control-menu-trigger"
|
||||
@contentClass="form-kit__control-menu-content"
|
||||
@disabled={{@field.disabled}}
|
||||
@placement="bottom-start"
|
||||
@offset={{5}}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { registerEmoji } from "pretty-text/emoji";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import PreloadStore from "discourse/lib/preload-store";
|
||||
|
||||
export default {
|
||||
initialize(owner) {
|
||||
const siteSettings = owner.lookup("service:site-settings");
|
||||
|
||||
if (!siteSettings.enable_emoji) {
|
||||
return;
|
||||
}
|
||||
|
@ -14,10 +16,23 @@ export default {
|
|||
toolbar.addButton({
|
||||
id: "emoji",
|
||||
group: "extras",
|
||||
icon: "far-face-smile",
|
||||
action: () => toolbar.context.send("emoji"),
|
||||
icon: "discourse-emojis",
|
||||
sendAction: () => {
|
||||
const menu = api.container.lookup("service:menu");
|
||||
menu.show(document.querySelector(".insert-composer-emoji"), {
|
||||
identifier: "emoji-picker",
|
||||
groupIdentifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
modalForMobile: true,
|
||||
data: {
|
||||
didSelectEmoji: (emoji) => {
|
||||
toolbar.context.textManipulation.emojiSelected(emoji);
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
title: "composer.emoji",
|
||||
className: "emoji insert-emoji",
|
||||
className: "emoji insert-composer-emoji",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,13 +5,24 @@ class VirtualElementFromTextRange {
|
|||
|
||||
updateRect() {
|
||||
const selection = document.getSelection();
|
||||
this.range = selection && selection.rangeCount && selection.getRangeAt(0);
|
||||
|
||||
this.range = selection?.rangeCount && selection?.getRangeAt?.(0);
|
||||
|
||||
if (!this.range) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a fake element if range is collapsed
|
||||
if (this.range.collapsed) {
|
||||
const tempSpan = document.createElement("span");
|
||||
tempSpan.textContent = "\u200B"; // Zero-width space
|
||||
this.range.insertNode(tempSpan);
|
||||
this.rect = tempSpan.getBoundingClientRect();
|
||||
tempSpan.parentNode.removeChild(tempSpan);
|
||||
} else {
|
||||
this.rect = this.range.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return this.rect;
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,8 @@ export default class CloseOnClickOutside extends Modifier {
|
|||
}
|
||||
|
||||
cleanup() {
|
||||
document.removeEventListener("pointerdown", this.check);
|
||||
document.removeEventListener("pointerdown", this.check, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,52 +1,93 @@
|
|||
import Service from "@ember/service";
|
||||
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import Service, { service } from "@ember/service";
|
||||
import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
|
||||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
|
||||
const EMOJI_USAGE = "emojiUsage";
|
||||
const EMOJI_SELECTED_DIVERSITY = "emojiSelectedDiversity";
|
||||
const TRACKED_EMOJIS = 15;
|
||||
const STORE_NAMESPACE = "discourse_emojis_";
|
||||
export const SKIN_TONE_STORE_KEY = "emojiSelectedDiversity";
|
||||
export const STORE_NAMESPACE = "discourse_emoji_reaction_";
|
||||
export const USER_EMOJIS_STORE_KEY = "emojiUsage";
|
||||
export const MAX_DISPLAYED_EMOJIS = 20;
|
||||
export const MAX_TRACKED_EMOJIS = MAX_DISPLAYED_EMOJIS * 2;
|
||||
export const DEFAULT_DIVERSITY = 1;
|
||||
|
||||
@disableImplicitInjections
|
||||
export default class EmojiStore extends Service {
|
||||
@service siteSettings;
|
||||
|
||||
@tracked list;
|
||||
|
||||
store = new KeyValueStore(STORE_NAMESPACE);
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
contexts = new TrackedObject();
|
||||
|
||||
if (!this.store.getObject(EMOJI_USAGE)) {
|
||||
this.favorites = [];
|
||||
}
|
||||
}
|
||||
@tracked _diversity;
|
||||
|
||||
get diversity() {
|
||||
return this.store.getObject(EMOJI_SELECTED_DIVERSITY) || 1;
|
||||
return this._diversity ?? this.store.getObject(SKIN_TONE_STORE_KEY) ?? 1;
|
||||
}
|
||||
|
||||
set diversity(value) {
|
||||
this.store.setObject({ key: EMOJI_SELECTED_DIVERSITY, value: value || 1 });
|
||||
this.notifyPropertyChange("diversity");
|
||||
this._diversity = value;
|
||||
this.store.setObject({ key: SKIN_TONE_STORE_KEY, value });
|
||||
}
|
||||
|
||||
get favorites() {
|
||||
return this.store.getObject(EMOJI_USAGE) || [];
|
||||
trackEmojiForContext(emoji, context) {
|
||||
const recentEmojis = this.#addEmojiToContext(emoji, context);
|
||||
this.contexts[context] = new TrackedArray(recentEmojis);
|
||||
this.#persistRecentEmojisForContext(recentEmojis, context);
|
||||
return recentEmojis;
|
||||
}
|
||||
|
||||
set favorites(value) {
|
||||
this.store.setObject({ key: EMOJI_USAGE, value: value || [] });
|
||||
this.notifyPropertyChange("favorites");
|
||||
}
|
||||
|
||||
track(code) {
|
||||
const normalizedCode = code.replace(/(^:)|(:$)/g, "");
|
||||
const recent = this.favorites.filter((r) => r !== normalizedCode);
|
||||
recent.unshift(normalizedCode);
|
||||
recent.length = Math.min(recent.length, TRACKED_EMOJIS);
|
||||
this.favorites = recent;
|
||||
favoritesForContext(context) {
|
||||
return this.#sortEmojisByFrequency(
|
||||
this.#recentEmojisForContext(context)
|
||||
).slice(0, MAX_DISPLAYED_EMOJIS);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.store.setObject({ key: EMOJI_USAGE, value: [] });
|
||||
this.store.setObject({ key: EMOJI_SELECTED_DIVERSITY, value: 1 });
|
||||
Object.keys(this.contexts).forEach((context) => {
|
||||
this.resetContext(context);
|
||||
});
|
||||
this.diversity = DEFAULT_DIVERSITY;
|
||||
}
|
||||
|
||||
resetContext(context) {
|
||||
this.contexts[context] = this.#defaultEmojis;
|
||||
this.#persistRecentEmojisForContext(this.#defaultEmojis, context);
|
||||
}
|
||||
|
||||
get #defaultEmojis() {
|
||||
return this.siteSettings.default_emoji_reactions.split("|").filter(Boolean);
|
||||
}
|
||||
|
||||
#recentEmojisForContext(context) {
|
||||
return this.contexts[context] ?? this.#defaultEmojis;
|
||||
}
|
||||
|
||||
#addEmojiToContext(emoji, context) {
|
||||
const recentEmojis = this.#recentEmojisForContext(context);
|
||||
recentEmojis.unshift(this.#normalizeEmojiCode(emoji));
|
||||
recentEmojis.length = Math.min(recentEmojis.length, MAX_TRACKED_EMOJIS);
|
||||
return recentEmojis;
|
||||
}
|
||||
|
||||
#persistRecentEmojisForContext(recentEmojis, context) {
|
||||
const key = this.#emojisStorekeyForContext(context);
|
||||
this.store.setObject({ key, value: recentEmojis });
|
||||
}
|
||||
|
||||
#normalizeEmojiCode(code) {
|
||||
return code.replace(/(^:)|(:$)/g, "");
|
||||
}
|
||||
|
||||
#emojisStorekeyForContext(context) {
|
||||
return `${context}_${USER_EMOJIS_STORE_KEY}`;
|
||||
}
|
||||
|
||||
#sortEmojisByFrequency(emojis = []) {
|
||||
const counters = emojis.reduce((obj, val) => {
|
||||
obj[val] = (obj[val] || 0) + 1;
|
||||
return obj;
|
||||
}, {});
|
||||
return Object.keys(counters).sort((a, b) => counters[b] - counters[a]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,269 +0,0 @@
|
|||
import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers";
|
||||
import { test } from "qunit";
|
||||
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
acceptance("EmojiPicker", function (needs) {
|
||||
needs.user();
|
||||
|
||||
needs.hooks.beforeEach(function () {
|
||||
this.emojiStore = this.container.lookup("service:emoji-store");
|
||||
this.emojiStore.reset();
|
||||
});
|
||||
needs.hooks.afterEach(function () {
|
||||
this.emojiStore.reset();
|
||||
});
|
||||
|
||||
test("emoji picker can be opened/closed", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
|
||||
await click("button.emoji.btn");
|
||||
assert.dom(".emoji-picker.opened").exists("opens the picker");
|
||||
|
||||
await click("button.emoji.btn");
|
||||
assert.dom(".emoji-picker.opened").doesNotExist("closes the picker");
|
||||
});
|
||||
|
||||
test("filters emoji", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click("button.emoji.btn");
|
||||
await fillIn(".emoji-picker input.filter", "guitar");
|
||||
|
||||
assert.dom(".emoji-picker .results img").hasAttribute("title", "guitar");
|
||||
});
|
||||
|
||||
test("emoji picker triggers event when picking emoji", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click("button.emoji.btn");
|
||||
await click(".emoji-picker-emoji-area img.emoji[title='grinning']");
|
||||
|
||||
assert
|
||||
.dom(".d-editor-input")
|
||||
.hasValue(
|
||||
":grinning:",
|
||||
"adds the emoji code in the editor when selected"
|
||||
);
|
||||
});
|
||||
|
||||
test("emoji picker adds leading whitespace before emoji", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
|
||||
// Whitespace should be added on text
|
||||
await fillIn(".d-editor-input", "This is a test input");
|
||||
await click("button.emoji.btn");
|
||||
await click(".emoji-picker-emoji-area img.emoji[title='grinning']");
|
||||
assert
|
||||
.dom(".d-editor-input")
|
||||
.hasValue(
|
||||
"This is a test input :grinning:",
|
||||
"adds the emoji code and a leading whitespace when there is text"
|
||||
);
|
||||
|
||||
// Whitespace should not be added on whitespace
|
||||
await fillIn(".d-editor-input", "This is a test input ");
|
||||
await click(".emoji-picker-emoji-area img.emoji[title='grinning']");
|
||||
|
||||
assert
|
||||
.dom(".d-editor-input")
|
||||
.hasValue(
|
||||
"This is a test input :grinning:",
|
||||
"adds the emoji code and no leading whitespace when user already entered whitespace"
|
||||
);
|
||||
});
|
||||
|
||||
test("emoji picker has a list of recently used emojis", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click("button.emoji.btn");
|
||||
await click(".emoji-picker-emoji-area img.emoji[title='grinning']");
|
||||
|
||||
assert
|
||||
.dom(
|
||||
".emoji-picker .section.recent .section-group img.emoji[title='grinning']"
|
||||
)
|
||||
.exists("shows recent selected emoji");
|
||||
|
||||
assert
|
||||
.dom('.emoji-picker .category-button[data-section="recent"]')
|
||||
.exists("it shows recent category icon");
|
||||
|
||||
await click(".emoji-picker .trash-recent");
|
||||
|
||||
assert
|
||||
.dom(
|
||||
".emoji-picker .section.recent .section-group img.emoji[title='grinning']"
|
||||
)
|
||||
.doesNotExist("has cleared recent emojis");
|
||||
|
||||
assert
|
||||
.dom('.emoji-picker .section[data-section="recent"]')
|
||||
.doesNotExist("it hides recent section");
|
||||
|
||||
assert
|
||||
.dom('.emoji-picker .category-button[data-section="recent"]')
|
||||
.doesNotExist("it hides recent category icon");
|
||||
});
|
||||
|
||||
test("emoji picker correctly orders recently used emojis", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click("button.emoji.btn");
|
||||
await click(".emoji-picker-emoji-area img.emoji[title='sunglasses']");
|
||||
await click(".emoji-picker-emoji-area img.emoji[title='grinning']");
|
||||
|
||||
assert
|
||||
.dom('.section[data-section="recent"] .section-group img.emoji')
|
||||
.exists({ count: 2 }, "has multiple recent emojis");
|
||||
|
||||
assert
|
||||
.dom(".section.recent .section-group img.emoji")
|
||||
.hasAttribute("src", /grinning/, "puts the last used emoji in first");
|
||||
});
|
||||
|
||||
test("updates the recent list when selecting from it (after you close re-open it or select other emoji)", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click("button.emoji.btn");
|
||||
await click(`.emoji-picker-emoji-area img.emoji[title="sunglasses"]`);
|
||||
await click(`.emoji-picker-emoji-area img.emoji[title="grinning"]`);
|
||||
|
||||
let recent = document.querySelectorAll(
|
||||
".section.recent .section-group img.emoji"
|
||||
);
|
||||
assert.dom(recent[0]).hasAttribute("title", "grinning");
|
||||
assert.dom(recent[1]).hasAttribute("title", "sunglasses");
|
||||
|
||||
await click(
|
||||
`.section[data-section="recent"] .section-group img.emoji[title="sunglasses"]`
|
||||
);
|
||||
|
||||
// The order is still the same
|
||||
recent = document.querySelectorAll(
|
||||
".section.recent .section-group img.emoji"
|
||||
);
|
||||
assert.dom(recent[0]).hasAttribute("title", "grinning");
|
||||
assert.dom(recent[1]).hasAttribute("title", "sunglasses");
|
||||
|
||||
await click("button.emoji.btn");
|
||||
await click("button.emoji.btn");
|
||||
|
||||
// but updates when you re-open
|
||||
recent = document.querySelectorAll(
|
||||
".section.recent .section-group img.emoji"
|
||||
);
|
||||
assert.dom(recent[0]).hasAttribute("title", "sunglasses");
|
||||
assert.dom(recent[1]).hasAttribute("title", "grinning");
|
||||
});
|
||||
|
||||
test("emoji picker persists state", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click("button.emoji.btn");
|
||||
await click(".emoji-picker button.diversity-scale.medium-dark");
|
||||
await click("button.emoji.btn");
|
||||
await click("button.emoji.btn");
|
||||
|
||||
assert
|
||||
.dom(".emoji-picker button.diversity-scale.medium-dark .d-icon")
|
||||
.exists("it stores diversity scale");
|
||||
});
|
||||
|
||||
test("emoji can be selected with keyboard", async function (assert) {
|
||||
const searchInput = ".emoji-picker-search-container input";
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click(".emoji.btn");
|
||||
|
||||
let emojis = document.querySelectorAll(
|
||||
".emoji-picker-emoji-area img.emoji"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector(searchInput),
|
||||
"search input is focused by default"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(searchInput, "keydown", "ArrowDown");
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
emojis[0],
|
||||
"ArrowDown from search focuses on the first emoji result"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
emojis[1],
|
||||
"ArrowRight from first emoji focuses on the second emoji"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowLeft");
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
emojis[0],
|
||||
"ArrowLeft from second emoji focuses on the first emoji"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "Enter");
|
||||
assert
|
||||
.dom(".d-editor-input")
|
||||
.hasValue(
|
||||
":smiley:",
|
||||
"Pressing enter inserts the emoji markup in the composer"
|
||||
);
|
||||
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click(".emoji.btn");
|
||||
await triggerKeyEvent(searchInput, "keydown", "ArrowDown");
|
||||
emojis = document.querySelectorAll(".emoji-picker-emoji-area img.emoji");
|
||||
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector(".emoji-picker-emoji-area .emoji.recent-emoji"),
|
||||
"ArrowDown focuses on the first emoji result (recent emoji)"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector(".emojis-container .emoji[title='grinning']"),
|
||||
"ArrowDown again focuses on the first emoji result in a section"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
emojis[4],
|
||||
"ArrowRight moves focus to next right element"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowUp");
|
||||
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector(searchInput),
|
||||
"ArrowUp from first row items moves focus to input"
|
||||
);
|
||||
});
|
||||
|
||||
test("emoji picker can be dismissed with escape key", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
await click("button.emoji.btn");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "Escape");
|
||||
assert.dom(".emoji-picker").doesNotExist();
|
||||
assert.strictEqual(
|
||||
document.activeElement,
|
||||
document.querySelector("textarea"),
|
||||
"escaping from emoji picker focuses back on input"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,6 +1,7 @@
|
|||
import { click, fillIn, visit } from "@ember/test-helpers";
|
||||
import { IMAGE_VERSION as v } from "pretty-text/emoji/version";
|
||||
import { test } from "qunit";
|
||||
import emojiPicker from "discourse/tests/helpers/emoji-picker-helper";
|
||||
import {
|
||||
acceptance,
|
||||
query,
|
||||
|
@ -28,17 +29,15 @@ acceptance("Emoji", function (needs) {
|
|||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
|
||||
await simulateKeys(".d-editor-input", "an :arrow");
|
||||
await simulateKeys(".d-editor-input", "a :man_");
|
||||
// the 6th item in the list is the "more..."
|
||||
await click(".autocomplete.ac-emoji ul li:nth-of-type(6)");
|
||||
|
||||
assert.dom(".emoji-picker.opened.has-filter").exists();
|
||||
await click(".emoji-picker .results img:first-of-type");
|
||||
await emojiPicker().select("man_rowing_boat");
|
||||
|
||||
assert
|
||||
.dom(".d-editor-preview")
|
||||
.hasHtml(
|
||||
`<p>an <img src="/images/emoji/twitter/arrow_backward.png?v=${v}" title=":arrow_backward:" class="emoji" alt=":arrow_backward:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
|
||||
`<p>a <img src="/images/emoji/twitter/man_rowing_boat.png?v=${v}" title=":man_rowing_boat:" class="emoji" alt=":man_rowing_boat:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { click, fillIn, visit } from "@ember/test-helpers";
|
||||
import { test } from "qunit";
|
||||
import emojiPicker from "discourse/tests/helpers/emoji-picker-helper";
|
||||
import {
|
||||
acceptance,
|
||||
updateCurrentUser,
|
||||
|
@ -9,15 +10,11 @@ async function openUserStatusModal() {
|
|||
await click(".pref-user-status .btn-default");
|
||||
}
|
||||
|
||||
async function pickEmoji(emoji) {
|
||||
await click(".btn-emoji");
|
||||
await fillIn(".emoji-picker-content .filter", emoji);
|
||||
await click(".results .emoji");
|
||||
}
|
||||
|
||||
async function setStatus(status) {
|
||||
await openUserStatusModal();
|
||||
await pickEmoji(status.emoji);
|
||||
await click(".btn-emoji");
|
||||
await emojiPicker().fill(status.emoji);
|
||||
await emojiPicker().select(status.emoji);
|
||||
await fillIn(".user-status-description", status.description);
|
||||
await click(".d-modal__footer .btn-primary"); // save and close modal
|
||||
}
|
||||
|
@ -25,7 +22,7 @@ async function setStatus(status) {
|
|||
acceptance("User Profile - Account - User Status", function (needs) {
|
||||
const username = "eviltrout";
|
||||
const status = {
|
||||
emoji: "tooth",
|
||||
emoji: "grinning",
|
||||
description: "off to dentist",
|
||||
};
|
||||
|
||||
|
@ -88,7 +85,7 @@ acceptance("User Profile - Account - User Status", function (needs) {
|
|||
this.siteSettings.enable_user_status = true;
|
||||
|
||||
await visit(`/u/${username}/preferences/account`);
|
||||
const newStatus = { emoji: "surfing_man", description: "surfing" };
|
||||
const newStatus = { emoji: "womans_clothes", description: "shopping" };
|
||||
await setStatus(newStatus);
|
||||
|
||||
assert
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { click, fillIn, visit } from "@ember/test-helpers";
|
||||
import { test } from "qunit";
|
||||
import emojiPicker from "discourse/tests/helpers/emoji-picker-helper";
|
||||
import {
|
||||
acceptance,
|
||||
publishToMessageBus,
|
||||
|
@ -14,8 +15,8 @@ async function openUserStatusModal() {
|
|||
|
||||
async function pickEmoji(emoji) {
|
||||
await click(".btn-emoji");
|
||||
await fillIn(".emoji-picker-content .filter", emoji);
|
||||
await click(".results .emoji");
|
||||
await emojiPicker().fill(emoji);
|
||||
await emojiPicker().select(emoji);
|
||||
}
|
||||
|
||||
async function setDoNotDisturbMode() {
|
||||
|
@ -24,7 +25,7 @@ async function setDoNotDisturbMode() {
|
|||
|
||||
acceptance("User Status", function (needs) {
|
||||
const userStatus = "off to dentist";
|
||||
const userStatusEmoji = "tooth";
|
||||
const userStatusEmoji = "grinning";
|
||||
const userId = 1;
|
||||
const userTimezone = "UTC";
|
||||
|
||||
|
@ -108,17 +109,13 @@ acceptance("User Status", function (needs) {
|
|||
|
||||
test("emoji picking", async function (assert) {
|
||||
this.siteSettings.enable_user_status = true;
|
||||
|
||||
await visit("/");
|
||||
await openUserStatusModal();
|
||||
|
||||
assert.dom(".d-icon-discourse-emojis").exists("empty status icon is shown");
|
||||
|
||||
await click(".btn-emoji");
|
||||
assert.dom(".emoji-picker.opened").exists("emoji picker is opened");
|
||||
await pickEmoji(userStatusEmoji);
|
||||
|
||||
await fillIn(".emoji-picker-content .filter", userStatusEmoji);
|
||||
await click(".results .emoji");
|
||||
assert
|
||||
.dom(`.btn-emoji img.emoji[title=${userStatusEmoji}]`)
|
||||
.exists("chosen status emoji is shown");
|
||||
|
@ -259,7 +256,8 @@ acceptance("User Status", function (needs) {
|
|||
await visit("/");
|
||||
await openUserStatusModal();
|
||||
await fillIn(".user-status-description", "another status");
|
||||
await pickEmoji("cold_face"); // another emoji
|
||||
|
||||
await pickEmoji("grinning"); // another emoji
|
||||
await click(".d-modal-cancel");
|
||||
await openUserStatusModal();
|
||||
|
||||
|
@ -318,7 +316,7 @@ acceptance(
|
|||
"User Status - pause notifications (do not disturb mode)",
|
||||
function (needs) {
|
||||
const userStatus = "off to dentist";
|
||||
const userStatusEmoji = "tooth";
|
||||
const userStatusEmoji = "grinning";
|
||||
const userId = 1;
|
||||
const userTimezone = "UTC";
|
||||
|
||||
|
@ -438,7 +436,7 @@ acceptance(
|
|||
|
||||
acceptance("User Status - user menu", function (needs) {
|
||||
const userStatus = "off to dentist";
|
||||
const userStatusEmoji = "tooth";
|
||||
const userStatusEmoji = "grinning";
|
||||
const userId = 1;
|
||||
const userTimezone = "UTC";
|
||||
|
||||
|
|
47
app/assets/javascripts/discourse/tests/fixtures/emojis-fixtures.js
vendored
Normal file
47
app/assets/javascripts/discourse/tests/fixtures/emojis-fixtures.js
vendored
Normal file
|
@ -0,0 +1,47 @@
|
|||
export default {
|
||||
"/emojis.json": {
|
||||
favorites: [
|
||||
{
|
||||
name: "grinning",
|
||||
tonable: false,
|
||||
url: "/images/emoji/twitter/grinning.png?v=12",
|
||||
group: "smileys_\u0026_emotion",
|
||||
search_aliases: ["smiley_cat", "star_struck"],
|
||||
},
|
||||
],
|
||||
"smileys_&_emotion": [
|
||||
{
|
||||
name: "grinning",
|
||||
tonable: false,
|
||||
url: "/images/emoji/twitter/grinning.png?v=12",
|
||||
group: "smileys_\u0026_emotion",
|
||||
search_aliases: ["smiley_cat", "star_struck"],
|
||||
},
|
||||
],
|
||||
"people_&_body": [
|
||||
{
|
||||
name: "raised_hands",
|
||||
tonable: true,
|
||||
url: "/images/emoji/twitter/raised_hands.png?v=12",
|
||||
group: "people_&_body",
|
||||
search_aliases: [],
|
||||
},
|
||||
{
|
||||
name: "man_rowing_boat",
|
||||
tonable: true,
|
||||
url: "/images/emoji/twitter/man_rowing_boat.png?v=12",
|
||||
group: "people_&_body",
|
||||
search_aliases: [],
|
||||
},
|
||||
],
|
||||
objects: [
|
||||
{
|
||||
name: "womans_clothes",
|
||||
tonable: false,
|
||||
url: "/images/emoji/twitter/womans_clothes.png?v=12",
|
||||
group: "objects",
|
||||
search_aliases: [],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { click, fillIn } from "@ember/test-helpers";
|
||||
import { query } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
class EmojiPicker {
|
||||
constructor(selector) {
|
||||
if (selector instanceof HTMLElement) {
|
||||
this.element = selector;
|
||||
} else {
|
||||
this.element = query(selector);
|
||||
}
|
||||
}
|
||||
|
||||
async fill(input) {
|
||||
await fillIn(query(".filter-input", this.element), input);
|
||||
}
|
||||
|
||||
async select(emoji) {
|
||||
await click(
|
||||
`.emoji-picker__scrollable-content img.emoji[data-emoji="${emoji}"]`
|
||||
);
|
||||
}
|
||||
|
||||
async tone(level) {
|
||||
await click(query(".emoji-picker__diversity-trigger", this.element));
|
||||
await click(`.emoji-picker__diversity-menu [data-level="${level}"]`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function picker(selector = ".emoji-picker-content") {
|
||||
const helper = new EmojiPicker(selector);
|
||||
|
||||
return {
|
||||
async fill(input) {
|
||||
await helper.fill(input);
|
||||
},
|
||||
async tone(level) {
|
||||
await helper.tone(level);
|
||||
},
|
||||
async select(emoji) {
|
||||
await helper.select(emoji);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -97,8 +97,8 @@ class FieldHelper {
|
|||
return this.element.querySelector(".form-kit__control-select").value;
|
||||
}
|
||||
case "menu": {
|
||||
return this.element.querySelector(".form-kit__control-menu").dataset
|
||||
.value;
|
||||
return this.element.querySelector(".form-kit__control-menu-trigger")
|
||||
.dataset.value;
|
||||
}
|
||||
case "checkbox": {
|
||||
return this.element.querySelector(".form-kit__control-checkbox")
|
||||
|
|
|
@ -107,7 +107,7 @@ class Field {
|
|||
break;
|
||||
case "menu":
|
||||
const trigger = this.element.querySelector(
|
||||
".fk-d-menu__trigger.form-kit__control-menu"
|
||||
".fk-d-menu__trigger.form-kit__control-menu-trigger"
|
||||
);
|
||||
await click(trigger);
|
||||
const menu = document.body.querySelector(
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
render,
|
||||
settled,
|
||||
triggerEvent,
|
||||
triggerKeyEvent,
|
||||
} from "@ember/test-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import { module, test } from "qunit";
|
||||
|
@ -13,6 +14,7 @@ import { withPluginApi } from "discourse/lib/plugin-api";
|
|||
import { setCaretPosition } from "discourse/lib/utilities";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import formatTextWithSelection from "discourse/tests/helpers/d-editor-helper";
|
||||
import emojiPicker from "discourse/tests/helpers/emoji-picker-helper";
|
||||
import { paste, query, queryAll } from "discourse/tests/helpers/qunit-helpers";
|
||||
import {
|
||||
getTextareaSelection,
|
||||
|
@ -702,44 +704,31 @@ third line`
|
|||
);
|
||||
|
||||
test("emoji", async function (assert) {
|
||||
// Test adding a custom button
|
||||
withPluginApi("0.1", (api) => {
|
||||
api.onToolbarCreate((toolbar) => {
|
||||
toolbar.addButton({
|
||||
id: "emoji",
|
||||
group: "extras",
|
||||
icon: "far-face-smile",
|
||||
action: () => toolbar.context.send("emoji"),
|
||||
});
|
||||
});
|
||||
});
|
||||
this.set("value", "hello world.");
|
||||
|
||||
await render(hbs`<DEditor @value={{this.value}} />`);
|
||||
|
||||
// we need DMenus here, as we are testing the d-editor which is not renderining
|
||||
// the in-element outlet container necessary for DMenu to work
|
||||
await render(hbs`<DMenus /><DEditor @value={{this.value}} />`);
|
||||
const picker = emojiPicker();
|
||||
jumpEnd(query("textarea.d-editor-input"));
|
||||
await click("button.emoji");
|
||||
await click(".d-editor-button-bar .emoji");
|
||||
await picker.select("raised_hands");
|
||||
|
||||
await click(
|
||||
'.emoji-picker .section[data-section="smileys_&_emotion"] img.emoji[title="grinning"]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
this.value,
|
||||
"hello world. :grinning:",
|
||||
"hello world. :raised_hands:",
|
||||
"it works when there is no partial emoji"
|
||||
);
|
||||
|
||||
await click("textarea.d-editor-input");
|
||||
await fillIn(".d-editor-input", "starting to type an emoji like :gri");
|
||||
await fillIn(".d-editor-input", "starting to type an emoji like :woman");
|
||||
jumpEnd(query("textarea.d-editor-input"));
|
||||
await click("button.emoji");
|
||||
await triggerKeyEvent(".d-editor-input", "keyup", "Backspace"); //simplest way to trigger more menu here
|
||||
await click(".ac-emoji li:last-child a");
|
||||
await picker.select("womans_clothes");
|
||||
|
||||
await click(
|
||||
'.emoji-picker .section[data-section="smileys_&_emotion"] img.emoji[title="grinning"]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
this.value,
|
||||
"starting to type an emoji like :grinning:",
|
||||
"starting to type an emoji like :womans_clothes:",
|
||||
"it works when there is a partial emoji"
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,40 +1,227 @@
|
|||
import { click, render } from "@ember/test-helpers";
|
||||
import { hbs } from "ember-cli-htmlbars";
|
||||
import { module, test } from "qunit";
|
||||
import { click, fillIn, render, triggerKeyEvent } from "@ember/test-helpers";
|
||||
import hbs from "htmlbars-inline-precompile";
|
||||
import { module, skip, test } from "qunit";
|
||||
import emojisFixtures from "discourse/tests/fixtures/emojis-fixtures";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import pretender, { response } from "discourse/tests/helpers/create-pretender";
|
||||
import emojiPicker from "discourse/tests/helpers/emoji-picker-helper";
|
||||
|
||||
module("Integration | Component | emoji-picker", function (hooks) {
|
||||
module("Integration | Component | emoji-picker-content", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("when placement == bottom, places the picker on the bottom", async function (assert) {
|
||||
this.set("showEmojiPicker", () => {
|
||||
this.set("pickerIsActive", true);
|
||||
hooks.beforeEach(function () {
|
||||
pretender.get("/emojis.json", () =>
|
||||
response(emojisFixtures["/emojis.json"])
|
||||
);
|
||||
|
||||
this.emojiStore = this.container.lookup("service:emoji-store");
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<DButton class="emoji-picker-anchor" @action={{this.showEmojiPicker}} />
|
||||
<EmojiPicker @isActive={{this.pickerIsActive}} @placement="bottom" />
|
||||
`);
|
||||
hooks.afterEach(function () {
|
||||
this.emojiStore.diversity = 1;
|
||||
});
|
||||
|
||||
test("When displaying navigation", async function (assert) {
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
|
||||
await click(".emoji-picker-anchor");
|
||||
assert
|
||||
.dom(".emoji-picker.opened")
|
||||
.hasAttribute("data-popper-placement", "bottom");
|
||||
});
|
||||
|
||||
test("when placement == right, places the picker on the right", async function (assert) {
|
||||
this.set("showEmojiPicker", () => {
|
||||
this.set("pickerIsActive", true);
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<DButton class="emoji-picker-anchor" @action={{this.showEmojiPicker}} />
|
||||
<EmojiPicker @isActive={{this.pickerIsActive}} @placement="right" />
|
||||
`);
|
||||
|
||||
await click(".emoji-picker-anchor");
|
||||
.dom(`.emoji-picker__section-btn.active[data-section="favorites"]`)
|
||||
.exists("it renders first section as active");
|
||||
assert
|
||||
.dom(".emoji-picker.opened")
|
||||
.hasAttribute("data-popper-placement", "right");
|
||||
.dom(`.emoji-picker__section-btn[data-section="smileys_&_emotion"]`)
|
||||
.exists();
|
||||
assert
|
||||
.dom(`.emoji-picker__section-btn[data-section="people_&_body"]`)
|
||||
.exists();
|
||||
assert.dom(`.emoji-picker__section-btn[data-section="objects"]`).exists();
|
||||
});
|
||||
|
||||
test("When changing tone scale", async function (assert) {
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
await emojiPicker(".emoji-picker").tone(6);
|
||||
|
||||
assert
|
||||
.dom(`img[src="/images/emoji/twitter/raised_hands/6.png"]`)
|
||||
.exists("it applies the tone to emojis");
|
||||
assert
|
||||
.dom(".emoji-picker__diversity-trigger img[title='clap:t6']")
|
||||
.exists("it changes the current scale to t6");
|
||||
});
|
||||
|
||||
test("When requesting section", async function (assert) {
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelector("#ember-testing-container").scrollTop,
|
||||
0
|
||||
);
|
||||
|
||||
await click(`.emoji-picker__section-btn[data-section="objects"]`);
|
||||
|
||||
assert.true(
|
||||
document.querySelector(".emoji-picker__scrollable-content").scrollTop > 0,
|
||||
"it scrolls to the section"
|
||||
);
|
||||
});
|
||||
|
||||
test("When filtering emojis", async function (assert) {
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
await fillIn(".filter-input", "grinning");
|
||||
|
||||
assert
|
||||
.dom(".emoji-picker__section.filtered > img")
|
||||
.exists({ count: 1 }, "it filters the emojis list");
|
||||
assert
|
||||
.dom('.emoji-picker__section.filtered > img[alt="grinning"]')
|
||||
.exists("it filters the correct emoji");
|
||||
|
||||
await fillIn(".filter-input", "Grinning");
|
||||
|
||||
assert
|
||||
.dom('.emoji-picker__section.filtered > img[alt="grinning"]')
|
||||
.exists("it is case insensitive");
|
||||
|
||||
await fillIn(".filter-input", "smiley_cat");
|
||||
|
||||
assert
|
||||
.dom('.emoji-picker__section.filtered > img[alt="grinning"]')
|
||||
.exists("it filters the correct emoji using search alias");
|
||||
});
|
||||
|
||||
test("When selecting an emoji", async function (assert) {
|
||||
this.didSelectEmoji = (emoji) => assert.step(emoji);
|
||||
|
||||
await render(
|
||||
hbs`<EmojiPicker::Content @didSelectEmoji={{this.didSelectEmoji}} />`
|
||||
);
|
||||
await click('img.emoji[data-emoji="grinning"]');
|
||||
|
||||
assert.verifySteps(["grinning"]);
|
||||
});
|
||||
|
||||
skip("When navigating sections", async function (assert) {
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
|
||||
|
||||
assert
|
||||
.dom(document.activeElement)
|
||||
.hasAttribute(
|
||||
"data-emoji",
|
||||
"grinning",
|
||||
"ArrowDown focuses on the first favorite emoji"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
|
||||
|
||||
assert
|
||||
.dom(document.activeElement)
|
||||
.hasAttribute("data-emoji", "raised_hands");
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
|
||||
assert
|
||||
.dom(document.activeElement)
|
||||
.hasAttribute(
|
||||
"data-emoji",
|
||||
"man_rowing_boat",
|
||||
"ArrowRight focuses on the emoji at the right"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowLeft");
|
||||
assert
|
||||
.dom(document.activeElement)
|
||||
.hasAttribute(
|
||||
"data-emoji",
|
||||
"raised_hands",
|
||||
"ArrowLeft focuses on the emoji at the left"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowUp");
|
||||
assert.dom(document.activeElement).hasAttribute("data-emoji", "grinning");
|
||||
});
|
||||
|
||||
skip("When navigating filtered emojis", async function (assert) {
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
await fillIn(".filter-input", "man");
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
|
||||
assert
|
||||
.dom(document.activeElement)
|
||||
.hasAttribute(
|
||||
"data-emoji",
|
||||
"man_rowing_boat",
|
||||
"ArrowDown focuses on the first filtered emoji"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowRight");
|
||||
assert
|
||||
.dom(document.activeElement)
|
||||
.hasAttribute(
|
||||
"data-emoji",
|
||||
"womans_clothes",
|
||||
"ArrowRight focuses on the emoji at the right"
|
||||
);
|
||||
|
||||
await triggerKeyEvent(document.activeElement, "keydown", "ArrowLeft");
|
||||
assert
|
||||
.dom(document.activeElement)
|
||||
.hasAttribute(
|
||||
"data-emoji",
|
||||
"man_rowing_boat",
|
||||
"ArrowLeft focuses on the emoji at the left"
|
||||
);
|
||||
});
|
||||
|
||||
test("When selecting a toned an emoji", async function (assert) {
|
||||
this.didSelectEmoji = (emoji) => assert.step(emoji);
|
||||
|
||||
await render(
|
||||
hbs`<EmojiPicker::Content @didSelectEmoji={{this.didSelectEmoji}} />`
|
||||
);
|
||||
const picker = emojiPicker(".emoji-picker");
|
||||
await picker.select("raised_hands");
|
||||
await picker.tone(2);
|
||||
await picker.select("raised_hands");
|
||||
|
||||
assert.verifySteps(["raised_hands", "raised_hands:t2"]);
|
||||
});
|
||||
|
||||
test("When hovering an emoji", async function (assert) {
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
|
||||
assert
|
||||
.dom(
|
||||
'.emoji-picker__section[data-section="people_&_body"] img.emoji:nth-child(1)'
|
||||
)
|
||||
.hasAttribute("title", ":raised_hands:", "first emoji has a title");
|
||||
|
||||
await emojiPicker(".emoji-picker").fill("grinning");
|
||||
|
||||
assert
|
||||
.dom('img.emoji[data-emoji="grinning"]')
|
||||
.hasAttribute("title", ":grinning:", "filtered emoji have a title");
|
||||
|
||||
await emojiPicker(".emoji-picker").tone(1);
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
|
||||
assert
|
||||
.dom('img.emoji[data-emoji="raised_hands"]')
|
||||
.hasAttribute(
|
||||
"title",
|
||||
":raised_hands:",
|
||||
"it has a title without the scale as diversity value is 1"
|
||||
);
|
||||
|
||||
await emojiPicker(".emoji-picker").tone(2);
|
||||
await render(hbs`<EmojiPicker::Content />`);
|
||||
|
||||
assert
|
||||
.dom('img.emoji[data-emoji="raised_hands"]')
|
||||
.hasAttribute(
|
||||
"title",
|
||||
":raised_hands:t2:",
|
||||
"it has a title with the scale"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import { fillIn, render, triggerEvent } from "@ember/test-helpers";
|
||||
import hbs from "htmlbars-inline-precompile";
|
||||
import { module, test } from "qunit";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import { exists } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
module("Integration | Component | filter-input", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test("Left icon", async function (assert) {
|
||||
await render(hbs`<FilterInput @icons={{hash left="bell"}} />`);
|
||||
|
||||
assert.true(exists(".d-icon-bell.-left"));
|
||||
});
|
||||
|
||||
test("Right icon", async function (assert) {
|
||||
await render(hbs`<FilterInput @icons={{hash right="bell"}} />`);
|
||||
|
||||
assert.true(exists(".d-icon-bell.-right"));
|
||||
});
|
||||
|
||||
test("containerClass argument", async function (assert) {
|
||||
await render(hbs`<FilterInput @containerClass="foo" />`);
|
||||
|
||||
assert.true(exists(".filter-input-container.foo"));
|
||||
});
|
||||
|
||||
test("Html attributes", async function (assert) {
|
||||
await render(hbs`<FilterInput data-foo="1" placeholder="bar" />`);
|
||||
|
||||
assert.true(exists('.filter-input[data-foo="1"]'));
|
||||
assert.true(exists('.filter-input[placeholder="bar"]'));
|
||||
});
|
||||
|
||||
test("Filter action", async function (assert) {
|
||||
this.set("value", null);
|
||||
this.set("action", (event) => {
|
||||
this.set("value", event.target.value);
|
||||
});
|
||||
await render(hbs`<FilterInput @filterAction={{this.action}} />`);
|
||||
await fillIn(".filter-input", "foo");
|
||||
|
||||
assert.strictEqual(this.value, "foo");
|
||||
});
|
||||
|
||||
test("Focused state", async function (assert) {
|
||||
await render(hbs`<FilterInput @filterAction={{this.action}} />`);
|
||||
await triggerEvent(".filter-input", "focusin");
|
||||
|
||||
assert.true(exists(".filter-input-container.is-focused"));
|
||||
|
||||
await triggerEvent(".filter-input", "focusout");
|
||||
|
||||
assert.false(exists(".filter-input-container.is-focused"));
|
||||
});
|
||||
});
|
|
@ -42,6 +42,6 @@ module("Integration | Component | FormKit | Controls | Menu", function (hooks) {
|
|||
</Form>
|
||||
</template>);
|
||||
|
||||
assert.dom(".form-kit__control-menu").hasAttribute("disabled");
|
||||
assert.dom(".form-kit__control-menu-trigger").hasAttribute("disabled");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -39,11 +39,11 @@ module("Integration | Component | user-status-picker", function (hooks) {
|
|||
await render(<template><UserStatusPicker @status={{status}} /></template>);
|
||||
|
||||
await click(".btn-emoji");
|
||||
await fillIn(".emoji-picker-content .filter", "mega");
|
||||
await click(".results .emoji");
|
||||
await fillIn(".emoji-picker-content .filter-input", "raised");
|
||||
await click(".emoji-picker__sections .emoji");
|
||||
|
||||
assert.dom(".emoji").hasAttribute("alt", "mega");
|
||||
assert.strictEqual(status.emoji, "mega");
|
||||
assert.dom(".emoji").hasAttribute("alt", "raised_hands");
|
||||
assert.strictEqual(status.emoji, "raised_hands");
|
||||
});
|
||||
|
||||
test("it sets default emoji when user starts typing a description", async function (assert) {
|
||||
|
|
|
@ -1,40 +1,132 @@
|
|||
import { getOwner } from "@ember/owner";
|
||||
import { setupTest } from "ember-qunit";
|
||||
import { module, test } from "qunit";
|
||||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
import {
|
||||
MAX_DISPLAYED_EMOJIS,
|
||||
MAX_TRACKED_EMOJIS,
|
||||
SKIN_TONE_STORE_KEY,
|
||||
STORE_NAMESPACE,
|
||||
USER_EMOJIS_STORE_KEY,
|
||||
} from "discourse/services/emoji-store";
|
||||
|
||||
module("Unit | Service | emoji-store", function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.emojiStore = getOwner(this).lookup("service:emoji-store");
|
||||
this.emojiStore.reset();
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
this.emojiStore.reset();
|
||||
});
|
||||
|
||||
test("defaults", function (assert) {
|
||||
assert.deepEqual(this.emojiStore.favorites, []);
|
||||
assert.strictEqual(this.emojiStore.diversity, 1);
|
||||
test(".favoritesForContext", function (assert) {
|
||||
assert.deepEqual(this.emojiStore.favoritesForContext("topic"), [
|
||||
"+1",
|
||||
"heart",
|
||||
"tada",
|
||||
]);
|
||||
});
|
||||
|
||||
test("diversity", function (assert) {
|
||||
test(".trackEmojiForContext", function (assert) {
|
||||
this.emojiStore.trackEmojiForContext("grinning", "topic");
|
||||
const storedEmojis = new KeyValueStore(STORE_NAMESPACE).getObject(
|
||||
`topic_${USER_EMOJIS_STORE_KEY}`
|
||||
);
|
||||
|
||||
assert.deepEqual(this.emojiStore.favoritesForContext("topic"), [
|
||||
"grinning",
|
||||
"+1",
|
||||
"heart",
|
||||
"tada",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
storedEmojis,
|
||||
["grinning", "+1", "heart", "tada"],
|
||||
"it persists the tracked emojis"
|
||||
);
|
||||
});
|
||||
|
||||
test("limits the maximum number of tracked emojis", function (assert) {
|
||||
let trackedEmojis;
|
||||
Array.from({ length: 45 }).forEach(() => {
|
||||
trackedEmojis = this.emojiStore.trackEmojiForContext("grinning", "topic");
|
||||
});
|
||||
|
||||
assert.strictEqual(trackedEmojis.length, MAX_TRACKED_EMOJIS);
|
||||
});
|
||||
|
||||
test("limits the maximum number of favorites emojis", function (assert) {
|
||||
Array.from({ length: 25 }).forEach((_, i) => {
|
||||
this.emojiStore.trackEmojiForContext(`emoji_${i}`, "topic");
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
this.emojiStore.favoritesForContext("topic").length,
|
||||
MAX_DISPLAYED_EMOJIS
|
||||
);
|
||||
});
|
||||
|
||||
test("support for multiple contexts", function (assert) {
|
||||
this.emojiStore.trackEmojiForContext("grinning", "topic");
|
||||
|
||||
assert.deepEqual(this.emojiStore.favoritesForContext("topic"), [
|
||||
"grinning",
|
||||
"+1",
|
||||
"heart",
|
||||
"tada",
|
||||
]);
|
||||
|
||||
this.emojiStore.trackEmojiForContext("cat", "chat");
|
||||
|
||||
assert.deepEqual(this.emojiStore.favoritesForContext("chat"), [
|
||||
"cat",
|
||||
"+1",
|
||||
"heart",
|
||||
"tada",
|
||||
]);
|
||||
});
|
||||
|
||||
test(".resetContext", function (assert) {
|
||||
this.emojiStore.trackEmojiForContext("grinning", "topic");
|
||||
|
||||
this.emojiStore.resetContext("topic");
|
||||
|
||||
assert.deepEqual(this.emojiStore.favoritesForContext("topic"), [
|
||||
"+1",
|
||||
"heart",
|
||||
"tada",
|
||||
]);
|
||||
});
|
||||
|
||||
test(".diversity", function (assert) {
|
||||
assert.deepEqual(this.emojiStore.diversity, 1);
|
||||
});
|
||||
|
||||
test(".diversity=", function (assert) {
|
||||
this.emojiStore.diversity = 2;
|
||||
assert.strictEqual(this.emojiStore.diversity, 2);
|
||||
const storedDiversity = new KeyValueStore(STORE_NAMESPACE).getObject(
|
||||
SKIN_TONE_STORE_KEY
|
||||
);
|
||||
|
||||
assert.deepEqual(this.emojiStore.diversity, 2);
|
||||
assert.deepEqual(storedDiversity, 2, "it persists the diversity value");
|
||||
});
|
||||
|
||||
test("favorites", function (assert) {
|
||||
this.emojiStore.favorites = ["smile"];
|
||||
assert.deepEqual(this.emojiStore.favorites, ["smile"]);
|
||||
});
|
||||
test("sort emojis by frequency", function (assert) {
|
||||
this.emojiStore.trackEmojiForContext("cat", "topic");
|
||||
this.emojiStore.trackEmojiForContext("cat", "topic");
|
||||
this.emojiStore.trackEmojiForContext("cat", "topic");
|
||||
this.emojiStore.trackEmojiForContext("dog", "topic");
|
||||
this.emojiStore.trackEmojiForContext("dog", "topic");
|
||||
|
||||
test("track", function (assert) {
|
||||
this.emojiStore.track("woman:t4");
|
||||
assert.deepEqual(this.emojiStore.favorites, ["woman:t4"]);
|
||||
|
||||
this.emojiStore.track("otter");
|
||||
this.emojiStore.track(":otter:");
|
||||
assert.deepEqual(this.emojiStore.favorites, ["otter", "woman:t4"]);
|
||||
assert.deepEqual(this.emojiStore.favoritesForContext("topic"), [
|
||||
"cat",
|
||||
"dog",
|
||||
"+1",
|
||||
"heart",
|
||||
"tada",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,7 @@ import DFloatPortal from "float-kit/components/d-float-portal";
|
|||
import { getScrollParent } from "float-kit/lib/get-scroll-parent";
|
||||
import FloatKitApplyFloatingUi from "float-kit/modifiers/apply-floating-ui";
|
||||
import FloatKitCloseOnEscape from "float-kit/modifiers/close-on-escape";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
|
||||
export default class DFloatBody extends Component {
|
||||
closeOnScroll = modifierFn(() => {
|
||||
|
@ -25,6 +26,18 @@ export default class DFloatBody extends Component {
|
|||
};
|
||||
});
|
||||
|
||||
trapPointerDown = modifierFn((element) => {
|
||||
const handler = (event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
element.addEventListener("pointerdown", handler);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("pointerdown", handler);
|
||||
};
|
||||
});
|
||||
|
||||
get supportsCloseOnClickOutside() {
|
||||
return this.args.instance.expanded && this.options.closeOnClickOutside;
|
||||
}
|
||||
|
@ -66,9 +79,10 @@ export default class DFloatBody extends Component {
|
|||
aria-expanded={{if @instance.expanded "true" "false"}}
|
||||
role={{@role}}
|
||||
{{FloatKitApplyFloatingUi this.trigger this.options @instance}}
|
||||
{{this.trapPointerDown}}
|
||||
{{(if @trapTab (modifier TrapTab autofocus=this.options.autofocus))}}
|
||||
{{(if
|
||||
this.supportsCloseOnClickOutside
|
||||
(and @instance.expanded this.supportsCloseOnClickOutside)
|
||||
(modifier
|
||||
closeOnClickOutside
|
||||
(fn @instance.close (hash focusTrigger=false))
|
||||
|
|
|
@ -3,8 +3,6 @@ import { concat } from "@ember/helper";
|
|||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||
import { service } from "@ember/service";
|
||||
import { modifier } from "ember-modifier";
|
||||
import { and } from "truth-helpers";
|
||||
|
@ -35,10 +33,13 @@ export default class DMenu extends Component {
|
|||
};
|
||||
});
|
||||
|
||||
@action
|
||||
registerFloatBody(element) {
|
||||
registerFloatBody = modifier((element) => {
|
||||
this.body = element;
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.body = null;
|
||||
};
|
||||
});
|
||||
|
||||
@action
|
||||
teardownFloatBody() {
|
||||
|
@ -156,8 +157,7 @@ export default class DMenu extends Component {
|
|||
@innerClass="fk-d-menu__inner-content"
|
||||
@role="dialog"
|
||||
@inline={{this.options.inline}}
|
||||
{{didInsert this.registerFloatBody}}
|
||||
{{willDestroy this.teardownFloatBody}}
|
||||
{{this.registerFloatBody}}
|
||||
>
|
||||
{{#if (has-block)}}
|
||||
{{yield this.componentArgs}}
|
||||
|
|
|
@ -71,6 +71,7 @@ export const MENU = {
|
|||
modalForMobile: false,
|
||||
inline: null,
|
||||
groupIdentifier: null,
|
||||
parentIdentifier: null,
|
||||
triggerClass: null,
|
||||
contentClass: null,
|
||||
class: null,
|
||||
|
|
|
@ -70,6 +70,8 @@ export default class DMenuInstance extends FloatKitInstance {
|
|||
if (options.focusTrigger) {
|
||||
this.trigger?.focus?.();
|
||||
}
|
||||
|
||||
await this.options.onClose?.(this);
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
import { registerDestructor } from "@ember/destroyable";
|
||||
import { service } from "@ember/service";
|
||||
import Modifier from "ember-modifier";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
export default class FloatKitCloseOnEscape extends Modifier {
|
||||
@service menu;
|
||||
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
registerDestructor(this, (instance) => instance.cleanup());
|
||||
|
|
|
@ -15,10 +15,6 @@ export function registerEmoji(code, url, group) {
|
|||
extendedEmojiMap.set(code, { url, group });
|
||||
}
|
||||
|
||||
export function extendedEmojiList() {
|
||||
return extendedEmojiMap;
|
||||
}
|
||||
|
||||
const emojiMap = new Map();
|
||||
|
||||
// Regex from https://github.com/mathiasbynens/emoji-test-regex-pattern/blob/main/dist/latest/javascript.txt
|
||||
|
|
|
@ -35,230 +35,3 @@ sup img.emoji {
|
|||
height: 1.1em;
|
||||
width: 1.1em;
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
width: 100%;
|
||||
color: var(--primary);
|
||||
background-color: var(--secondary);
|
||||
border: 1px solid var(--primary-low);
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
background-clip: padding-box;
|
||||
z-index: z("modal", "content");
|
||||
flex-direction: row;
|
||||
height: 320px;
|
||||
max-height: 50vh;
|
||||
max-width: 420px;
|
||||
|
||||
img.emoji {
|
||||
// custom emojis might import images of various sizes
|
||||
// we don't want them to be deformed in the picker
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
|
||||
.emoji-picker-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 20;
|
||||
}
|
||||
|
||||
.emoji-picker-emoji-area {
|
||||
overflow-y: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25em;
|
||||
padding-left: 0.75em;
|
||||
height: 100%;
|
||||
background: var(--secondary);
|
||||
|
||||
.section {
|
||||
margin: 0 0 1em;
|
||||
|
||||
.trash-recent {
|
||||
background: none;
|
||||
font-size: var(--font-down-1);
|
||||
|
||||
&:hover .d-icon {
|
||||
color: var(--danger-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-weight: 900;
|
||||
padding: 0.25em 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-group,
|
||||
.results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
img.emoji {
|
||||
padding: 0.25em 0.28em;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: var(--tertiary-low);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
padding: 0.25em 0;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-category-buttons {
|
||||
overflow-y: auto;
|
||||
width: 50px;
|
||||
padding-left: 0.5em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
border-right: 1px solid var(--primary-low);
|
||||
|
||||
.category-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5em;
|
||||
outline: none;
|
||||
|
||||
.emoji {
|
||||
pointer-events: none;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
&:hover .emoji,
|
||||
&.current .emoji {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.has-filter {
|
||||
.emojis-container {
|
||||
visibility: hidden;
|
||||
height: 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emoji-picker-category-buttons {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
.category-button.current .emoji {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-search-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 0.75em;
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
|
||||
.filter {
|
||||
flex: 1 0 auto;
|
||||
margin: 0;
|
||||
width: calc(100% - 50px);
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary-medium);
|
||||
cursor: pointer;
|
||||
padding: 0.25em;
|
||||
&:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--primary-low);
|
||||
}
|
||||
|
||||
.emoji-picker-emoji-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 0.5em;
|
||||
|
||||
img.emoji {
|
||||
height: 32px !important;
|
||||
width: 32px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-diversity-picker {
|
||||
border: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0.5em;
|
||||
|
||||
.diversity-scale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: auto;
|
||||
border-radius: 3px;
|
||||
margin: 0.15em;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
.d-icon {
|
||||
color: #fff;
|
||||
filter: drop-shadow(0.5px 1.5px 0 rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
.diversity-scale.default {
|
||||
background: #ffcc4d;
|
||||
}
|
||||
.diversity-scale.light {
|
||||
background: #f7dece;
|
||||
}
|
||||
.diversity-scale.medium-light {
|
||||
background: #f3d2a2;
|
||||
}
|
||||
.diversity-scale.medium {
|
||||
background: #d5ab88;
|
||||
}
|
||||
.diversity-scale.medium-dark {
|
||||
background: #af7e57;
|
||||
}
|
||||
.diversity-scale.dark {
|
||||
background: #7c533e;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-modal-overlay {
|
||||
z-index: z("modal", "overlay");
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.8;
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
|
|
@ -58,3 +58,6 @@
|
|||
@import "widget-dropdown";
|
||||
@import "welcome-header";
|
||||
@import "notifications-tracking";
|
||||
@import "emoji-picker";
|
||||
@import "filter-input";
|
||||
@import "dropdown-menu";
|
||||
|
|
18
app/assets/stylesheets/common/components/dropdown-menu.scss
Normal file
18
app/assets/stylesheets/common/components/dropdown-menu.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
.dropdown-menu {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&__item {
|
||||
list-style: none;
|
||||
|
||||
.btn {
|
||||
padding: 0.65rem 1rem;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
margin: 0rem;
|
||||
}
|
||||
}
|
|
@ -1,16 +1,36 @@
|
|||
.chat-emoji-picker {
|
||||
border-top: 1px solid var(--primary-low);
|
||||
transition: height 125ms ease;
|
||||
.fk-d-menu[data-content][data-identifier="emoji-picker"] {
|
||||
z-index: z("modal", "dialog");
|
||||
}
|
||||
|
||||
.emoji-picker-trigger {
|
||||
.d-icon + .d-button-label {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
background: var(--secondary);
|
||||
width: 500px;
|
||||
max-width: 100vw;
|
||||
|
||||
.spinner-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__diversity-menu.fk-d-menu {
|
||||
z-index: z("modal", "dialog");
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
padding: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
cursor: pointer;
|
||||
|
||||
|
@ -23,20 +43,19 @@
|
|||
}
|
||||
|
||||
&__filter-container {
|
||||
top: 0;
|
||||
position: sticky;
|
||||
background: var(--secondary);
|
||||
background: var(--primary-very-low);
|
||||
display: flex;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
&__filter {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin: 0.25rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: var(--d-border-radius);
|
||||
box-sizing: border-box;
|
||||
|
||||
input {
|
||||
background: none;
|
||||
background-color: transparent !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
@ -44,40 +63,51 @@
|
|||
color: var(--primary-medium);
|
||||
}
|
||||
|
||||
&.dc-filter-input-container {
|
||||
&.filter-input-container {
|
||||
border-color: transparent;
|
||||
background: var(--primary-very-low);
|
||||
background: var(--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
&__scrollable-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
text-transform: capitalize;
|
||||
@include chat-scrollbar();
|
||||
margin: 1px;
|
||||
overscroll-behavior: contain;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
font-weight: 700;
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
&__sections-nav {
|
||||
top: 0;
|
||||
position: sticky;
|
||||
background: var(--secondary);
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
height: 50px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__indicator {
|
||||
background: var(--tertiary);
|
||||
height: 4px;
|
||||
transition: transform 0.3s cubic-bezier(0.1, 0.82, 0.25, 1);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
flex-direction: column;
|
||||
min-width: 40px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 250px;
|
||||
background: var(--primary-low);
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
&__sections-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__section-btn {
|
||||
|
@ -94,6 +124,10 @@
|
|||
background: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
scale: 1.4;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
|
@ -114,18 +148,23 @@
|
|||
right: 0;
|
||||
}
|
||||
|
||||
&__section-title-container {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: -1px; // to avoid an ugly sub 1px gap on retina
|
||||
background: rgba(var(--secondary-rgb), 0.95);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
color: var(--primary-very-high);
|
||||
font-size: var(--font-up-0);
|
||||
color: var(--primary-high);
|
||||
font-size: var(--font-down-2);
|
||||
font-weight: 700;
|
||||
background: rgba(var(--secondary-rgb), 0.95);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__fitzpatrick-modifier-btn {
|
||||
|
@ -198,18 +237,3 @@
|
|||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-channel-message-emoji-picker-connector {
|
||||
position: relative;
|
||||
|
||||
.chat-emoji-picker {
|
||||
border: 1px solid var(--primary-low);
|
||||
width: 320px;
|
||||
z-index: z("header") + 1;
|
||||
|
||||
.emoji {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
.dc-filter-input-container {
|
||||
.filter-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
@ -9,8 +9,8 @@
|
|||
border: 1px solid var(--tertiary);
|
||||
}
|
||||
|
||||
.dc-filter-input,
|
||||
.dc-filter-input:focus {
|
||||
.filter-input,
|
||||
.filter-input:focus {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
|
@ -8,9 +8,10 @@
|
|||
width: 100%;
|
||||
border: 1px solid var(--primary-medium);
|
||||
|
||||
.btn-emoji {
|
||||
.emoji-picker-trigger {
|
||||
margin: 3px;
|
||||
width: 2.3em;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,15 @@
|
|||
.form-kit__control-menu {
|
||||
.form-kit__control-menu-trigger {
|
||||
@include default-input;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.form-kit__control-menu-content {
|
||||
.dropdown-menu {
|
||||
min-width: 200px;
|
||||
}
|
||||
.dropdown-menu__item {
|
||||
&:hover {
|
||||
background: var(--d-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
@import "directory";
|
||||
@import "discourse";
|
||||
@import "edit-category";
|
||||
@import "emoji";
|
||||
@import "header";
|
||||
@import "invite-signup";
|
||||
@import "lightbox";
|
||||
|
|
|
@ -4,3 +4,6 @@
|
|||
@import "user-card";
|
||||
@import "user-stream-item";
|
||||
@import "welcome-header";
|
||||
@import "more-topics";
|
||||
@import "bookmark-menu";
|
||||
@import "emoji-picker";
|
||||
|
|
57
app/assets/stylesheets/mobile/components/emoji-picker.scss
Normal file
57
app/assets/stylesheets/mobile/components/emoji-picker.scss
Normal file
|
@ -0,0 +1,57 @@
|
|||
.fk-d-menu-modal.emoji-picker-content {
|
||||
.emoji-picker {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
html:not(.keyboard-visible.mobile-view) & .d-modal__container {
|
||||
height: calc(var(--composer-vh, var(--1dvh)) * 100);
|
||||
max-height: 100%;
|
||||
|
||||
.emoji-picker__content {
|
||||
height: calc(
|
||||
var(--composer-vh, var(--1dvh)) * 100 - 50px -
|
||||
env(safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
html.keyboard-visible.mobile-view & .d-modal__container {
|
||||
.emoji-picker__content {
|
||||
height: calc(var(--composer-vh, var(--1dvh)) * 100 - 50px);
|
||||
}
|
||||
}
|
||||
|
||||
.d-modal__body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.d-modal__container {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.emoji-picker__sections-nav {
|
||||
order: 1;
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.emoji-picker__content {
|
||||
flex-direction: column;
|
||||
padding-top: 1em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.emoji-picker__scrollable-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.emoji-picker__close-btn {
|
||||
margin-left: 0.25em;
|
||||
padding-left: 0.75em;
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
.emoji-picker {
|
||||
border: none;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
max-width: 100vh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
min-height: 50vh;
|
||||
.emoji-picker-emoji-area {
|
||||
img.emoji {
|
||||
// custom emojis might import images of various sizes
|
||||
// we don't want them to be deformed in the picker
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,9 +10,6 @@
|
|||
max-width: 100px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.d-modal__body {
|
||||
padding: 1em 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
padding-top: 0.25em;
|
||||
|
|
8
app/controllers/emojis_controller.rb
Normal file
8
app/controllers/emojis_controller.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class EmojisController < ApplicationController
|
||||
def index
|
||||
emojis = Emoji.allowed.group_by(&:group)
|
||||
render json: MultiJson.dump(emojis)
|
||||
end
|
||||
end
|
|
@ -2603,6 +2603,8 @@ en:
|
|||
disable_mailing_list_mode: "Disallow users from enabling mailing list mode (prevents any mailing list emails from being sent.)"
|
||||
default_email_previous_replies: "Include previous replies in emails by default."
|
||||
|
||||
default_emoji_reactions: "Default favorites emoji reactions. Add up to 5 emojis for quick reaction."
|
||||
|
||||
default_email_in_reply_to: "Include excerpt of replied to post in emails by default."
|
||||
|
||||
default_hide_profile: "Hide user public profile by default."
|
||||
|
@ -2977,6 +2979,7 @@ en:
|
|||
default_email_mailing_list_mode_frequency: ""
|
||||
default_email_messages_level: ""
|
||||
default_email_previous_replies: ""
|
||||
default_emoji_reactions: ""
|
||||
default_hide_presence: ""
|
||||
default_hide_profile: ""
|
||||
default_include_tl0_in_digests: ""
|
||||
|
|
|
@ -1711,6 +1711,8 @@ Discourse::Application.routes.draw do
|
|||
get "/form-templates/:id" => "form_templates#show"
|
||||
get "/form-templates" => "form_templates#index"
|
||||
|
||||
get "/emojis" => "emojis#index"
|
||||
|
||||
if Rails.env.test?
|
||||
# Routes that are only used for testing
|
||||
get "/test_net_http_timeouts" => "test_requests#test_net_http_timeouts"
|
||||
|
|
|
@ -959,6 +959,10 @@ posting:
|
|||
max_emojis_in_title:
|
||||
default: 1
|
||||
area: "emojis"
|
||||
default_emoji_reactions:
|
||||
type: emoji_list
|
||||
default: +1|heart|tada
|
||||
client: true
|
||||
allow_uncategorized_topics:
|
||||
client: true
|
||||
default: false
|
||||
|
|
|
@ -189,20 +189,6 @@ task "javascript:update_constants" => :environment do
|
|||
write_template("pretty-text/addon/emoji/version.js", task_name, <<~JS)
|
||||
export const IMAGE_VERSION = "#{Emoji::EMOJI_VERSION}";
|
||||
JS
|
||||
|
||||
groups_json = JSON.parse(File.read("lib/emoji/groups.json"))
|
||||
|
||||
emoji_buttons = groups_json.map { |group| <<~HTML }
|
||||
<button type="button" data-section="#{group["name"]}" {{on "click" (fn this.onCategorySelection "#{group["name"]}")}} class="btn btn-default category-button emoji">
|
||||
{{replace-emoji ":#{group["tabicon"]}:"}}
|
||||
</button>
|
||||
HTML
|
||||
|
||||
emoji_sections = groups_json.map { |group| html_for_section(group) }
|
||||
|
||||
components_dir = "discourse/app/components"
|
||||
write_hbs_template("#{components_dir}/emoji-group-buttons.hbs", task_name, emoji_buttons.join)
|
||||
write_hbs_template("#{components_dir}/emoji-group-sections.hbs", task_name, emoji_sections.join)
|
||||
end
|
||||
|
||||
task "javascript:update" => "clean_up" do
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
|
@ -17,8 +16,6 @@ export default class ChatIncomingWebhookEditForm extends Component {
|
|||
@service toasts;
|
||||
@service router;
|
||||
|
||||
@tracked emojiPickerIsActive = false;
|
||||
|
||||
get formData() {
|
||||
return {
|
||||
name: this.args.webhook?.name,
|
||||
|
@ -32,7 +29,6 @@ export default class ChatIncomingWebhookEditForm extends Component {
|
|||
@action
|
||||
emojiSelected(setData, emoji) {
|
||||
setData("emoji", `:${emoji}:`);
|
||||
this.emojiPickerIsActive = false;
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -129,6 +125,7 @@ export default class ChatIncomingWebhookEditForm extends Component {
|
|||
@name="emoji"
|
||||
@title={{i18n "chat.incoming_webhooks.emoji"}}
|
||||
@description={{i18n "chat.incoming_webhooks.emoji_instructions"}}
|
||||
@size="large"
|
||||
as |field|
|
||||
>
|
||||
<field.Custom>
|
||||
|
@ -140,21 +137,9 @@ export default class ChatIncomingWebhookEditForm extends Component {
|
|||
</span>
|
||||
{{/if}}
|
||||
|
||||
<EmojiPicker
|
||||
@isActive={{this.emojiPickerIsActive}}
|
||||
@isEditorFocused={{true}}
|
||||
@emojiSelected={{fn this.emojiSelected form.set}}
|
||||
@onEmojiPickerClose={{fn (mut this.emojiPickerIsActive) false}}
|
||||
/>
|
||||
|
||||
{{#unless this.emojiPickerIsActive}}
|
||||
<form.Row as |row|>
|
||||
<row.Col @size={{6}}>
|
||||
<DButton
|
||||
@label="chat.incoming_webhooks.select_emoji"
|
||||
@action={{fn (mut this.emojiPickerIsActive) true}}
|
||||
class="btn-primary admin-chat-webhooks-select-emoji"
|
||||
/>
|
||||
<row.Col @size={{2}}>
|
||||
<EmojiPicker @didSelectEmoji={{fn this.emojiSelected form.set}} />
|
||||
</row.Col>
|
||||
<row.Col @size={{6}}>
|
||||
<DButton
|
||||
|
@ -165,8 +150,6 @@ export default class ChatIncomingWebhookEditForm extends Component {
|
|||
/>
|
||||
</row.Col>
|
||||
</form.Row>
|
||||
{{/unless}}
|
||||
|
||||
</field.Custom>
|
||||
</form.Field>
|
||||
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class EmojisController < ::Chat::BaseController
|
||||
def index
|
||||
emojis = Emoji.allowed.group_by(&:group)
|
||||
render json: MultiJson.dump(emojis)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -8,13 +8,13 @@ import { schedule } from "@ember/runloop";
|
|||
import { service } from "@ember/service";
|
||||
import { eq } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import FilterInput from "discourse/components/filter-input";
|
||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import List from "discourse/plugins/chat/discourse/components/chat/list";
|
||||
import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message";
|
||||
import ChatChannelCard from "discourse/plugins/chat/discourse/components/chat-channel-card";
|
||||
import DcFilterInput from "discourse/plugins/chat/discourse/components/dc-filter-input";
|
||||
|
||||
const ARCHIVED = "archived";
|
||||
const ALL = "all";
|
||||
|
@ -89,11 +89,10 @@ export default class BrowseChannels extends Component {
|
|||
</ul>
|
||||
</nav>
|
||||
|
||||
<DcFilterInput
|
||||
<FilterInput
|
||||
{{didInsert this.focusFilterInput}}
|
||||
@filterAction={{this.setFilter}}
|
||||
@icons={{hash right="magnifying-glass"}}
|
||||
@containerClass="filter-input"
|
||||
placeholder={{i18n "chat.browse.filter_input_placeholder"}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import { modifier } from "ember-modifier";
|
||||
import { headerOffset } from "discourse/lib/offset-calculator";
|
||||
import ChatEmojiPicker from "discourse/plugins/chat/discourse/components/chat-emoji-picker";
|
||||
|
||||
export default class ChatChannelMessageEmojiPicker extends Component {
|
||||
@service site;
|
||||
@service chatEmojiPickerManager;
|
||||
|
||||
context = "chat-channel-message";
|
||||
|
||||
listenToBodyScroll = modifier(() => {
|
||||
const handler = () => {
|
||||
this.chatEmojiPickerManager.close();
|
||||
};
|
||||
|
||||
document.addEventListener("scroll", handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("scroll", handler);
|
||||
};
|
||||
});
|
||||
|
||||
@action
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._popper?.destroy();
|
||||
}
|
||||
|
||||
@action
|
||||
didSelectEmoji(emoji) {
|
||||
this.chatEmojiPickerManager.picker?.didSelectEmoji(emoji);
|
||||
this.chatEmojiPickerManager.close();
|
||||
}
|
||||
|
||||
@action
|
||||
didInsert(element) {
|
||||
if (this.site.mobileView) {
|
||||
element.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
this._popper = createPopper(
|
||||
this.chatEmojiPickerManager.picker?.trigger,
|
||||
element,
|
||||
{
|
||||
placement: "top",
|
||||
modifiers: [
|
||||
{
|
||||
name: "eventListeners",
|
||||
options: { scroll: false, resize: false },
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: { padding: { top: headerOffset() } },
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
element.classList.remove("hidden");
|
||||
}
|
||||
|
||||
<template>
|
||||
<ChatEmojiPicker
|
||||
{{this.listenToBodyScroll}}
|
||||
@context="chat-channel-message"
|
||||
@didInsert={{this.didInsert}}
|
||||
@willDestroy={{this.willDestroy}}
|
||||
@didSelectEmoji={{this.didSelectEmoji}}
|
||||
class="hidden"
|
||||
/>
|
||||
</template>
|
||||
}
|
|
@ -55,7 +55,6 @@ export default class ChatChannel extends Component {
|
|||
@service chatApi;
|
||||
@service chatChannelsManager;
|
||||
@service chatDraftsManager;
|
||||
@service chatEmojiPickerManager;
|
||||
@service chatStateManager;
|
||||
@service chatChannelScrollPositions;
|
||||
@service("chat-channel-composer") composer;
|
||||
|
|
|
@ -8,8 +8,9 @@ import DMenu from "float-kit/components/d-menu";
|
|||
|
||||
export default class ChatComposerDropdown extends Component {
|
||||
@action
|
||||
onButtonClick(button, closeFn) {
|
||||
closeFn({ focusTrigger: false });
|
||||
async onButtonClick(button, closeFn) {
|
||||
await closeFn({ focusTrigger: false });
|
||||
|
||||
button.action();
|
||||
}
|
||||
|
||||
|
@ -27,6 +28,7 @@ export default class ChatComposerDropdown extends Component {
|
|||
@arrow={{true}}
|
||||
@placements={{array "top" "bottom"}}
|
||||
@identifier="chat-composer-dropdown__menu"
|
||||
@modalForMobile={{true}}
|
||||
...attributes
|
||||
as |menu|
|
||||
>
|
||||
|
@ -39,6 +41,7 @@ export default class ChatComposerDropdown extends Component {
|
|||
@label={{button.label}}
|
||||
class={{concatClass
|
||||
"chat-composer-dropdown__action-btn"
|
||||
"btn-transparent"
|
||||
button.id
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -79,9 +79,13 @@
|
|||
/>
|
||||
{{/each}}
|
||||
|
||||
<Chat::Composer::Separator />
|
||||
{{/if}}
|
||||
|
||||
<PluginOutlet
|
||||
@name="chat-composer-inline-buttons"
|
||||
@outletArgs={{hash composer=this channel=@channel}}
|
||||
/>
|
||||
|
||||
{{#if this.site.desktopView}}
|
||||
<Chat::Composer::Button
|
||||
@icon="paper-plane"
|
||||
|
@ -125,9 +129,4 @@
|
|||
<div class="chat-replying-indicator-container">
|
||||
<ChatReplyingIndicator @presenceChannelName={{this.presenceChannelName}} />
|
||||
</div>
|
||||
|
||||
<ChatEmojiPicker
|
||||
@context={{this.context}}
|
||||
@didSelectEmoji={{this.onSelectEmoji}}
|
||||
/>
|
||||
</div>
|
|
@ -9,6 +9,7 @@ import $ from "jquery";
|
|||
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
|
||||
import { translations } from "pretty-text/emoji/data";
|
||||
import { Promise } from "rsvp";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
|
||||
import { SKIP } from "discourse/lib/autocomplete";
|
||||
import {
|
||||
|
@ -23,6 +24,8 @@ import {
|
|||
initUserStatusHtml,
|
||||
renderUserStatusHtml,
|
||||
} from "discourse/lib/user-status-on-autocomplete";
|
||||
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
|
||||
import { waitForClosedKeyboard } from "discourse/lib/wait-for-keyboard";
|
||||
import { cloneJSON } from "discourse-common/lib/object";
|
||||
import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
@ -41,12 +44,12 @@ export default class ChatComposer extends Component {
|
|||
@service composerPresenceManager;
|
||||
@service chatComposerWarningsTracker;
|
||||
@service appEvents;
|
||||
@service chatEmojiReactionStore;
|
||||
@service chatEmojiPickerManager;
|
||||
@service emojiStore;
|
||||
@service currentUser;
|
||||
@service chatApi;
|
||||
@service chatDraftsManager;
|
||||
@service modal;
|
||||
@service menu;
|
||||
|
||||
@tracked isFocused = false;
|
||||
@tracked inProgressUploadsCount = 0;
|
||||
|
@ -375,14 +378,10 @@ export default class ChatComposer extends Component {
|
|||
|
||||
@action
|
||||
onSelectEmoji(emoji) {
|
||||
const code = `:${emoji}:`;
|
||||
this.chatEmojiReactionStore.track(code);
|
||||
this.composer.textarea.emojiSelected(emoji);
|
||||
|
||||
if (this.site.desktopView) {
|
||||
this.composer.focus();
|
||||
} else {
|
||||
this.chatEmojiPickerManager.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -490,16 +489,33 @@ export default class ChatComposer extends Component {
|
|||
return [matches[1]];
|
||||
}
|
||||
},
|
||||
transformComplete: (v) => {
|
||||
transformComplete: async (v) => {
|
||||
if (v.code) {
|
||||
this.chatEmojiReactionStore.track(v.code);
|
||||
return `${v.code}:`;
|
||||
} else {
|
||||
$textarea.autocomplete({ cancel: true });
|
||||
this.chatEmojiPickerManager.open({
|
||||
context: this.context,
|
||||
initialFilter: v.term,
|
||||
});
|
||||
|
||||
const menuOptions = {
|
||||
identifier: "emoji-picker",
|
||||
groupIdentifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
context: `channel_${this.args.channel.id}`,
|
||||
modalForMobile: true,
|
||||
data: {
|
||||
didSelectEmoji: (emoji) => {
|
||||
this.onSelectEmoji(emoji);
|
||||
},
|
||||
term: v.term,
|
||||
context: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
// Close the keyboard before showing the emoji picker
|
||||
// it avoids a whole range of bugs on iOS
|
||||
await waitForClosedKeyboard(this);
|
||||
|
||||
const virtualElement = virtualElementFromTextRange();
|
||||
this.menuInstance = await this.menu.show(virtualElement, menuOptions);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
@ -529,8 +545,9 @@ export default class ChatComposer extends Component {
|
|||
}
|
||||
|
||||
if (term === "") {
|
||||
if (this.chatEmojiReactionStore.favorites.length) {
|
||||
return resolve(this.chatEmojiReactionStore.favorites.slice(0, 5));
|
||||
const favorites = this.emojiStore.favoritesForContext("chat");
|
||||
if (favorites.length > 0) {
|
||||
return resolve(favorites.slice(0, 5));
|
||||
} else {
|
||||
return resolve([
|
||||
"slight_smile",
|
||||
|
@ -571,7 +588,7 @@ export default class ChatComposer extends Component {
|
|||
|
||||
const options = emojiSearch(term, {
|
||||
maxResults: 5,
|
||||
diversity: this.chatEmojiReactionStore.diversity,
|
||||
diversity: this.emojiStore.diversity,
|
||||
exclude: emojiDenied,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,246 +0,0 @@
|
|||
{{! template-lint-disable no-invalid-interactive }}
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
{{! template-lint-disable no-pointer-down-event-binding }}
|
||||
|
||||
{{#if (eq this.chatEmojiPickerManager.picker.context @context)}}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-emoji-picker"
|
||||
(if this.chatEmojiPickerManager.closing "closing")
|
||||
}}
|
||||
{{did-insert this.addClickOutsideEventListener}}
|
||||
{{did-insert this.chatEmojiPickerManager.loadEmojis}}
|
||||
{{did-insert (if @didInsert @didInsert (noop))}}
|
||||
{{will-destroy (if @willDestroy @willDestroy (noop))}}
|
||||
{{will-destroy this.removeClickOutsideEventListener}}
|
||||
{{on "keydown" this.trapKeyDownEvents}}
|
||||
...attributes
|
||||
>
|
||||
<div class="chat-emoji-picker__filter-container">
|
||||
<DcFilterInput
|
||||
{{did-insert (if this.site.desktopView this.focusFilter (noop))}}
|
||||
{{did-insert
|
||||
(fn
|
||||
this.didInputFilter this.chatEmojiPickerManager.picker.initialFilter
|
||||
)
|
||||
}}
|
||||
@value={{this.chatEmojiPickerManager.picker.initialFilter}}
|
||||
@filterAction={{with-event-value this.didInputFilter}}
|
||||
@icons={{hash left="magnifying-glass"}}
|
||||
@containerClass="chat-emoji-picker__filter"
|
||||
autofocus={{true}}
|
||||
placeholder={{i18n "chat.emoji_picker.search_placeholder"}}
|
||||
>
|
||||
<div
|
||||
class="chat-emoji-picker__fitzpatrick-scale"
|
||||
role="toolbar"
|
||||
{{on "keyup" this.didNavigateFitzpatrickScale}}
|
||||
>
|
||||
{{#if this.isExpandedFitzpatrickScale}}
|
||||
{{#each this.fitzpatrickModifiers as |fitzpatrick|}}
|
||||
|
||||
{{#if
|
||||
(not-eq fitzpatrick.scale this.chatEmojiReactionStore.diversity)
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
title={{concat "t" fitzpatrick.scale}}
|
||||
tabindex="-1"
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__fitzpatrick-modifier-btn"
|
||||
(concat "t" fitzpatrick.scale)
|
||||
}}
|
||||
{{on
|
||||
"keyup"
|
||||
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
|
||||
}}
|
||||
{{on
|
||||
"click"
|
||||
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
|
||||
}}
|
||||
>
|
||||
{{d-icon "check"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={{concat "t" this.fitzpatrick.scale}}
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__fitzpatrick-modifier-btn current"
|
||||
(concat "t" this.chatEmojiReactionStore.diversity)
|
||||
}}
|
||||
{{on "keyup" this.didToggleFitzpatrickScale}}
|
||||
{{on "click" this.didToggleFitzpatrickScale}}
|
||||
></button>
|
||||
</div>
|
||||
</DcFilterInput>
|
||||
</div>
|
||||
|
||||
{{#if this.chatEmojiPickerManager.sections.length}}
|
||||
{{#if (eq this.filteredEmojis null)}}
|
||||
<div class="chat-emoji-picker__sections-nav">
|
||||
<div
|
||||
class="chat-emoji-picker__sections-nav__indicator"
|
||||
style={{this.navIndicatorStyle}}
|
||||
></div>
|
||||
|
||||
{{#each-in this.groups as |section emojis|}}
|
||||
<DButton
|
||||
class={{concat-class
|
||||
"btn-flat"
|
||||
"chat-emoji-picker__section-btn"
|
||||
(if
|
||||
(eq this.chatEmojiPickerManager.lastVisibleSection section)
|
||||
"active"
|
||||
)
|
||||
}}
|
||||
tabindex="-1"
|
||||
style={{this.navBtnStyle}}
|
||||
@action={{fn this.didRequestSection section}}
|
||||
data-section={{section}}
|
||||
>
|
||||
{{#if (eq section "favorites")}}
|
||||
{{replace-emoji ":star:"}}
|
||||
{{else}}
|
||||
<img
|
||||
width="18"
|
||||
height="18"
|
||||
class="emoji"
|
||||
src={{get emojis "0.url"}}
|
||||
/>
|
||||
{{/if}}
|
||||
</DButton>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div
|
||||
class="chat-emoji-picker__scrollable-content"
|
||||
{{chat/emoji-picker-scroll-listener}}
|
||||
>
|
||||
<div
|
||||
class="chat-emoji-picker__sections"
|
||||
{{on "click" this.didSelectEmoji}}
|
||||
{{on "keydown" this.onSectionsKeyDown}}
|
||||
role="button"
|
||||
>
|
||||
{{#if (not-eq this.filteredEmojis null)}}
|
||||
<div class="chat-emoji-picker__section filtered">
|
||||
{{#each this.filteredEmojis as |emoji|}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonable-emoji-url
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
tabindex="0"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonable-emoji-title
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{else}}
|
||||
<p class="chat-emoji-picker__no-results">
|
||||
{{i18n "chat.emoji_picker.no_results"}}
|
||||
</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each-in this.groups as |section emojis|}}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__section"
|
||||
(if (not-eq this.filteredEmojis null) "hidden")
|
||||
}}
|
||||
data-section={{section}}
|
||||
role="region"
|
||||
aria-label={{i18n
|
||||
(concat "chat.emoji_picker." section)
|
||||
translatedFallback=section
|
||||
}}
|
||||
>
|
||||
<h2 class="chat-emoji-picker__section-title">
|
||||
{{i18n
|
||||
(concat "chat.emoji_picker." section)
|
||||
translatedFallback=section
|
||||
}}
|
||||
</h2>
|
||||
<div class="chat-emoji-picker__section-emojis">
|
||||
{{! we always want the first emoji for tabbing}}
|
||||
{{#let (get emojis "0") as |emoji|}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonable-emoji-url
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
tabindex="0"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonable-emoji-title
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{/let}}
|
||||
|
||||
{{#if
|
||||
(includes this.chatEmojiPickerManager.visibleSections section)
|
||||
}}
|
||||
{{#each emojis as |emoji index|}}
|
||||
{{! first emoji has already been rendered, we don't want to re render or would lose focus}}
|
||||
{{#if (gt index 0)}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonable-emoji-url
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
tabindex="-1"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonable-emoji-title
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="spinner medium"></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if
|
||||
(and
|
||||
this.site.mobileView
|
||||
(eq this.chatEmojiPickerManager.picker.context "chat-channel-message")
|
||||
)
|
||||
}}
|
||||
<div class="chat-emoji-picker__backdrop"></div>
|
||||
{{/if}}
|
||||
{{/if}}
|
|
@ -1,424 +0,0 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { later, schedule } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { emojiUrlFor } from "discourse/lib/text";
|
||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
export const FITZPATRICK_MODIFIERS = [
|
||||
{
|
||||
scale: 1,
|
||||
modifier: null,
|
||||
},
|
||||
{
|
||||
scale: 2,
|
||||
modifier: ":t2",
|
||||
},
|
||||
{
|
||||
scale: 3,
|
||||
modifier: ":t3",
|
||||
},
|
||||
{
|
||||
scale: 4,
|
||||
modifier: ":t4",
|
||||
},
|
||||
{
|
||||
scale: 5,
|
||||
modifier: ":t5",
|
||||
},
|
||||
{
|
||||
scale: 6,
|
||||
modifier: ":t6",
|
||||
},
|
||||
];
|
||||
|
||||
export default class ChatEmojiPicker extends Component {
|
||||
@service chatEmojiPickerManager;
|
||||
@service emojiPickerScrollObserver;
|
||||
@service chatEmojiReactionStore;
|
||||
@service capabilities;
|
||||
@service site;
|
||||
|
||||
@tracked filteredEmojis = null;
|
||||
@tracked isExpandedFitzpatrickScale = false;
|
||||
|
||||
fitzpatrickModifiers = FITZPATRICK_MODIFIERS;
|
||||
|
||||
get groups() {
|
||||
const emojis = this.chatEmojiPickerManager.emojis;
|
||||
const favorites = {
|
||||
favorites: this.chatEmojiReactionStore.favorites
|
||||
.filter((f) => !this.site.denied_emojis?.includes(f))
|
||||
.map((name) => {
|
||||
return {
|
||||
name,
|
||||
group: "favorites",
|
||||
url: emojiUrlFor(name),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
...favorites,
|
||||
...emojis,
|
||||
};
|
||||
}
|
||||
|
||||
get flatEmojis() {
|
||||
if (!this.chatEmojiPickerManager.emojis) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let { favorites, ...rest } = this.chatEmojiPickerManager.emojis;
|
||||
return Object.values(rest).flat();
|
||||
}
|
||||
|
||||
get navIndicatorStyle() {
|
||||
const section = this.chatEmojiPickerManager.lastVisibleSection;
|
||||
const index = Object.keys(this.groups).indexOf(section);
|
||||
|
||||
return htmlSafe(
|
||||
`width: ${
|
||||
100 / Object.keys(this.groups).length
|
||||
}%; transform: translateX(${index * 100}%);`
|
||||
);
|
||||
}
|
||||
|
||||
get navBtnStyle() {
|
||||
return htmlSafe(`width: ${100 / Object.keys(this.groups).length}%;`);
|
||||
}
|
||||
|
||||
@action
|
||||
trapKeyDownEvents(event) {
|
||||
if (event.key === "Escape") {
|
||||
this.chatEmojiPickerManager.close();
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "ArrowDown" &&
|
||||
event.target.classList.contains("dc-filter-input")
|
||||
) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
document
|
||||
.querySelector(
|
||||
`.chat-emoji-picker__scrollable-content .emoji[tabindex="0"]`
|
||||
)
|
||||
?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didNavigateFitzpatrickScale(event) {
|
||||
if (event.type !== "keyup") {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleNodes =
|
||||
event.target
|
||||
.closest(".chat-emoji-picker__fitzpatrick-scale")
|
||||
?.querySelectorAll(".chat-emoji-picker__fitzpatrick-modifier-btn") ||
|
||||
[];
|
||||
|
||||
const scales = [...scaleNodes];
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.target === scales[scales.length - 1]) {
|
||||
scales[0].focus();
|
||||
} else {
|
||||
event.target.nextElementSibling?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.target === scales[0]) {
|
||||
scales[scales.length - 1].focus();
|
||||
} else {
|
||||
event.target.previousElementSibling?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didToggleFitzpatrickScale(event) {
|
||||
if (event.type === "keyup") {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.isExpandedFitzpatrickScale = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.isExpandedFitzpatrickScale = !this.isExpandedFitzpatrickScale;
|
||||
}
|
||||
|
||||
@action
|
||||
didRequestFitzpatrickScale(scale, event) {
|
||||
if (event.type === "keyup") {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isExpandedFitzpatrickScale = false;
|
||||
this._focusCurrentFitzpatrickScale();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.isExpandedFitzpatrickScale = false;
|
||||
this.chatEmojiReactionStore.diversity = scale;
|
||||
this._focusCurrentFitzpatrickScale();
|
||||
}
|
||||
|
||||
_focusCurrentFitzpatrickScale() {
|
||||
schedule("afterRender", () => {
|
||||
document
|
||||
.querySelector(".chat-emoji-picker__fitzpatrick-modifier-btn.current")
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
didInputFilter(value) {
|
||||
if (!value?.length) {
|
||||
this.filteredEmojis = null;
|
||||
return;
|
||||
}
|
||||
|
||||
discourseDebounce(this, this.debouncedDidInputFilter, value, INPUT_DELAY);
|
||||
}
|
||||
|
||||
@action
|
||||
focusFilter(target) {
|
||||
schedule("afterRender", () => {
|
||||
target?.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
debouncedDidInputFilter(filter = "") {
|
||||
filter = filter.toLowerCase();
|
||||
|
||||
this.filteredEmojis = this.flatEmojis.filter(
|
||||
(emoji) =>
|
||||
emoji.name.toLowerCase().includes(filter) ||
|
||||
emoji.search_aliases?.any((alias) =>
|
||||
alias.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
|
||||
schedule("afterRender", () => {
|
||||
const scrollableContent = document.querySelector(
|
||||
".chat-emoji-picker__scrollable-content"
|
||||
);
|
||||
|
||||
if (scrollableContent) {
|
||||
scrollableContent.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onSectionsKeyDown(event) {
|
||||
if (event.key === "Enter") {
|
||||
this.didSelectEmoji(event);
|
||||
} else {
|
||||
this.didNavigateSection(event);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didNavigateSection(event) {
|
||||
const sectionsEmojis = (section) => [...section.querySelectorAll(".emoji")];
|
||||
const focusSectionsLastEmoji = (section) => {
|
||||
const emojis = sectionsEmojis(section);
|
||||
return emojis[emojis.length - 1].focus();
|
||||
};
|
||||
const focusSectionsFirstEmoji = (section) => {
|
||||
sectionsEmojis(section)[0].focus();
|
||||
};
|
||||
const currentSection = event.target.closest(".chat-emoji-picker__section");
|
||||
const focusFilter = () => {
|
||||
document.querySelector(".dc-filter-input")?.focus();
|
||||
};
|
||||
const allEmojis = () => [
|
||||
...document.querySelectorAll(
|
||||
".chat-emoji-picker__section:not(.hidden) .emoji"
|
||||
),
|
||||
];
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const nextEmoji = event.target.nextElementSibling;
|
||||
|
||||
if (nextEmoji) {
|
||||
nextEmoji.focus();
|
||||
} else {
|
||||
const nextSection = currentSection.nextElementSibling;
|
||||
if (nextSection) {
|
||||
focusSectionsFirstEmoji(nextSection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
const prevEmoji = event.target.previousElementSibling;
|
||||
|
||||
if (prevEmoji) {
|
||||
prevEmoji.focus();
|
||||
} else {
|
||||
const prevSection = currentSection.previousElementSibling;
|
||||
if (prevSection) {
|
||||
focusSectionsLastEmoji(prevSection);
|
||||
} else {
|
||||
focusFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const nextEmoji = allEmojis()
|
||||
.filter((c) => c.offsetTop > event.target.offsetTop)
|
||||
.findBy("offsetLeft", event.target.offsetLeft);
|
||||
|
||||
if (nextEmoji) {
|
||||
nextEmoji.focus();
|
||||
} else {
|
||||
// for perf reason all emojis might not be loaded at this point
|
||||
// but the first one will always be
|
||||
const nextSection = currentSection.nextElementSibling;
|
||||
if (nextSection) {
|
||||
focusSectionsFirstEmoji(nextSection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const prevEmoji = allEmojis()
|
||||
.reverse()
|
||||
.filter((c) => c.offsetTop < event.target.offsetTop)
|
||||
.findBy("offsetLeft", event.target.offsetLeft);
|
||||
|
||||
if (prevEmoji) {
|
||||
prevEmoji.focus();
|
||||
} else {
|
||||
focusFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didSelectEmoji(event) {
|
||||
if (!event.target.classList.contains("emoji")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "click" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let emoji = event.target.dataset.emoji;
|
||||
const tonable = event.target.dataset.tonable;
|
||||
const diversity = this.chatEmojiReactionStore.diversity;
|
||||
if (tonable && diversity > 1) {
|
||||
emoji = `${emoji}:t${diversity}`;
|
||||
}
|
||||
|
||||
this.args.didSelectEmoji?.(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
didRequestSection(section) {
|
||||
const scrollableContent = document.querySelector(
|
||||
".chat-emoji-picker__scrollable-content"
|
||||
);
|
||||
|
||||
this.filteredEmojis = null;
|
||||
|
||||
// we disable scroll listener during requesting section
|
||||
// to avoid it from detecting another section during scroll to requested section
|
||||
this.emojiPickerScrollObserver.enabled = false;
|
||||
this.chatEmojiPickerManager.addVisibleSections([section]);
|
||||
this.chatEmojiPickerManager.lastVisibleSection = section;
|
||||
|
||||
// iOS hack to avoid blank div when requesting section during momentum
|
||||
if (scrollableContent && this.capabilities.isIOS) {
|
||||
document.querySelector(
|
||||
".chat-emoji-picker__scrollable-content"
|
||||
).style.overflow = "hidden";
|
||||
}
|
||||
|
||||
schedule("afterRender", () => {
|
||||
const firstEmoji = document.querySelector(
|
||||
`.chat-emoji-picker__section[data-section="${section}"] .emoji:nth-child(1)`
|
||||
);
|
||||
|
||||
const targetEmoji =
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
`.chat-emoji-picker__section[data-section="${section}"] .emoji`
|
||||
),
|
||||
].find((emoji) => emoji.offsetTop > firstEmoji.offsetTop) || firstEmoji;
|
||||
|
||||
targetEmoji.focus();
|
||||
|
||||
later(() => {
|
||||
// iOS hack to avoid blank div when requesting section during momentum
|
||||
if (scrollableContent && this.capabilities.isIOS) {
|
||||
document.querySelector(
|
||||
".chat-emoji-picker__scrollable-content"
|
||||
).style.overflow = "scroll";
|
||||
}
|
||||
|
||||
this.emojiPickerScrollObserver.enabled = true;
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
addClickOutsideEventListener() {
|
||||
document.addEventListener("click", this.didClickOutside);
|
||||
}
|
||||
|
||||
@action
|
||||
removeClickOutsideEventListener() {
|
||||
document.removeEventListener("click", this.didClickOutside);
|
||||
}
|
||||
|
||||
@bind
|
||||
didClickOutside(event) {
|
||||
if (!event.target.closest(".chat-emoji-picker")) {
|
||||
this.chatEmojiPickerManager.close();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ import DropdownSelectBox from "select-kit/components/dropdown-select-box";
|
|||
import ChatMessageReaction from "discourse/plugins/chat/discourse/components/chat-message-reaction";
|
||||
import chatMessageContainer from "discourse/plugins/chat/discourse/lib/chat-message-container";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import ChatMessageReactionModel from "discourse/plugins/chat/discourse/models/chat-message-reaction";
|
||||
|
||||
const MSG_ACTIONS_VERTICAL_PADDING = -10;
|
||||
const FULL = "full";
|
||||
|
@ -26,13 +27,24 @@ const REDUCED_WIDTH_THRESHOLD = 500;
|
|||
|
||||
export default class ChatMessageActionsDesktop extends Component {
|
||||
@service chat;
|
||||
@service chatEmojiPickerManager;
|
||||
@service site;
|
||||
@service emojiStore;
|
||||
|
||||
@tracked size = FULL;
|
||||
|
||||
popper = null;
|
||||
|
||||
get favoriteReactions() {
|
||||
return this.emojiStore
|
||||
.favoritesForContext(`channel_${this.message.channel.id}`)
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(emoji) =>
|
||||
this.message.reactions.find((reaction) => reaction.emoji === emoji) ||
|
||||
ChatMessageReactionModel.create({ emoji })
|
||||
);
|
||||
}
|
||||
|
||||
get message() {
|
||||
return this.chat.activeMessage.model;
|
||||
}
|
||||
|
@ -53,6 +65,16 @@ export default class ChatMessageActionsDesktop extends Component {
|
|||
return this.size === FULL;
|
||||
}
|
||||
|
||||
get messageContainer() {
|
||||
return chatMessageContainer(this.message.id, this.context);
|
||||
}
|
||||
|
||||
@action
|
||||
openEmojiPicker(_, event) {
|
||||
event.preventDefault();
|
||||
this.messageInteractor.openEmojiPicker(event.target);
|
||||
}
|
||||
|
||||
@action
|
||||
onWheel() {
|
||||
// prevents menu to stop scroll on the list of messages
|
||||
|
@ -64,24 +86,19 @@ export default class ChatMessageActionsDesktop extends Component {
|
|||
this.popper?.destroy();
|
||||
|
||||
schedule("afterRender", () => {
|
||||
const messageContainer = chatMessageContainer(
|
||||
this.message.id,
|
||||
this.context
|
||||
);
|
||||
|
||||
if (!messageContainer) {
|
||||
if (!this.messageContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = messageContainer.closest(".popper-viewport");
|
||||
const viewport = this.messageContainer.closest(".popper-viewport");
|
||||
this.size =
|
||||
viewport.clientWidth < REDUCED_WIDTH_THRESHOLD ? REDUCED : FULL;
|
||||
|
||||
if (!messageContainer) {
|
||||
if (!this.messageContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.popper = createPopper(messageContainer, element, {
|
||||
this.popper = createPopper(this.messageContainer, element, {
|
||||
placement: "top-end",
|
||||
strategy: "fixed",
|
||||
modifiers: [
|
||||
|
@ -133,10 +150,7 @@ export default class ChatMessageActionsDesktop extends Component {
|
|||
}}
|
||||
>
|
||||
{{#if this.shouldRenderFavoriteReactions}}
|
||||
{{#each
|
||||
this.messageInteractor.emojiReactions key="emoji"
|
||||
as |reaction|
|
||||
}}
|
||||
{{#each this.favoriteReactions as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@onReaction={{this.messageInteractor.react}}
|
||||
|
@ -149,10 +163,9 @@ export default class ChatMessageActionsDesktop extends Component {
|
|||
|
||||
{{#if this.messageInteractor.canInteractWithMessage}}
|
||||
<DButton
|
||||
@action={{this.messageInteractor.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@action={{this.openEmojiPicker}}
|
||||
@forwardEvent={{true}}
|
||||
@icon="discourse-emojis"
|
||||
class="btn-flat react-btn"
|
||||
/>
|
||||
{{/if}}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { and, or } from "truth-helpers";
|
|||
import BookmarkIcon from "discourse/components/bookmark-icon";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DModal from "discourse/components/d-modal";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import ChatMessageReaction from "discourse/plugins/chat/discourse/components/chat-message-reaction";
|
||||
import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat-user-avatar";
|
||||
|
@ -19,6 +20,8 @@ export default class ChatMessageActionsMobile extends Component {
|
|||
@service chat;
|
||||
@service site;
|
||||
@service capabilities;
|
||||
@service modal;
|
||||
@service menu;
|
||||
|
||||
@tracked hasExpandedReply = false;
|
||||
|
||||
|
@ -70,9 +73,19 @@ export default class ChatMessageActionsMobile extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
openEmojiPicker(_, event) {
|
||||
this.args.closeModal();
|
||||
this.messageInteractor.openEmojiPicker(_, event);
|
||||
async openEmojiPicker(_, event) {
|
||||
await this.args.closeModal();
|
||||
|
||||
await this.menu.show(event.target, {
|
||||
identifier: "emoji-picker",
|
||||
groupIdentifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
modalForMobile: true,
|
||||
data: {
|
||||
context: "chat",
|
||||
didSelectEmoji: this.messageInteractor.selectReaction,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
|
@ -128,12 +141,10 @@ export default class ChatMessageActionsMobile extends Component {
|
|||
{{/each}}
|
||||
|
||||
<DButton
|
||||
@action={{this.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
data-id="react"
|
||||
class="btn-flat react-btn"
|
||||
@action={{this.openEmojiPicker}}
|
||||
@forwardEvent={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { Input } from "@ember/component";
|
||||
import { fn } from "@ember/helper";
|
||||
import { concat, fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
|
@ -13,6 +13,7 @@ import { service } from "@ember/service";
|
|||
import { modifier } from "ember-modifier";
|
||||
import { eq, lt, not } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import EmojiPicker from "discourse/components/emoji-picker";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { applyValueTransformer } from "discourse/lib/transformer";
|
||||
import { updateUserStatusOnMention } from "discourse/lib/update-user-status-on-mention";
|
||||
|
@ -57,14 +58,13 @@ export default class ChatMessage extends Component {
|
|||
@service capabilities;
|
||||
@service chat;
|
||||
@service chatApi;
|
||||
@service chatEmojiReactionStore;
|
||||
@service chatEmojiPickerManager;
|
||||
@service chatChannelPane;
|
||||
@service chatThreadPane;
|
||||
@service chatChannelsManager;
|
||||
@service router;
|
||||
@service toasts;
|
||||
@service modal;
|
||||
@service interactedChatMessage;
|
||||
|
||||
@tracked isActive = false;
|
||||
|
||||
|
@ -305,6 +305,10 @@ export default class ChatMessage extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.interactedChatMessage.emojiPickerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.secondaryActionsIsExpanded) {
|
||||
this._onMouseEnterMessageDebouncedHandler = discourseDebounce(
|
||||
this,
|
||||
|
@ -324,9 +328,15 @@ export default class ChatMessage extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.secondaryActionsIsExpanded) {
|
||||
this._setActiveMessage();
|
||||
if (this.secondaryActionsIsExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.interactedChatMessage.emojiPickerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setActiveMessage();
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -344,9 +354,16 @@ export default class ChatMessage extends Component {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
if (!this.secondaryActionsIsExpanded) {
|
||||
this.chat.activeMessage = null;
|
||||
|
||||
if (this.interactedChatMessage.emojiPickerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.secondaryActionsIsExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chat.activeMessage = null;
|
||||
}
|
||||
|
||||
@bind
|
||||
|
@ -522,6 +539,16 @@ export default class ChatMessage extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
@action
|
||||
onEmojiPickerClose() {
|
||||
this.interactedChatMessage.emojiPickerOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
onEmojiPickerShow() {
|
||||
this.interactedChatMessage.emojiPickerOpen = true;
|
||||
}
|
||||
|
||||
@action
|
||||
stopMessageStreaming(message) {
|
||||
this.chatApi.stopMessageStreaming(message.channel.id, message.id);
|
||||
|
@ -651,12 +678,13 @@ export default class ChatMessage extends Component {
|
|||
{{/each}}
|
||||
|
||||
{{#if this.shouldRenderOpenEmojiPickerButton}}
|
||||
<DButton
|
||||
@action={{this.messageInteractor.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
class="chat-message-react-btn"
|
||||
<EmojiPicker
|
||||
@context={{concat "channel_" @message.channel.id}}
|
||||
@didSelectEmoji={{this.messageInteractor.selectReaction}}
|
||||
@btnClass="btn-flat react-btn chat-message-react-btn"
|
||||
@onClose={{this.onEmojiPickerClose}}
|
||||
@onShow={{this.onEmojiPickerShow}}
|
||||
class="chat-message-reaction"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
|
|
@ -6,6 +6,7 @@ import { action } from "@ember/object";
|
|||
import { LinkTo } from "@ember/routing";
|
||||
import { service } from "@ember/service";
|
||||
import { modifier } from "ember-modifier";
|
||||
import FilterInput from "discourse/components/filter-input";
|
||||
import isElementInViewport from "discourse/lib/is-element-in-viewport";
|
||||
import DiscourseURL, { userPath } from "discourse/lib/url";
|
||||
import autoFocus from "discourse/modifiers/auto-focus";
|
||||
|
@ -16,7 +17,6 @@ import { i18n } from "discourse-i18n";
|
|||
import MessageCreator from "discourse/plugins/chat/discourse/components/chat/message-creator";
|
||||
import { MODES } from "discourse/plugins/chat/discourse/components/chat/message-creator/constants";
|
||||
import ChatUserInfo from "discourse/plugins/chat/discourse/components/chat-user-info";
|
||||
import DcFilterInput from "discourse/plugins/chat/discourse/components/dc-filter-input";
|
||||
|
||||
export default class ChatRouteChannelInfoMembers extends Component {
|
||||
@service appEvents;
|
||||
|
@ -144,7 +144,7 @@ export default class ChatRouteChannelInfoMembers extends Component {
|
|||
/>
|
||||
{{else}}
|
||||
<div class="c-channel-members">
|
||||
<DcFilterInput
|
||||
<FilterInput
|
||||
{{autoFocus}}
|
||||
@filterAction={{this.mutFilter}}
|
||||
@icons={{hash right="magnifying-glass"}}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<ChatChannelMessageEmojiPicker />
|
|
@ -0,0 +1,9 @@
|
|||
{{#if this.site.desktopView}}
|
||||
<EmojiPicker
|
||||
@context={{concat "channel_" @outletArgs.channel.id}}
|
||||
@didSelectEmoji={{@outletArgs.composer.onSelectEmoji}}
|
||||
@btnClass="chat-composer-button btn-transparent -emoji"
|
||||
/>
|
||||
|
||||
<Chat::Composer::Separator />
|
||||
{{/if}}
|
|
@ -1,7 +0,0 @@
|
|||
export default function tonableEmojiTitle(emoji, diversity) {
|
||||
if (!emoji.tonable || diversity === 1) {
|
||||
return `:${emoji.name}:`;
|
||||
}
|
||||
|
||||
return `:${emoji.name}:t${diversity}:`;
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
export default function tonableEmojiUrl(emoji, scale) {
|
||||
if (!emoji.tonable || scale === 1) {
|
||||
return emoji.url;
|
||||
}
|
||||
|
||||
return emoji.url.split(".png")[0] + `/${scale}.png`;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { setOwner } from "@ember/owner";
|
||||
import { service } from "@ember/service";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import { number } from "discourse/lib/formatter";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
|
||||
|
@ -58,6 +59,37 @@ class ChatSetupInit {
|
|||
|
||||
api.registerHashtagType("channel", new ChannelHashtagType(owner));
|
||||
|
||||
if (this.siteSettings.enable_emoji) {
|
||||
api.registerChatComposerButton({
|
||||
label: "chat.emoji",
|
||||
id: "emoji",
|
||||
class: "chat-emoji-btn",
|
||||
icon: "discourse-emojis",
|
||||
position: "dropdown",
|
||||
displayed: owner.lookup("service:site").mobileView,
|
||||
action(context) {
|
||||
const didSelectEmoji = (emoji) => {
|
||||
const composer = owner.lookup(`service:chat-${context}-composer`);
|
||||
composer.textarea.addText(
|
||||
composer.textarea.getSelected(),
|
||||
`:${emoji}:`
|
||||
);
|
||||
};
|
||||
|
||||
owner.lookup("service:menu").show(document.body, {
|
||||
identifier: "emoji-picker",
|
||||
groupIdentifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
modalForMobile: true,
|
||||
data: {
|
||||
context: "chat",
|
||||
didSelectEmoji,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
api.registerChatComposerButton({
|
||||
id: "chat-upload-btn",
|
||||
icon: "far-image",
|
||||
|
@ -83,36 +115,6 @@ class ChatSetupInit {
|
|||
});
|
||||
}
|
||||
|
||||
api.registerChatComposerButton({
|
||||
label: "chat.emoji",
|
||||
id: "emoji",
|
||||
class: "chat-emoji-btn",
|
||||
icon: "far-face-smile",
|
||||
position: this.site.desktopView ? "inline" : "dropdown",
|
||||
context: "channel",
|
||||
action() {
|
||||
const chatEmojiPickerManager = owner.lookup(
|
||||
"service:chat-emoji-picker-manager"
|
||||
);
|
||||
chatEmojiPickerManager.open({ context: "channel" });
|
||||
},
|
||||
});
|
||||
|
||||
api.registerChatComposerButton({
|
||||
label: "chat.emoji",
|
||||
id: "channel-emoji",
|
||||
class: "chat-emoji-btn",
|
||||
icon: "discourse-emojis",
|
||||
position: "dropdown",
|
||||
context: "thread",
|
||||
action() {
|
||||
const chatEmojiPickerManager = owner.lookup(
|
||||
"service:chat-emoji-picker-manager"
|
||||
);
|
||||
chatEmojiPickerManager.open({ context: "thread" });
|
||||
},
|
||||
});
|
||||
|
||||
// we want to decorate the chat quote dates regardless
|
||||
// of whether the current user has chat enabled
|
||||
api.decorateCookedElement((elem) => {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { tracked } from "@glimmer/tracking";
|
|||
import { action } from "@ember/object";
|
||||
import { getOwner, setOwner } from "@ember/owner";
|
||||
import { service } from "@ember/service";
|
||||
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
|
||||
import BookmarkModal from "discourse/components/modal/bookmark";
|
||||
import FlagModal from "discourse/components/modal/flag";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
|
@ -14,9 +15,7 @@ import { i18n } from "discourse-i18n";
|
|||
import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message";
|
||||
import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
import ChatMessageReaction, {
|
||||
REACTIONS,
|
||||
} from "discourse/plugins/chat/discourse/models/chat-message-reaction";
|
||||
import { REACTIONS } from "discourse/plugins/chat/discourse/models/chat-message-reaction";
|
||||
|
||||
const removedSecondaryActions = new Set();
|
||||
|
||||
|
@ -28,12 +27,10 @@ export function resetRemovedChatComposerSecondaryActions() {
|
|||
removedSecondaryActions.clear();
|
||||
}
|
||||
|
||||
export default class ChatMessageInteractor {
|
||||
export default class ChatemojiReactions {
|
||||
@service appEvents;
|
||||
@service dialog;
|
||||
@service chat;
|
||||
@service chatEmojiReactionStore;
|
||||
@service chatEmojiPickerManager;
|
||||
@service chatChannelComposer;
|
||||
@service chatThreadComposer;
|
||||
@service chatChannelPane;
|
||||
|
@ -44,19 +41,18 @@ export default class ChatMessageInteractor {
|
|||
@service router;
|
||||
@service modal;
|
||||
@service capabilities;
|
||||
@service menu;
|
||||
@service toasts;
|
||||
@service interactedChatMessage;
|
||||
|
||||
@tracked message = null;
|
||||
@tracked context = null;
|
||||
|
||||
cachedFavoritesReactions = null;
|
||||
|
||||
constructor(owner, message, context) {
|
||||
setOwner(this, owner);
|
||||
|
||||
this.message = message;
|
||||
this.context = context;
|
||||
this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites;
|
||||
}
|
||||
|
||||
get pane() {
|
||||
|
@ -65,22 +61,6 @@ export default class ChatMessageInteractor {
|
|||
: this.chatChannelPane;
|
||||
}
|
||||
|
||||
get emojiReactions() {
|
||||
let favorites = this.cachedFavoritesReactions;
|
||||
|
||||
// may be a {} if no defaults defined in some production builds
|
||||
if (!favorites || !favorites.slice) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return favorites.slice(0, 3).map((emoji) => {
|
||||
return (
|
||||
this.message.reactions.find((reaction) => reaction.emoji === emoji) ||
|
||||
ChatMessageReaction.create({ emoji })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
return (
|
||||
!this.message.deletedAt &&
|
||||
|
@ -291,10 +271,6 @@ export default class ChatMessageInteractor {
|
|||
this.chat.activeMessage = null;
|
||||
}
|
||||
|
||||
if (reactAction === REACTIONS.add) {
|
||||
this.chatEmojiReactionStore.track(`:${emoji}:`);
|
||||
}
|
||||
|
||||
this.pane.reacting = true;
|
||||
|
||||
this.message.react(
|
||||
|
@ -406,13 +382,29 @@ export default class ChatMessageInteractor {
|
|||
}
|
||||
|
||||
@action
|
||||
openEmojiPicker(_, { target }) {
|
||||
const pickerState = {
|
||||
didSelectEmoji: this.selectReaction,
|
||||
trigger: target,
|
||||
context: "chat-channel-message",
|
||||
};
|
||||
this.chatEmojiPickerManager.open(pickerState);
|
||||
async openEmojiPicker(trigger) {
|
||||
this.interactedChatMessage.emojiPickerOpen = true;
|
||||
|
||||
await this.menu.show(trigger, {
|
||||
identifier: "emoji-picker",
|
||||
groupIdentifier: "emoji-picker",
|
||||
component: EmojiPickerDetached,
|
||||
onClose: () => {
|
||||
this.interactedChatMessage.emojiPickerOpen = false;
|
||||
},
|
||||
data: {
|
||||
context: `channel_${this.message.channel.id}`,
|
||||
didSelectEmoji: (emoji) => {
|
||||
this.selectReaction(emoji);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async closeEmojiPicker() {
|
||||
await this.menu.close("emoji-picker");
|
||||
this.interactedChatMessage.emojiPickerOpen = false;
|
||||
}
|
||||
|
||||
@bind
|
||||
|
|
|
@ -4,6 +4,7 @@ import { setOwner } from "@ember/owner";
|
|||
import { next, schedule } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import TextareaTextManipulation from "discourse/lib/textarea-text-manipulation";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
// This class sole purpose is to provide a way to interact with the textarea
|
||||
// using the existing TextareaTextManipulation mixin without using it directly
|
||||
|
@ -45,6 +46,7 @@ export default class TextareaInteractor extends EmberObject {
|
|||
this.textarea.dispatchEvent(event);
|
||||
}
|
||||
|
||||
@bind
|
||||
blur() {
|
||||
next(() => {
|
||||
schedule("afterRender", () => {
|
||||
|
@ -53,6 +55,7 @@ export default class TextareaInteractor extends EmberObject {
|
|||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
focus(opts = { ensureAtEnd: false, refreshHeight: true, addText: null }) {
|
||||
next(() => {
|
||||
schedule("afterRender", () => {
|
||||
|
@ -80,6 +83,7 @@ export default class TextareaInteractor extends EmberObject {
|
|||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
ensureCaretAtEnd() {
|
||||
schedule("afterRender", () => {
|
||||
this.textarea.setSelectionRange(
|
||||
|
@ -89,6 +93,7 @@ export default class TextareaInteractor extends EmberObject {
|
|||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
refreshHeight() {
|
||||
schedule("afterRender", () => {
|
||||
// this is a quirk which forces us to `auto` first or textarea
|
||||
|
@ -101,18 +106,22 @@ export default class TextareaInteractor extends EmberObject {
|
|||
});
|
||||
}
|
||||
|
||||
@bind
|
||||
getSelected() {
|
||||
return this.textManipulation.getSelected(...arguments);
|
||||
}
|
||||
|
||||
@bind
|
||||
applySurround() {
|
||||
return this.textManipulation.applySurround(...arguments);
|
||||
}
|
||||
|
||||
@bind
|
||||
addText() {
|
||||
return this.textManipulation.addText(...arguments);
|
||||
}
|
||||
|
||||
@bind
|
||||
isInside() {
|
||||
return this.textManipulation.isInside(...arguments);
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import { registerDestructor } from "@ember/destroyable";
|
||||
import { service } from "@ember/service";
|
||||
import Modifier from "ember-modifier";
|
||||
|
||||
export default class EmojiPickerScrollListener extends Modifier {
|
||||
@service emojiPickerScrollObserver;
|
||||
|
||||
element = null;
|
||||
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
registerDestructor(this, (instance) => instance.cleanup());
|
||||
}
|
||||
|
||||
modify(element) {
|
||||
this.element = element;
|
||||
this.emojiPickerScrollObserver.observe(element);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.emojiPickerScrollObserver.unobserve(this.element);
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
import ChatEmojiPickerManager from "./chat-emoji-picker-manager";
|
||||
|
||||
export default class ChatChannelEmojiPickerManager extends ChatEmojiPickerManager {}
|
|
@ -1,91 +0,0 @@
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import Service, { service } from "@ember/service";
|
||||
import { Promise } from "rsvp";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { makeArray } from "discourse-common/lib/helpers";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
const TRANSITION_TIME = 125; // CSS transition time
|
||||
const DEFAULT_VISIBLE_SECTIONS = ["favorites", "smileys_&_emotion"];
|
||||
const DEFAULT_LAST_SECTION = "favorites";
|
||||
|
||||
export default class ChatEmojiPickerManager extends Service {
|
||||
@service appEvents;
|
||||
|
||||
@tracked closing = false;
|
||||
@tracked loading = false;
|
||||
@tracked picker = null;
|
||||
@tracked emojis = null;
|
||||
@tracked visibleSections = DEFAULT_VISIBLE_SECTIONS;
|
||||
@tracked lastVisibleSection = DEFAULT_LAST_SECTION;
|
||||
@tracked element = null;
|
||||
|
||||
get sections() {
|
||||
return !this.loading && this.emojis ? Object.keys(this.emojis) : [];
|
||||
}
|
||||
|
||||
@bind
|
||||
closeExisting() {
|
||||
this.visibleSections = DEFAULT_VISIBLE_SECTIONS;
|
||||
this.lastVisibleSection = DEFAULT_LAST_SECTION;
|
||||
this.picker = null;
|
||||
}
|
||||
|
||||
@bind
|
||||
close() {
|
||||
this.closing = true;
|
||||
|
||||
discourseLater(() => {
|
||||
if (this.isDestroyed || this.isDestroying) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.visibleSections = DEFAULT_VISIBLE_SECTIONS;
|
||||
this.lastVisibleSection = DEFAULT_LAST_SECTION;
|
||||
this.closing = false;
|
||||
this.picker = null;
|
||||
}, TRANSITION_TIME);
|
||||
}
|
||||
|
||||
addVisibleSections(sections) {
|
||||
this.visibleSections = makeArray(this.visibleSections)
|
||||
.concat(makeArray(sections))
|
||||
.uniq();
|
||||
}
|
||||
|
||||
open(picker) {
|
||||
this.loadEmojis();
|
||||
|
||||
if (this.picker) {
|
||||
if (this.picker.trigger === picker.trigger) {
|
||||
this.closeExisting();
|
||||
} else {
|
||||
this.closeExisting();
|
||||
this.picker = picker;
|
||||
}
|
||||
} else {
|
||||
this.picker = picker;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
loadEmojis() {
|
||||
if (this.emojis) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
return ajax("/chat/emojis.json")
|
||||
.then((emojis) => {
|
||||
this.emojis = emojis;
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
// This class is adapted from emoji-store class in core. We want to maintain separate emoji store for reactions in chat plugin.
|
||||
// https://github.com/discourse/discourse/blob/892f7e0506f3a4d40d9a59a4c926ff0a2aa0947e/app/assets/javascripts/discourse/app/services/emoji-store.js
|
||||
|
||||
import Service, { service } from "@ember/service";
|
||||
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
|
||||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
|
||||
@disableImplicitInjections
|
||||
export default class ChatEmojiReactionStore extends Service {
|
||||
@service siteSettings;
|
||||
|
||||
STORE_NAMESPACE = "discourse_chat_emoji_reaction_";
|
||||
MAX_DISPLAYED_EMOJIS = 20;
|
||||
MAX_TRACKED_EMOJIS = this.MAX_DISPLAYED_EMOJIS * 2;
|
||||
SKIN_TONE_STORE_KEY = "emojiSelectedDiversity";
|
||||
USER_EMOJIS_STORE_KEY = "emojiUsage";
|
||||
|
||||
store = new KeyValueStore(this.STORE_NAMESPACE);
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
if (!this.store.getObject(this.USER_EMOJIS_STORE_KEY)) {
|
||||
this.storedFavorites = [];
|
||||
}
|
||||
}
|
||||
|
||||
get diversity() {
|
||||
return this.store.getObject(this.SKIN_TONE_STORE_KEY) || 1;
|
||||
}
|
||||
|
||||
set diversity(value = 1) {
|
||||
this.store.setObject({ key: this.SKIN_TONE_STORE_KEY, value });
|
||||
this.notifyPropertyChange("diversity");
|
||||
}
|
||||
|
||||
get storedFavorites() {
|
||||
let value = this.store.getObject(this.USER_EMOJIS_STORE_KEY) || [];
|
||||
|
||||
if (value.length < 1) {
|
||||
if (!this.siteSettings.default_emoji_reactions) {
|
||||
value = [];
|
||||
} else {
|
||||
value = this.siteSettings.default_emoji_reactions
|
||||
.split("|")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
set storedFavorites(value) {
|
||||
this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value });
|
||||
this.notifyPropertyChange("favorites");
|
||||
}
|
||||
|
||||
get favorites() {
|
||||
const computedStored = [
|
||||
...new Set(this._frequencySort(this.storedFavorites)),
|
||||
];
|
||||
|
||||
return computedStored.slice(0, this.MAX_DISPLAYED_EMOJIS);
|
||||
}
|
||||
|
||||
set favorites(value = []) {
|
||||
this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value });
|
||||
}
|
||||
|
||||
track(code) {
|
||||
const normalizedCode = code.replace(/(^:)|(:$)/g, "");
|
||||
let recent = this.storedFavorites;
|
||||
recent.unshift(normalizedCode);
|
||||
recent.length = Math.min(recent.length, this.MAX_TRACKED_EMOJIS);
|
||||
this.storedFavorites = recent;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value: [] });
|
||||
this.store.setObject({ key: this.SKIN_TONE_STORE_KEY, value: 1 });
|
||||
}
|
||||
|
||||
_frequencySort(array = []) {
|
||||
const counters = array.reduce((obj, val) => {
|
||||
obj[val] = (obj[val] || 0) + 1;
|
||||
return obj;
|
||||
}, {});
|
||||
return Object.keys(counters).sort((a, b) => counters[b] - counters[a]);
|
||||
}
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
import Service, { service } from "@ember/service";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
export default class EmojiPickerScrollObserver extends Service {
|
||||
@service chatEmojiPickerManager;
|
||||
|
||||
@tracked enabled = true;
|
||||
direction = "up";
|
||||
prevYPosition = 0;
|
||||
|
||||
@bind
|
||||
_observerCallback(event) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setScrollDirection(event.target);
|
||||
|
||||
const visibleSections = [
|
||||
...document.querySelectorAll(".chat-emoji-picker__section"),
|
||||
].filter((sectionElement) =>
|
||||
this._isSectionVisibleInPicker(sectionElement, event.target)
|
||||
);
|
||||
|
||||
if (visibleSections?.length) {
|
||||
let sectionElement;
|
||||
|
||||
if (this.direction === "up" || this.prevYPosition < 50) {
|
||||
sectionElement = visibleSections.firstObject;
|
||||
} else {
|
||||
sectionElement = visibleSections.lastObject;
|
||||
}
|
||||
|
||||
this.chatEmojiPickerManager.lastVisibleSection =
|
||||
sectionElement.dataset.section;
|
||||
|
||||
this.chatEmojiPickerManager.addVisibleSections(
|
||||
visibleSections.map((s) => s.dataset.section)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
observe(element) {
|
||||
element.addEventListener("scroll", this._observerCallback);
|
||||
}
|
||||
|
||||
unobserve(element) {
|
||||
element.removeEventListener("scroll", this._observerCallback);
|
||||
}
|
||||
|
||||
_setScrollDirection(target) {
|
||||
if (target.scrollTop > this.prevYPosition) {
|
||||
this.direction = "down";
|
||||
} else {
|
||||
this.direction = "up";
|
||||
}
|
||||
|
||||
this.prevYPosition = target.scrollTop;
|
||||
}
|
||||
|
||||
_isSectionVisibleInPicker(section, picker) {
|
||||
const { bottom, height, top } = section.getBoundingClientRect();
|
||||
const containerRect = picker.getBoundingClientRect();
|
||||
|
||||
return top <= containerRect.top
|
||||
? containerRect.top - top <= height
|
||||
: bottom - containerRect.bottom <= height;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
import Service from "@ember/service";
|
||||
|
||||
export default class InteractedChatMessage extends Service {
|
||||
@tracked secondaryOptionsOpen = false;
|
||||
@tracked emojiPickerOpen = false;
|
||||
}
|
|
@ -137,16 +137,6 @@ html.ios-device.keyboard-visible body #main-outlet .full-page-chat {
|
|||
color: var(--primary-high);
|
||||
font-size: var(--font-down-2);
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.chat-.chat-message-react-btn {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-emoji-avatar {
|
||||
|
|
|
@ -48,11 +48,11 @@
|
|||
@include breakpoint(tablet) {
|
||||
flex-direction: column;
|
||||
|
||||
.dc-filter-input-container {
|
||||
.filter-input-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.dc-filter-input-container,
|
||||
.filter-input-container,
|
||||
nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -1,21 +1,9 @@
|
|||
.chat-composer.is-disabled {
|
||||
.no-touch & {
|
||||
.chat-composer-dropdown__trigger-btn:hover {
|
||||
cursor: default;
|
||||
.d-icon {
|
||||
color: var(--primary-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-composer-dropdown__trigger-btn {
|
||||
margin-left: 0.2rem;
|
||||
transition: transform 0.25s ease-in-out;
|
||||
|
||||
.d-icon {
|
||||
padding: 5px;
|
||||
transition: transform 0.1s ease-in-out;
|
||||
|
||||
background: var(--primary-low);
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
@ -30,17 +18,6 @@
|
|||
background: none !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.-expanded {
|
||||
.d-icon {
|
||||
transform: rotate(135deg);
|
||||
transform-origin: center center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-composer-dropdown__list {
|
||||
|
@ -52,9 +29,4 @@
|
|||
.chat-composer-dropdown__action-btn {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
background: none;
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,6 +93,7 @@
|
|||
cursor: inherit;
|
||||
@include chat-scrollbar();
|
||||
white-space: pre-wrap !important;
|
||||
min-width: 20px;
|
||||
|
||||
&[disabled] {
|
||||
background: none;
|
||||
|
|
|
@ -12,11 +12,6 @@
|
|||
border-radius: var(--d-border-radius);
|
||||
display: flex;
|
||||
|
||||
.emoji-picker-anchor {
|
||||
position: absolute;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.link-to-message-btn {
|
||||
.d-icon {
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
@ -34,7 +29,6 @@
|
|||
.reply-btn,
|
||||
.chat-message-thread-btn,
|
||||
.bookmark-btn {
|
||||
margin-right: -1px;
|
||||
padding: 0.5em 0;
|
||||
width: 2.5em;
|
||||
|
||||
|
|
|
@ -113,7 +113,7 @@
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.chat-message-react-btn {
|
||||
.react-btn {
|
||||
vertical-align: top;
|
||||
padding: 0em 0.25em;
|
||||
background: none;
|
||||
|
@ -166,7 +166,7 @@
|
|||
|
||||
&:hover {
|
||||
.chat-message-reaction-list .chat-message-react-btn {
|
||||
display: inline-block;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -250,6 +250,6 @@
|
|||
}
|
||||
|
||||
.chat-message-reaction-list .chat-message-react-btn {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
@import "chat-composer-button";
|
||||
@import "chat-message-blocks";
|
||||
@import "chat-drawer";
|
||||
@import "chat-emoji-picker";
|
||||
@import "chat-form";
|
||||
@import "chat-index";
|
||||
@import "chat-mention-warnings";
|
||||
|
@ -41,7 +40,6 @@
|
|||
@import "chat-upload-drop-zone";
|
||||
@import "chat-transcript";
|
||||
@import "core-extensions";
|
||||
@import "dc-filter-input";
|
||||
@import "incoming-chat-webhooks";
|
||||
@import "reviewable-chat-message";
|
||||
@import "chat-thread-list-item";
|
||||
|
|
|
@ -6,8 +6,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-composer-dropdown__trigger-btn {
|
||||
align-self: flex-end;
|
||||
height: 50px;
|
||||
}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
[data-content][data-identifier="chat-composer-dropdown__menu"] {
|
||||
width: calc(100% - 2rem);
|
||||
max-width: unset;
|
||||
|
||||
.fk-d-menu__inner-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-composer-dropdown {
|
||||
&__list {
|
||||
width: 100%;
|
||||
}
|
||||
&__action-btn {
|
||||
padding-block: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
.chat-channel-message-emoji-picker-connector {
|
||||
position: relative;
|
||||
|
||||
.chat-emoji-picker {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-card);
|
||||
z-index: z("header") + 2;
|
||||
max-width: 100vw;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(var(--always-black-rgb), 0.75);
|
||||
z-index: z("header") + 1;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,12 +1,10 @@
|
|||
@import "base-mobile";
|
||||
@import "chat-channel";
|
||||
@import "chat-composer";
|
||||
@import "chat-composer-dropdown";
|
||||
@import "chat-index";
|
||||
@import "chat-message-actions";
|
||||
@import "chat-message";
|
||||
@import "chat-selection-manager";
|
||||
@import "chat-emoji-picker";
|
||||
@import "chat-composer-upload";
|
||||
@import "chat-thread";
|
||||
@import "chat-threads-list";
|
||||
|
|
|
@ -15,7 +15,6 @@ en:
|
|||
chat_minimum_message_length: "Minimum number of characters for a chat message."
|
||||
chat_allow_uploads: "Allow uploads in public chat channels and direct message channels."
|
||||
chat_archive_destination_topic_status: "The status that the destination topic should be once a channel archive is completed. This only applies when the destination topic is a new topic, not an existing one."
|
||||
default_emoji_reactions: "Default emoji reactions for chat messages. Add up to 5 emojis for quick reaction."
|
||||
direct_message_enabled_groups: "Allow users within these groups to create user-to-user Personal Chats. Note: staff can always create Personal Chats, and users will be able to reply to Personal Chats initiated by users who have permission to create them."
|
||||
chat_message_flag_allowed_groups: "Users in these groups are allowed to flag chat messages. Note that admins and moderators can always flag chat messages."
|
||||
max_mentions_per_chat_message: "Maximum number of @name notifications a user can use in a chat message."
|
||||
|
|
|
@ -91,8 +91,6 @@ Chat::Engine.routes.draw do
|
|||
put "/user_chat_enabled/:user_id" => "chat#set_user_chat_status"
|
||||
post "/:chat_channel_id" => "api/channel_messages#create"
|
||||
|
||||
get "/emojis" => "emojis#index"
|
||||
|
||||
base_c_route = "/c/:channel_title/:channel_id"
|
||||
get base_c_route => "chat#respond", :as => "channel"
|
||||
get "#{base_c_route}/:message_id" => "chat#respond"
|
||||
|
|
|
@ -70,10 +70,6 @@ chat:
|
|||
default: 0.5
|
||||
min: 0
|
||||
max: 1
|
||||
default_emoji_reactions:
|
||||
type: emoji_list
|
||||
default: +1|heart|tada
|
||||
client: true
|
||||
chat_minimum_message_length:
|
||||
type: integer
|
||||
default: 1
|
||||
|
|
|
@ -64,7 +64,7 @@ RSpec.describe "Chat composer", type: :system do
|
|||
|
||||
click_link(I18n.t("js.composer.more_emoji"))
|
||||
|
||||
expect(find(".chat-emoji-picker .dc-filter-input").value).to eq("gri")
|
||||
expect(find(".emoji-picker .filter-input").value).to eq("gri")
|
||||
end
|
||||
|
||||
xit "filters with the prefilled input" do
|
||||
|
@ -73,8 +73,8 @@ RSpec.describe "Chat composer", type: :system do
|
|||
|
||||
click_link(I18n.t("js.composer.more_emoji"))
|
||||
|
||||
expect(page).to have_selector(".chat-emoji-picker [data-emoji='fr']")
|
||||
expect(page).to have_no_selector(".chat-emoji-picker [data-emoji='grinning']")
|
||||
expect(page).to have_selector(".emoji-picker [data-emoji='fr']")
|
||||
expect(page).to have_no_selector(".emoji-picker [data-emoji='grinning']")
|
||||
end
|
||||
|
||||
xit "replaces the partially typed emoji with the selected" do
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user