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:
Joffrey JAFFEUX 2025-01-08 11:41:36 +01:00 committed by GitHub
parent d9ddc25808
commit 6740a340ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
115 changed files with 1939 additions and 6328 deletions

View File

@ -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}}
/>

View File

@ -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);
},
},
});
});
}

View File

@ -77,11 +77,3 @@
</span>
</div>
</div>
<EmojiPicker
@isActive={{this.emojiPickerIsActive}}
@isEditorFocused={{this.isEditorFocused}}
@initialFilter={{this.emojiFilter}}
@emojiSelected={{this.textManipulation.emojiSelected}}
@onEmojiPickerClose={{this.onEmojiPickerClose}}
/>

View File

@ -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) {

View File

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

View File

@ -1,3 +0,0 @@
import Component from "@ember/component";
export default class EmojiGroupButtons extends Component {}

View File

@ -1,3 +0,0 @@
import Component from "@ember/component";
export default class EmojiGroupSections extends Component {}

View File

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

View File

@ -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);
}
}
}

View File

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

View File

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

View File

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

View File

@ -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}}
&#8203;
{{/if}}
</:trigger>
<:content>
<EmojiPickerContent
@close={{this.menu.close}}
@didSelectEmoji={{@didSelectEmoji}}
@context={{this.context}}
/>
</:content>
</DMenu>
</template>
}

View File

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

View File

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

View File

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

View File

@ -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",
});
});
});

View File

@ -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;
}
this.rect = this.range.getBoundingClientRect();
// 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;
}

View File

@ -45,6 +45,8 @@ export default class CloseOnClickOutside extends Modifier {
}
cleanup() {
document.removeEventListener("pointerdown", this.check);
document.removeEventListener("pointerdown", this.check, {
passive: true,
});
}
}

View File

@ -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]);
}
}

View File

@ -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"
);
});
});

View File

@ -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>`
);
});

View File

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

View File

@ -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";

View 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: [],
},
]
}
}

View File

@ -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);
},
};
}

View File

@ -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")

View File

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

View File

@ -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"
);
});

View File

@ -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"])
);
await render(hbs`
<DButton class="emoji-picker-anchor" @action={{this.showEmojiPicker}} />
<EmojiPicker @isActive={{this.pickerIsActive}} @placement="bottom" />
`);
await click(".emoji-picker-anchor");
assert
.dom(".emoji-picker.opened")
.hasAttribute("data-popper-placement", "bottom");
this.emojiStore = this.container.lookup("service:emoji-store");
});
test("when placement == right, places the picker on the right", async function (assert) {
this.set("showEmojiPicker", () => {
this.set("pickerIsActive", true);
});
hooks.afterEach(function () {
this.emojiStore.diversity = 1;
});
await render(hbs`
<DButton class="emoji-picker-anchor" @action={{this.showEmojiPicker}} />
<EmojiPicker @isActive={{this.pickerIsActive}} @placement="right" />
`);
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", "right");
.dom(`.emoji-picker__section-btn.active[data-section="favorites"]`)
.exists("it renders first section as active");
assert
.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"
);
});
});

View File

@ -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"));
});
});

View File

@ -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");
});
});

View File

@ -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) {

View File

@ -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",
]);
});
});

View File

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

View File

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

View File

@ -71,6 +71,7 @@ export const MENU = {
modalForMobile: false,
inline: null,
groupIdentifier: null,
parentIdentifier: null,
triggerClass: null,
contentClass: null,
class: null,

View File

@ -70,6 +70,8 @@ export default class DMenuInstance extends FloatKitInstance {
if (options.focusTrigger) {
this.trigger?.focus?.();
}
await this.options.onClose?.(this);
}
@action

View File

@ -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());

View File

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

View File

@ -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);
}

View File

@ -58,3 +58,6 @@
@import "widget-dropdown";
@import "welcome-header";
@import "notifications-tracking";
@import "emoji-picker";
@import "filter-input";
@import "dropdown-menu";

View 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;
}
}

View File

@ -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;
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;
}
&__indicator {
background: var(--tertiary);
height: 4px;
transition: transform 0.3s cubic-bezier(0.1, 0.82, 0.25, 1);
position: absolute;
bottom: 0;
}
&__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;
}
}
}

View File

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

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -12,7 +12,6 @@
@import "directory";
@import "discourse";
@import "edit-category";
@import "emoji";
@import "header";
@import "invite-signup";
@import "lightbox";

View File

@ -4,3 +4,6 @@
@import "user-card";
@import "user-stream-item";
@import "welcome-header";
@import "more-topics";
@import "bookmark-menu";
@import "emoji-picker";

View 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;
}
}

View File

@ -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;
}
}
}

View File

@ -10,9 +10,6 @@
max-width: 100px;
border-radius: 10px;
}
.d-modal__body {
padding: 1em 0;
}
h3 {
padding-top: 0.25em;

View 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

View File

@ -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: ""

View File

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

View File

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

View File

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

View File

@ -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,33 +137,19 @@ 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>
<row.Col @size={{6}}>
<DButton
@label="chat.incoming_webhooks.reset_emoji"
@action={{fn this.resetEmoji form.set}}
@disabled={{not field.value}}
class="admin-chat-webhooks-clear-emoji"
/>
</row.Col>
</form.Row>
{{/unless}}
<form.Row as |row|>
<row.Col @size={{2}}>
<EmojiPicker @didSelectEmoji={{fn this.emojiSelected form.set}} />
</row.Col>
<row.Col @size={{6}}>
<DButton
@label="chat.incoming_webhooks.reset_emoji"
@action={{fn this.resetEmoji form.set}}
@disabled={{not field.value}}
class="admin-chat-webhooks-clear-emoji"
/>
</row.Col>
</form.Row>
</field.Custom>
</form.Field>

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}}
/>

View File

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

View File

@ -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,
});

View File

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

View File

@ -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();
}
}
}

View File

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

View File

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

View File

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

View File

@ -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"}}

View File

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

View File

@ -1,7 +0,0 @@
export default function tonableEmojiTitle(emoji, diversity) {
if (!emoji.tonable || diversity === 1) {
return `:${emoji.name}:`;
}
return `:${emoji.name}:t${diversity}:`;
}

View File

@ -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`;
}

View File

@ -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) => {

View File

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

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -1,3 +0,0 @@
import ChatEmojiPickerManager from "./chat-emoji-picker-manager";
export default class ChatChannelEmojiPickerManager extends ChatEmojiPickerManager {}

View File

@ -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;
});
}
}

View File

@ -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]);
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

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

View File

@ -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%;
}

View File

@ -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);
}
}

View File

@ -93,6 +93,7 @@
cursor: inherit;
@include chat-scrollbar();
white-space: pre-wrap !important;
min-width: 20px;
&[disabled] {
background: none;

View File

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

View File

@ -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;
}
}

View File

@ -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";

View File

@ -6,8 +6,3 @@
}
}
}
.chat-composer-dropdown__trigger-btn {
align-self: flex-end;
height: 50px;
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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";

View File

@ -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."

View File

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

View File

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

View File

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