]> Untitled Git - lemmy-ui.git/commitdiff
Add Custom Emoji Support
authorAnon <makotech222@gmail.com>
Sun, 27 Nov 2022 18:02:32 +0000 (12:02 -0600)
committerAnon <makotech222@gmail.com>
Sun, 26 Feb 2023 17:22:05 +0000 (11:22 -0600)
lemmy-translations
package.json
src/assets/css/main.css
src/shared/components/common/emoji-mart.tsx [new file with mode: 0644]
src/shared/components/common/emoji-picker.tsx [new file with mode: 0644]
src/shared/components/common/markdown-textarea.tsx
src/shared/components/home/admin-settings.tsx
src/shared/components/home/emojis-form.tsx [new file with mode: 0644]
src/shared/components/home/home.tsx
src/shared/utils.ts
yarn.lock

index 7379716231b9f7e67f710751c839398b7ab5d65e..819531ae64c6cba12cb406eb98333fd52988bf3e 160000 (submodule)
@@ -1 +1 @@
-Subproject commit 7379716231b9f7e67f710751c839398b7ab5d65e
+Subproject commit 819531ae64c6cba12cb406eb98333fd52988bf3e
index 839a3d88e4bd78eec23c197e0e839a163273f0b7..87f23d08442820667c6a0e004c91bcec795cbbe2 100644 (file)
@@ -23,6 +23,7 @@
     "@babel/preset-env": "7.20.2",
     "@babel/preset-typescript": "^7.21.0",
     "@babel/runtime": "^7.21.0",
+    "@emoji-mart/data": "^1.1.0",
     "autosize": "^6.0.1",
     "babel-loader": "^9.1.2",
     "babel-plugin-inferno": "^6.6.0",
@@ -32,6 +33,7 @@
     "clean-webpack-plugin": "^4.0.0",
     "copy-webpack-plugin": "^11.0.0",
     "css-loader": "^6.7.3",
+    "emoji-mart": "^5.4.0",
     "emoji-short-name": "^2.0.0",
     "express": "~4.18.2",
     "html-to-text": "^9.0.4",
@@ -48,6 +50,7 @@
     "lemmy-js-client": "0.17.2-rc.1",
     "markdown-it": "^13.0.1",
     "markdown-it-container": "^3.0.0",
+    "markdown-it-emoji": "^2.0.2",
     "markdown-it-footnote": "^3.0.3",
     "markdown-it-html5-embed": "^1.0.0",
     "markdown-it-sub": "^1.0.0",
index 766c9dbd19b7f6b2b9dd41efa800b930df64d407..e796b7faf33f12c6cac18a4be97ebdffce2f6e8f 100644 (file)
 .md-div h1 {
   font-size: 2rem;
 }
+
 .md-div h2 {
   font-size: 1.8rem;
 }
+
 .md-div h3 {
   font-size: 1.6rem;
 }
+
 .md-div h4 {
   font-size: 1.4rem;
 }
+
 .md-div h5 {
   font-size: 1.2rem;
 }
@@ -95,7 +99,7 @@
   border-bottom: 2px solid var(--dark);
 }
 
-.md-div table tbody + tbody {
+.md-div table tbody+tbody {
   border-top: 2px solid var(--dark);
 }
 
   user-select: none;
 }
 
+.icon-emoji {
+  width: 4em;
+  height: auto;
+  max-height: inherit;
+}
+
+.icon-emoji-admin {
+  max-width: 24px; 
+  max-height: 24px; 
+  display: inline-block;
+}
+
 .icon-inline {
   margin-bottom: 2px;
 }
 
+.emoji-picker-container {
+  position: absolute;
+  top: 30px;
+  z-index: 1000;
+  transform: translateX(-50%);
+}
+
+@media only screen and (max-width: 992px) {
+  .emoji-picker-container {
+    width: 100vw;
+    transform: translateX(0%);
+    position: fixed;
+    left: 0;
+  }
+
+  .emoji-picker-container>section {
+    width: 100% !important;
+  }
+}
+
+.click-away-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, .3);
+  z-index: 999;
+}
+
 .spinner-large {
   display: grid;
   display: block;
   0% {
     transform: rotate(0deg);
   }
+
   100% {
     transform: rotate(359deg);
   }
@@ -340,19 +387,24 @@ br.big {
   list-style: none;
   background: var(--light);
 }
+
 .tribute-container li {
   padding: 5px 5px;
   cursor: pointer;
 }
+
 .tribute-container li.highlight {
   background: var(--primary);
 }
+
 .tribute-container li span {
   font-weight: bold;
 }
+
 .tribute-container li.no-match {
   cursor: default;
 }
+
 .tribute-container .menu-highlighted {
   font-weight: bold;
 }
@@ -376,7 +428,6 @@ br.big {
   -webkit-line-clamp: 3;
   -webkit-box-orient: vertical;
 }
-
 .lang-select-action {
   width: 100px;
 }
@@ -384,3 +435,6 @@ br.big {
 .lang-select-action:focus {
   width: auto;
 }
+em-emoji-picker{
+  width:100%;
+}
diff --git a/src/shared/components/common/emoji-mart.tsx b/src/shared/components/common/emoji-mart.tsx
new file mode 100644 (file)
index 0000000..39b09bc
--- /dev/null
@@ -0,0 +1,33 @@
+import { Component } from "inferno";
+import { getEmojiMart } from "../../utils";
+
+
+interface EmojiMartProps {
+    onEmojiClick?(val: any): any;
+    pickerOptions: any;
+}
+
+export class EmojiMart extends Component<
+    EmojiMartProps
+> {
+    constructor(props: any, context: any) {
+        super(props, context);
+        this.handleEmojiClick = this.handleEmojiClick.bind(this);
+    }
+    componentDidMount() {
+        let div: any = document.getElementById("emoji-picker");
+        if (div) {
+            div.appendChild(getEmojiMart(this.handleEmojiClick, this.props.pickerOptions));
+        }
+    }
+
+    render() {
+        return (
+            <div id="emoji-picker"></div>
+        );
+    }
+
+    handleEmojiClick(e: any) {
+        this.props.onEmojiClick?.(e);
+    }
+}
diff --git a/src/shared/components/common/emoji-picker.tsx b/src/shared/components/common/emoji-picker.tsx
new file mode 100644 (file)
index 0000000..cb46651
--- /dev/null
@@ -0,0 +1,62 @@
+import { Component, linkEvent } from "inferno";
+import { i18n } from "../../i18next";
+import { EmojiMart } from "./emoji-mart";
+import { Icon } from "./icon";
+
+interface EmojiPickerProps {
+    onEmojiClick?(val: any): any;
+}
+
+interface EmojiPickerState {
+    showPicker: boolean,
+}
+
+export class EmojiPicker extends Component<
+    EmojiPickerProps,
+    EmojiPickerState
+> {
+    private emptyState: EmojiPickerState = {
+        showPicker: false,
+    };
+    state: EmojiPickerState;
+    constructor(props: any, context: any) {
+        super(props, context);
+        this.state = this.emptyState;
+        this.handleEmojiClick = this.handleEmojiClick.bind(this);
+    }
+    render() {
+        return (
+            <span>
+                <button
+                    className="btn btn-sm text-muted"
+                    data-tippy-content={i18n.t("emoji")}
+                    aria-label={i18n.t("emoji")}
+                    onClick={linkEvent(this,this.togglePicker)}
+                >
+                    <Icon icon="smile" classes="icon-inline" />
+                </button>
+
+                {this.state.showPicker && (
+                    <>
+                        <div className="emoji-picker-container">
+                            <EmojiMart onEmojiClick={this.handleEmojiClick} pickerOptions={({})}></EmojiMart>
+                        </div>
+                        <div
+                            onClick={linkEvent(this,this.togglePicker)}
+                            className="click-away-container"
+                        />
+                    </>
+                )}
+            </span>
+        );
+    }
+
+    togglePicker(i: EmojiPicker, e: any) {
+        e.preventDefault();
+        i.setState({ showPicker: !i.state.showPicker });
+    }
+
+    handleEmojiClick(e: any) {
+        this.props.onEmojiClick?.(e);
+    }
+}
\ No newline at end of file
index 48b90428be90797f3af42ca96640bcd4e7245003..07a966c11a280a12f86a14949f4308ca772b91d2 100644 (file)
@@ -6,6 +6,7 @@ import { pictrsUri } from "../../env";
 import { i18n } from "../../i18next";
 import { UserService } from "../../services";
 import {
+  customEmojisLookup,
   isBrowser,
   markdownFieldCharacterLimit,
   markdownHelpUrl,
@@ -17,6 +18,7 @@ import {
   setupTribute,
   toast,
 } from "../../utils";
+import { EmojiPicker } from "./emoji-picker";
 import { Icon, Spinner } from "./icon";
 import { LanguageSelect } from "./language-select";
 
@@ -226,6 +228,7 @@ export class MarkdownTextArea extends Component<
             >
               <Icon icon="link" classes="icon-inline" />
             </button>
+            <EmojiPicker onEmojiClick={(e) => this.handleEmoji(this,e)}></EmojiPicker>
             <form className="btn btn-sm text-muted font-weight-bold">
               <label
                 htmlFor={`file-upload-${this.id}`}
@@ -328,6 +331,22 @@ export class MarkdownTextArea extends Component<
     );
   }
 
+  handleEmoji(i: MarkdownTextArea, e: any) {
+    let value = e.native;
+    if (value == null){
+      let emoji = customEmojisLookup.get(e.id)?.custom_emoji;
+      if (emoji){
+        value = `![${emoji.alt_text}](${emoji.image_url} "${emoji.shortcode}")`;
+      }
+    }
+    i.setState({
+      content: `${i.state.content ?? ""} ${value} `,
+    });
+    i.contentChange();
+    let textarea: any = document.getElementById(i.id);
+    autosize.update(textarea);
+  }
+
   handleImageUploadPaste(i: MarkdownTextArea, event: any) {
     let image = event.clipboardData.files[0];
     if (image) {
index b590988ab6b19779d1ea2d8368920ccf307a93c1..e4a7509708bdefbcc52af8b82c6c311d90902199 100644 (file)
@@ -28,6 +28,7 @@ import {
 import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
 import { PersonListing } from "../person/person-listing";
+import { EmojiForm } from "./emojis-form";
 import { SiteForm } from "./site-form";
 
 interface AdminSettingsState {
@@ -35,6 +36,7 @@ interface AdminSettingsState {
   banned: PersonViewSafe[];
   loading: boolean;
   leaveAdminTeamLoading: boolean;
+  currentTab: string;
 }
 
 export class AdminSettings extends Component<any, AdminSettingsState> {
@@ -46,6 +48,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
     banned: [],
     loading: true,
     leaveAdminTeamLoading: false,
+    currentTab: "site",
   };
 
   constructor(props: any, context: any) {
@@ -99,9 +102,8 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
   }
 
   get documentTitle(): string {
-    return `${i18n.t("admin_settings")} - ${
-      this.state.siteRes.site_view.site.name
-    }`;
+    return `${i18n.t("admin_settings")} - ${this.state.siteRes.site_view.site.name
+      }`;
   }
 
   render() {
@@ -112,21 +114,47 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
             <Spinner large />
           </h5>
         ) : (
-          <div className="row">
-            <div className="col-12 col-md-6">
-              <HtmlTags
-                title={this.documentTitle}
-                path={this.context.router.route.match.url}
-              />
-              <SiteForm
-                siteRes={this.state.siteRes}
-                showLocal={showLocal(this.isoData)}
-              />
-            </div>
-            <div className="col-12 col-md-6">
-              {this.admins()}
-              {this.bannedUsers()}
-            </div>
+          <div>
+            <HtmlTags
+                  title={this.documentTitle}
+                  path={this.context.router.route.match.url}
+                />
+            <ul className="nav nav-tabs mb-2">
+              <li className="nav-item">
+                <button
+                  className={`nav-link btn ${this.state.currentTab == "site" && "active"}`}
+                  onClick={linkEvent({ ctx: this, tab: "site" }, this.handleSwitchTab) }>
+                  {i18n.t("site")}
+                </button>
+              </li>
+              <li className="nav-item">
+                <button
+                  className={`nav-link btn ${this.state.currentTab == "emojis" && "active"
+                    }`}
+                  onClick={linkEvent({ ctx: this, tab: "emojis" }, this.handleSwitchTab) }>
+                  {i18n.t("emojis")}
+                </button>
+              </li>
+            </ul>
+            {this.state.currentTab == "site" &&
+              <div className="row"><div className="col-12 col-md-6">
+                
+                <SiteForm
+                  siteRes={this.state.siteRes}
+                  showLocal={showLocal(this.isoData)}
+                />
+              </div>
+                <div className="col-12 col-md-6">
+                  {this.admins()}
+                  {this.bannedUsers()}
+                </div>
+              </div>
+            }
+            {this.state.currentTab == "emojis" &&
+              <div className="row">
+                <EmojiForm></EmojiForm>
+              </div>
+            }
           </div>
         )}
       </div>
@@ -179,6 +207,10 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
     );
   }
 
+  handleSwitchTab(i: { ctx: AdminSettings; tab: string }, event: any) {
+    i.ctx.setState({ currentTab: i.tab });
+  }
+
   handleLeaveAdminTeam(i: AdminSettings) {
     let auth = myAuth();
     if (auth) {
diff --git a/src/shared/components/home/emojis-form.tsx b/src/shared/components/home/emojis-form.tsx
new file mode 100644 (file)
index 0000000..73fceac
--- /dev/null
@@ -0,0 +1,445 @@
+import { Component, linkEvent } from "inferno";
+import { GetSiteResponse, UserOperation, wsJsonToRes, wsUserOp } from "lemmy-js-client";
+import { CreateCustomEmoji, CustomEmojiResponse, DeleteCustomEmoji, DeleteCustomEmojiResponse, EditCustomEmoji } from "lemmy-js-client/dist/interfaces/api/custom_emoji";
+import { WebSocketService } from "../../services";
+import { i18n } from "../../i18next";
+import { customEmojisLookup, isBrowser, myAuth, removeFromEmojiDataModel, setIsoData, toast, updateEmojiDataModel, wsClient, wsSubscribe } from "../../utils";
+import { EmojiMart } from "../common/emoji-mart";
+import { HtmlTags } from "../common/html-tags";
+import { Icon } from "../common/icon";
+import { Subscription } from "rxjs";
+import { pictrsUri } from "../../env";
+import { Paginator } from "../common/paginator";
+
+interface EmojiFormState {
+    siteRes: GetSiteResponse;
+    customEmojis: CustomEmojiViewForm[];
+    loading: boolean;
+    page: number;
+}
+
+interface CustomEmojiViewForm {
+    id: number;
+    category: string;
+    shortcode: string;
+    image_url: string;
+    alt_text: string;
+    keywords: string;
+    changed: boolean;
+    page: number;
+}
+
+export class EmojiForm extends Component<any, EmojiFormState> {
+    private isoData = setIsoData(this.context);
+    private subscription: Subscription | undefined;
+    private itemsPerPage = 15;
+    private emptyState: EmojiFormState = {
+        loading: false,
+        siteRes: this.isoData.site_res,
+        customEmojis: this.isoData.site_res.custom_emojis.map((x, index) =>
+        ({
+            id: x.custom_emoji.id,
+            category: x.custom_emoji.category,
+            shortcode: x.custom_emoji.shortcode,
+            image_url: x.custom_emoji.image_url,
+            alt_text: x.custom_emoji.alt_text,
+            keywords: x.keywords.map(x => x.keyword).join(" "),
+            changed: false,
+            page: 1 + (Math.floor(index / this.itemsPerPage))
+        })),
+        page: 1
+    };
+    state: EmojiFormState;
+    private scrollRef: any = {};
+    constructor(props: any, context: any) {
+        super(props, context);
+        this.state = this.emptyState;
+
+        this.handlePageChange = this.handlePageChange.bind(this);
+        this.parseMessage = this.parseMessage.bind(this);
+        this.handleEmojiClick = this.handleEmojiClick.bind(this);
+        this.subscription = wsSubscribe(this.parseMessage);
+    }
+    get documentTitle(): string {
+        return i18n.t("custom_emojis");
+    }
+
+    componentWillUnmount() {
+        if (isBrowser()) {
+            this.subscription?.unsubscribe();
+        }
+    }
+
+    render() {
+        return (
+            <div className="col-12">
+                <HtmlTags
+                    title={this.documentTitle}
+                    path={this.context.router.route.match.url}
+                />
+                <h5 className="col-12">{i18n.t("custom_emojis")}</h5>
+                {customEmojisLookup.size > 0 && <div>
+                    <EmojiMart onEmojiClick={this.handleEmojiClick} pickerOptions={this.configurePicker()}></EmojiMart>
+                </div>}
+                <div className="table-responsive">
+                    <table
+                        id="emojis_table"
+                        className="table table-sm table-hover"
+                    >
+                        <thead className="pointer">
+                            <tr>
+                                <th>{i18n.t("column_emoji")}</th>
+                                <th className="text-right">{i18n.t("column_shortcode")}</th>
+                                <th className="text-right">{i18n.t("column_category")}</th>
+                                <th className="text-right">{i18n.t("column_imageurl")}</th>
+                                <th className="text-right">{i18n.t("column_alttext")}</th>
+                                <th className="text-right d-none d-lg-table-cell">{i18n.t("column_keywords")}</th>
+                                <th style="width:121px"></th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {this.state.customEmojis.slice((this.state.page - 1) * this.itemsPerPage, ((this.state.page - 1) * this.itemsPerPage) + this.itemsPerPage)
+                                .map((cv, index) => (
+                                    <tr key={index} ref={e => this.scrollRef[cv.shortcode] = e}>
+                                        <td style="text-align:center;">
+                                            <label
+                                                htmlFor={index.toString()}
+                                                className="pointer text-muted small font-weight-bold"
+                                            >
+                                                {cv.image_url.length > 0 && <img className="icon-emoji-admin" src={cv.image_url} />}
+                                                {cv.image_url.length == 0 && <span className="btn btn-sm btn-secondary">Upload</span>}
+                                            </label>
+                                            <input
+                                                name={index.toString()}
+                                                id={index.toString()}
+                                                type="file"
+                                                accept="image/*"
+                                                className="d-none"
+                                                onChange={linkEvent({form: this, index: index},this.handleImageUpload)}
+                                            />
+                                        </td>
+                                        <td className="text-right">
+                                            <input
+                                                type="text"
+                                                placeholder="ShortCode"
+                                                className="form-control"
+                                                disabled={cv.id > 0}
+                                                value={cv.shortcode}
+                                                onInput={linkEvent({form: this, index: index},this.handleEmojiShortCodeChange)}
+                                            />
+                                        </td>
+                                        <td className="text-right">
+                                            <input
+                                                type="text"
+                                                placeholder="Category"
+                                                className="form-control"
+                                                value={cv.category}
+                                                onInput={linkEvent({form: this, index: index},this.handleEmojiCategoryChange)}
+                                            />
+                                        </td>
+                                        <td className="text-right">
+                                            <input
+                                                type="text"
+                                                placeholder="Url"
+                                                className="form-control"
+                                                value={cv.image_url}
+                                                onInput={linkEvent({form: this, index: index, overrideValue: null},this.handleEmojiImageUrlChange)}
+                                            />
+                                        </td>
+                                        <td className="text-right">
+                                            <input
+                                                type="text"
+                                                placeholder="Alt Text"
+                                                className="form-control"
+                                                value={cv.alt_text}
+                                                onInput={linkEvent({form: this, index: index},this.handleEmojiAltTextChange)}
+                                            />
+                                        </td>
+                                        <td className="text-right d-none d-lg-table-cell">
+                                            <input
+                                                type="text"
+                                                placeholder="Keywords"
+                                                className="form-control"
+                                                value={cv.keywords}
+                                                onInput={linkEvent({form: this, index: index},this.handleEmojiKeywordChange)}
+                                            />
+                                        </td>
+                                        <td>
+                                            <div>
+                                                <span
+                                                    title={this.getEditTooltip(cv)}>
+                                                    <button
+                                                        className={(cv.changed ? "text-success " : "text-muted ") + "btn btn-link btn-animate"}
+                                                        onClick={linkEvent({form: this, cv: cv},this.handleEditEmojiClick)}
+                                                        data-tippy-content={i18n.t("save")}
+                                                        aria-label={i18n.t("save")}
+                                                        disabled={this.state.loading || !this.canEdit(cv) || !cv.changed}
+                                                    >
+                                                        {/* <Icon
+                                                            icon="edit"
+                                                            classes={`icon-inline`}
+                                                        /> */}
+                                                        Save
+                                                    </button>
+                                                </span>
+                                                <button
+                                                    className="btn btn-link btn-animate text-muted"
+                                                    onClick={linkEvent({form: this, index: index, cv:cv},this.handleDeleteEmojiClick)}
+                                                    data-tippy-content={i18n.t("delete")}
+                                                    aria-label={i18n.t("delete")}
+                                                    disabled={this.state.loading}
+                                                    title={i18n.t("delete")}
+                                                >
+                                                    <Icon
+                                                        icon="trash"
+                                                        classes={`icon-inline text-danger`}
+                                                    />
+                                                </button>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                ))}
+                        </tbody>
+                    </table>
+                    <br />
+                    <button
+                        className="btn btn-sm btn-secondary mr-2"
+                        onClick={linkEvent(this,this.handleAddEmojiClick)}
+                    >
+                        {i18n.t("add_custom_emoji")}
+                    </button>
+
+                    <Paginator
+                        page={this.state.page}
+                        onChange={this.handlePageChange}
+                    />
+                </div>
+            </div>
+        );
+    }
+
+    canEdit(cv: CustomEmojiViewForm) {
+        const noEmptyFields = (cv.alt_text.length > 0 && cv.category.length > 0 && cv.image_url.length > 0 && cv.shortcode.length > 0);
+        const noDuplicateShortCodes = this.state.customEmojis.filter(x => x.shortcode == cv.shortcode && x.id != cv.id).length == 0;
+        return noEmptyFields && noDuplicateShortCodes;
+    }
+
+    getEditTooltip(cv: CustomEmojiViewForm) {
+        if (this.canEdit(cv))
+            return i18n.t("save");
+        else
+            return i18n.t("custom_emoji_save_validation");
+    }
+
+    handlePageChange(page: number) {
+        this.setState({ page: page });
+    }
+
+    handleEmojiClick(e: any) {
+        const view = customEmojisLookup.get(e.id);
+        if (view) {
+            const page = this.state.customEmojis.find(x => x.id == view.custom_emoji.id)?.page;
+            if (page) {
+                this.setState({ page: page });
+                this.scrollRef[view.custom_emoji.shortcode].scrollIntoView()
+            }
+        }
+    }
+
+    handleEmojiCategoryChange(props: { form: EmojiForm, index: number }, event: any) {
+        let custom_emojis = [...props.form.state.customEmojis];
+        let pagedIndex = ((props.form.state.page - 1) * props.form.itemsPerPage) + props.index;
+        let item = {
+            ... props.form.state.customEmojis[pagedIndex],
+            category: event.target.value,
+            changed: true,
+        }
+        custom_emojis[pagedIndex] = item;
+        props.form.setState({ customEmojis: custom_emojis });
+    }
+
+    handleEmojiShortCodeChange(props: { form: EmojiForm, index: number }, event: any) {
+        let custom_emojis = [...props.form.state.customEmojis];
+        let pagedIndex = ((props.form.state.page - 1) * props.form.itemsPerPage) + props.index;
+        let item = {
+            ... props.form.state.customEmojis[pagedIndex],
+            shortcode: event.target.value,
+            changed: true,
+        }
+        custom_emojis[pagedIndex] = item;
+        props.form.setState({ customEmojis: custom_emojis });
+    }
+
+    handleEmojiImageUrlChange(props: { form: EmojiForm, index: number, overrideValue: string | null }, event: any) {
+        let custom_emojis = [...props.form.state.customEmojis];
+        let pagedIndex = ((props.form.state.page - 1) * props.form.itemsPerPage) + props.index;
+        let item = {
+            ... props.form.state.customEmojis[pagedIndex],
+            image_url: props.overrideValue ?? event.target.value,
+            changed: true,
+        }
+        custom_emojis[pagedIndex] = item;
+        props.form.setState({ customEmojis: custom_emojis });
+    }
+
+    handleEmojiAltTextChange(props: { form: EmojiForm, index: number }, event: any) {
+        let custom_emojis = [...props.form.state.customEmojis];
+        let pagedIndex = ((props.form.state.page - 1) * props.form.itemsPerPage) + props.index;
+        let item = {
+            ... props.form.state.customEmojis[pagedIndex],
+            alt_text: event.target.value,
+            changed: true,
+        }
+        custom_emojis[pagedIndex] = item;
+        props.form.setState({ customEmojis: custom_emojis });
+    }
+
+    handleEmojiKeywordChange(props: { form: EmojiForm, index: number }, event: any) {
+        let custom_emojis = [...props.form.state.customEmojis];
+        let pagedIndex = ((props.form.state.page - 1) * props.form.itemsPerPage) + props.index;
+        let item = {
+            ... props.form.state.customEmojis[pagedIndex],
+            keywords: event.target.value,
+            changed: true,
+        }
+        custom_emojis[pagedIndex] = item;
+        props.form.setState({ customEmojis: custom_emojis });
+    }
+
+    handleDeleteEmojiClick(props: { form: EmojiForm, index: number, cv: CustomEmojiViewForm }) {
+        let pagedIndex = ((props.form.state.page - 1) * props.form.itemsPerPage) + props.index;
+        if (props.cv.id != 0) {
+            const deleteForm: DeleteCustomEmoji = {
+                id: props.cv.id,
+                auth: myAuth() ?? ""
+            };
+            WebSocketService.Instance.send(wsClient.deleteCustomEmoji(deleteForm));
+        }
+        else {
+            let custom_emojis = [...props.form.state.customEmojis];
+            custom_emojis.splice(pagedIndex, 1);
+            props.form.setState({ customEmojis: custom_emojis });
+        }
+    }
+
+    handleEditEmojiClick(props: { form: EmojiForm, cv: CustomEmojiViewForm }) {
+        const keywords = props.cv.keywords.split(" ").filter(x => x.length > 0) as string[];
+        const uniqueKeywords = Array.from(new Set(keywords));
+        if (props.cv.id != 0) {
+            const editForm: EditCustomEmoji = {
+                id: props.cv.id,
+                category: props.cv.category,
+                image_url: props.cv.image_url,
+                alt_text: props.cv.alt_text,
+                keywords: uniqueKeywords,
+                auth: myAuth() ?? ""
+            };
+            WebSocketService.Instance.send(wsClient.editCustomEmoji(editForm));
+        }
+        else {
+            const createForm: CreateCustomEmoji = {
+                category: props.cv.category,
+                shortcode: props.cv.shortcode,
+                image_url: props.cv.image_url,
+                alt_text: props.cv.alt_text,
+                keywords: uniqueKeywords,
+                auth: myAuth() ?? ""
+            };
+            WebSocketService.Instance.send(wsClient.createCustomEmoji(createForm));
+        }
+    }
+
+    handleAddEmojiClick(form: EmojiForm, event: any) {
+        event.preventDefault();
+        let custom_emojis = [...form.state.customEmojis];
+        const page = 1 + (Math.floor((form.state.customEmojis.length) / form.itemsPerPage))
+        let item: CustomEmojiViewForm = {
+            id: 0,
+            shortcode: "",
+            alt_text: "",
+            category: "",
+            image_url: "",
+            keywords: "",
+            changed: true,
+            page: page
+        }
+        custom_emojis.push(item);
+        form.setState({ customEmojis: custom_emojis, page: page });
+    }
+
+    async handleImageUpload(props: { form: EmojiForm, index: number }, event: any) {
+        event.preventDefault();
+        let file = event.target.files[0];
+        const formData = new FormData();
+        formData.append("images[]", file);
+
+        props.form.setState({ loading: true });
+        let res: any = await fetch(pictrsUri, {
+            method: "POST",
+            body: formData,
+        });
+        let data = await res.json();
+        props.form.setState({ loading: false });
+        if (data.msg != "ok") {
+            toast(JSON.stringify(data), "danger");
+        }
+        else {
+            let hash = data.files[0].file;
+            let url = `${pictrsUri}/${hash}`;
+            props.form.handleEmojiImageUrlChange({form: props.form, index: props.index, overrideValue: url}, event)
+        }
+    }
+
+    configurePicker(): any {
+        return {
+            data: { categories: [], emojis: [], aliases: [] },
+            maxFrequentRows: 0,
+            dynamicWidth: true,
+        };
+    }
+
+    parseMessage(msg: any) {
+        let op = wsUserOp(msg);
+        console.log(msg);
+        if (msg.error) {
+            toast(i18n.t(msg.error), "danger");
+            this.context.router.history.push("/");
+            this.setState({ loading: false });
+            return;
+        }
+        else if (op == UserOperation.CreateCustomEmoji) {
+            let data = wsJsonToRes<CustomEmojiResponse>(msg);
+            const custom_emoji_view = data.custom_emoji;
+            updateEmojiDataModel(custom_emoji_view)
+            let currentEmojis = this.state.customEmojis;
+            let newEmojiIndex = currentEmojis.findIndex(x => x.shortcode == custom_emoji_view.custom_emoji.shortcode)
+            currentEmojis[newEmojiIndex].id = custom_emoji_view.custom_emoji.id;
+            currentEmojis[newEmojiIndex].changed = false;
+            this.setState({ customEmojis: currentEmojis });
+            toast(i18n.t("saved_emoji"));
+            this.setState({ loading: false });
+        }
+        else if (op == UserOperation.EditCustomEmoji) {
+            let data = wsJsonToRes<CustomEmojiResponse>(msg);
+            const custom_emoji_view = data.custom_emoji;
+            updateEmojiDataModel(data.custom_emoji)
+            let currentEmojis = this.state.customEmojis;
+            let newEmojiIndex = currentEmojis.findIndex(x => x.shortcode == custom_emoji_view.custom_emoji.shortcode)
+            currentEmojis[newEmojiIndex].changed = false;
+            this.setState({ customEmojis: currentEmojis });
+            toast(i18n.t("saved_emoji"));
+            this.setState({ loading: false });
+        }
+        else if (op == UserOperation.DeleteCustomEmoji) {
+            let data = wsJsonToRes<DeleteCustomEmojiResponse>(msg);
+            if (data.success) {
+                removeFromEmojiDataModel(data.id);
+                let custom_emojis = [...this.state.customEmojis.filter(x => x.id != data.id)];
+                this.setState({ customEmojis: custom_emojis });
+                toast(i18n.t("deleted_emoji"));
+            }
+            this.setState({ loading: false });
+        }
+    }
+}
+
index b0187c9a5d3085328641e9096d4a7dd8084db2ed..1cd231e7d22935f4ec7eb4623f39960350360bdc 100644 (file)
@@ -170,7 +170,7 @@ export class Home extends Component<any, HomeState> {
           wsClient.communityJoin({ community_id: 0 })
         );
       }
-      const taglines = this.state.siteRes.taglines;
+      const taglines = this.state?.siteRes?.taglines ?? [];
       this.state = {
         ...this.state,
         trendingCommunities: trendingRes?.communities ?? [],
index 09da9dbab73dc08960845d62888cc253e8138536..99e8e56277fd59bf722a2207ca7995844b0d8fbb 100644 (file)
@@ -1,4 +1,5 @@
 import emojiShortName from "emoji-short-name";
+import { Picker } from 'emoji-mart'
 import {
   BlockCommunityResponse,
   BlockPersonResponse,
@@ -9,6 +10,7 @@ import {
   CommentView,
   CommunityModeratorView,
   CommunityView,
+  CustomEmojiView,
   GetSiteMetadata,
   GetSiteResponse,
   Language,
@@ -32,6 +34,7 @@ import markdown_it_footnote from "markdown-it-footnote";
 import markdown_it_html5_embed from "markdown-it-html5-embed";
 import markdown_it_sub from "markdown-it-sub";
 import markdown_it_sup from "markdown-it-sup";
+import markdownitEmoji from 'markdown-it-emoji/bare';
 import moment from "moment";
 import { Subscription } from "rxjs";
 import { delay, retryWhen, take } from "rxjs/operators";
@@ -41,6 +44,8 @@ import { httpBase } from "./env";
 import { i18n, languages } from "./i18next";
 import { DataType, IsoData } from "./interfaces";
 import { UserService, WebSocketService } from "./services";
+import Renderer from "markdown-it/lib/renderer";
+import Token from "markdown-it/lib/token";
 
 var Tribute: any;
 if (isBrowser()) {
@@ -74,6 +79,9 @@ export const markdownFieldCharacterLimit = 50000;
 
 export const relTags = "noopener nofollow";
 
+let customEmojis: EmojiMartCategory[] = [];
+export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<string, CustomEmojiView>();
+
 const DEFAULT_ALPHABET =
   "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
 
@@ -124,26 +132,9 @@ const spoilerConfig = {
   },
 };
 
-const markdownItConfig: MarkdownIt.Options = {
-  html: false,
-  linkify: true,
-  typographer: true,
-};
+export let md: MarkdownIt = new MarkdownIt();
 
-export const md = new MarkdownIt(markdownItConfig)
-  .use(markdown_it_sub)
-  .use(markdown_it_sup)
-  .use(markdown_it_footnote)
-  .use(markdown_it_html5_embed, html5EmbedConfig)
-  .use(markdown_it_container, "spoiler", spoilerConfig);
-
-export const mdNoImages = new MarkdownIt(markdownItConfig)
-  .use(markdown_it_sub)
-  .use(markdown_it_sup)
-  .use(markdown_it_footnote)
-  .use(markdown_it_html5_embed, html5EmbedConfig)
-  .use(markdown_it_container, "spoiler", spoilerConfig)
-  .disable("image");
+export let mdNoImages: MarkdownIt = new MarkdownIt();
 
 export function hotRankComment(comment_view: CommentView): number {
   return hotRank(comment_view.counts.score, comment_view.comment.published);
@@ -191,8 +182,8 @@ export function getUnixTime(text?: string): number | undefined {
 export function futureDaysToUnixTime(days?: number): number | undefined {
   return days
     ? Math.trunc(
-        new Date(Date.now() + 1000 * 60 * 60 * 24 * days).getTime() / 1000
-      )
+      new Date(Date.now() + 1000 * 60 * 60 * 24 * days).getTime() / 1000
+    )
     : undefined;
 }
 
@@ -630,11 +621,20 @@ export function setupTribute() {
           return `${item.original.val} ${shortName}`;
         },
         selectTemplate: (item: any) => {
-          return `${item.original.val}`;
+          let customEmoji = customEmojisLookup[item.original.key]?.custom_emoji;
+          if (customEmoji == undefined)
+            return `${item.original.val}`;
+          else
+            return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
         },
         values: Object.entries(emojiShortName).map(e => {
           return { key: e[1], val: e[0] };
-        }),
+        }).concat(
+          Object.entries(customEmojisLookup).map((k) => ({
+            key: k[0],
+            val: `<img class="icon icon-emoji" src="${k[1].custom_emoji.image_url}" title="${k[1].custom_emoji.shortcode}" alt="${k[1].custom_emoji.alt_text}" />`
+          }))
+        ),
         allowSpaces: false,
         autocompleteMode: true,
         // TODO
@@ -678,6 +678,118 @@ export function setupTribute() {
   });
 }
 
+
+
+function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
+  let groupedEmojis = groupBy(custom_emoji_views, x => x.custom_emoji.category);
+  for (const [category, emojis] of Object.entries(groupedEmojis)) {
+    customEmojis.push({
+      id: category,
+      name: category,
+      emojis: emojis.map(emoji =>
+      ({
+        id: emoji.custom_emoji.shortcode,
+        name: emoji.custom_emoji.shortcode,
+        keywords: emoji.keywords.map(x => x.keyword),
+        skins: [{ src: emoji.custom_emoji.image_url }]
+      }))
+    })
+  }
+  customEmojisLookup = new Map(custom_emoji_views.map(view => [view.custom_emoji.shortcode, view]));
+}
+
+export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
+  const emoji: EmojiMartCustomEmoji = {
+    id: custom_emoji_view.custom_emoji.shortcode,
+    name: custom_emoji_view.custom_emoji.shortcode,
+    keywords: custom_emoji_view.keywords.map(x => x.keyword),
+    skins: [{ src: custom_emoji_view.custom_emoji.image_url }]
+  };
+  let categoryIndex = customEmojis.findIndex(x => x.id == custom_emoji_view.custom_emoji.category);
+  if (categoryIndex == -1) {
+    customEmojis.push({
+      id: custom_emoji_view.custom_emoji.category,
+      name: custom_emoji_view.custom_emoji.category,
+      emojis: [emoji]
+    })
+  }
+  else {
+    let emojiIndex = customEmojis[categoryIndex].emojis.findIndex(x => x.id == custom_emoji_view.custom_emoji.shortcode);
+    if (emojiIndex == -1) {
+      customEmojis[categoryIndex].emojis.push(emoji)
+    }
+    else {
+      customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
+    }
+  }
+  customEmojisLookup[custom_emoji_view.custom_emoji.shortcode] = custom_emoji_view;
+}
+
+export function removeFromEmojiDataModel(id: number) {
+  let view: CustomEmojiView | undefined;
+  for (let item of customEmojisLookup.values()) {
+    if (item.custom_emoji.id === id) {
+      view = item;
+      break;
+    }
+  }
+  if (!view)
+    return;
+  const categoryIndex = customEmojis.findIndex(x => x.id == view?.custom_emoji.category);
+  const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(x => x.id == view?.custom_emoji.shortcode)
+  customEmojis[categoryIndex].emojis = customEmojis[categoryIndex].emojis.splice(emojiIndex, 1);
+
+  customEmojisLookup.delete(view?.custom_emoji.shortcode);
+}
+
+function setupMarkdown() {
+  const markdownItConfig: MarkdownIt.Options = {
+    html: false,
+    linkify: true,
+    typographer: true,
+  };
+
+  const emojiDefs = Object.fromEntries(Object.entries(customEmojisLookup).map((k) => [k[0], k[0]]));
+  md = new MarkdownIt(markdownItConfig)
+    .use(markdown_it_sub)
+    .use(markdown_it_sup)
+    .use(markdown_it_footnote)
+    .use(markdown_it_html5_embed, html5EmbedConfig)
+    .use(markdown_it_container, "spoiler", spoilerConfig)
+    .use(markdownitEmoji, {
+      defs: emojiDefs
+    });
+
+  mdNoImages = new MarkdownIt(markdownItConfig)
+    .use(markdown_it_sub)
+    .use(markdown_it_sup)
+    .use(markdown_it_footnote)
+    .use(markdown_it_html5_embed, html5EmbedConfig)
+    .use(markdown_it_container, "spoiler", spoilerConfig)
+    .use(markdownitEmoji, {
+      defs: emojiDefs
+    })
+    .disable("image");
+  var defaultRenderer = md.renderer.rules.image;
+  md.renderer.rules.image = function (tokens: Token[], idx: number, options: MarkdownIt.Options, env: any, self: Renderer) {
+    //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
+    const item = tokens[idx] as any;
+    const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
+    const src: string = item.attrs[0][1];
+    const isCustomEmoji = customEmojisLookup.get(title) != undefined;
+    if (!isCustomEmoji) {
+      return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
+    }
+    const alt_text = item.content;
+    return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
+  }
+}
+
+export function getEmojiMart(onEmojiSelect: (e: any) => void, customPickerOptions: any = {}) {
+  const pickerOptions = { ...customPickerOptions, onEmojiSelect: onEmojiSelect, custom: customEmojis }
+  return new Picker(pickerOptions);
+}
+
 var tippyInstance: any;
 if (isBrowser()) {
   tippyInstance = tippy("[data-tippy-content]");
@@ -737,8 +849,8 @@ export function getListingTypeFromProps(
   return props.match.params.listing_type
     ? routeListingTypeToEnum(props.match.params.listing_type)
     : myLt
-    ? Object.values(ListingType)[myLt]
-    : defaultListingType;
+      ? Object.values(ListingType)[myLt]
+      : defaultListingType;
 }
 
 export function getListingTypeFromPropsNoDefault(props: any): ListingType {
@@ -761,8 +873,8 @@ export function getSortTypeFromProps(
   return props.match.params.sort
     ? routeSortTypeToEnum(props.match.params.sort)
     : mySortType
-    ? Object.values(SortType)[mySortType]
-    : SortType.Active;
+      ? Object.values(SortType)[mySortType]
+      : SortType.Active;
 }
 
 export function getPageFromProps(props: any): number {
@@ -1300,6 +1412,8 @@ export function personSelectName(pvs: PersonViewSafe): string {
 export function initializeSite(site: GetSiteResponse) {
   UserService.Instance.myUserInfo = site.my_user;
   i18n.changeLanguage(getLanguages()[0]);
+  setupEmojiDataModel(site.custom_emojis);
+  setupMarkdown();
 }
 
 const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", {
@@ -1405,8 +1519,8 @@ export function nsfwCheck(
   return !nsfw || (nsfw && myShowNsfw);
 }
 
-export function getRandomFromList<T>(list?: T[]): T | undefined {
-  return list?.at(Math.floor(Math.random() * list.length));
+export function getRandomFromList<T>(list: T[]): T | undefined {
+  return list.length == 0 ? undefined : list.at(Math.floor(Math.random() * list.length));
 }
 
 /**
@@ -1440,3 +1554,25 @@ export function selectableLanguages(
     }
   }
 }
+interface EmojiMartCategory {
+  id: string,
+  name: string,
+  emojis: EmojiMartCustomEmoji[]
+}
+
+interface EmojiMartCustomEmoji {
+  id: string,
+  name: string,
+  keywords: string[],
+  skins: EmojiMartSkin[]
+}
+
+interface EmojiMartSkin {
+  src: string
+}
+
+const groupBy = <T>(array: T[], predicate: (value: T, index: number, array: T[]) => string) =>
+  array.reduce((acc, value, index, array) => {
+    (acc[predicate(value, index, array)] ||= []).push(value);
+    return acc;
+  }, {} as { [key: string]: T[] });
\ No newline at end of file
index b9a50db7027f9804b9cdc7faf93b299a19f06e88..8f977a54ff1f15a8b5d38708fe5fdd065828e586 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
   integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
 
+"@emoji-mart/data@^1.1.0":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.1.2.tgz#777c976f8f143df47cbb23a7077c9ca9fe5fc513"
+  integrity sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==
+
 "@eslint/eslintrc@^1.4.1":
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e"
   dependencies:
     "@types/node" "*"
 
+"@types/emoji-mart@^3.0.9":
+  version "3.0.9"
+  resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.9.tgz#2f7ef5d9ec194f28029c46c81a5fc1e5b0efa73c"
+  integrity sha512-qdBo/2Y8MXaJ/2spKjDZocuq79GpnOhkwMHnK2GnVFa8WYFgfA+ei6sil3aeWQPCreOKIx9ogPpR5+7MaOqYAA==
+  dependencies:
+    "@types/react" "*"
+
 "@types/eslint-scope@^3.7.3":
   version "3.7.4"
   resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.0.tgz#94c47b9217bbac49d4a67a967fdcdeed89ebb7d0"
   integrity sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A==
 
+"@types/prop-types@*":
+  version "15.7.5"
+  resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
+  integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
+
 "@types/qs@*":
   version "6.9.7"
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
+"@types/react@*":
+  version "18.0.28"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.28.tgz#accaeb8b86f4908057ad629a26635fe641480065"
+  integrity sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==
+  dependencies:
+    "@types/prop-types" "*"
+    "@types/scheduler" "*"
+    csstype "^3.0.2"
+
 "@types/retry@0.12.0":
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
   dependencies:
     htmlparser2 "^8.0.0"
 
+"@types/scheduler@*":
+  version "0.16.2"
+  resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
+  integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
+
 "@types/semver@^7.3.12":
   version "7.3.13"
   resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91"
@@ -2963,7 +2994,7 @@ cssesc@^3.0.0:
   resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
   integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
 
-csstype@^3.1.0, csstype@^3.1.1:
+csstype@^3.0.2, csstype@^3.1.0, csstype@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
   integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
@@ -3264,6 +3295,11 @@ electron-to-chromium@^1.4.251:
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.264.tgz#2f68a062c38b7a04bf57f3e6954b868672fbdcd3"
   integrity sha512-AZ6ZRkucHOQT8wke50MktxtmcWZr67kE17X/nAXFf62NIdMdgY6xfsaJD5Szoy84lnkuPWH+4tTNE3s2+bPCiw==
 
+emoji-mart@^5.4.0:
+  version "5.5.2"
+  resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.5.2.tgz#3ddbaf053139cf4aa217650078bc1c50ca8381af"
+  integrity sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==
+
 emoji-regex@^7.0.1:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
@@ -5736,6 +5772,11 @@ markdown-it-container@^3.0.0:
   resolved "https://registry.yarnpkg.com/markdown-it-container/-/markdown-it-container-3.0.0.tgz#1d19b06040a020f9a827577bb7dbf67aa5de9a5b"
   integrity sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==
 
+markdown-it-emoji@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-2.0.2.tgz#cd42421c2fda1537d9cc12b9923f5c8aeb9029c8"
+  integrity sha512-zLftSaNrKuYl0kR5zm4gxXjHaOI3FAOEaloKmRA5hijmJZvSjmxcokOLlzycb/HXlUFWzXqpIEoyEMCE4i9MvQ==
+
 markdown-it-footnote@^3.0.3:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/markdown-it-footnote/-/markdown-it-footnote-3.0.3.tgz#e0e4c0d67390a4c5f0c75f73be605c7c190ca4d8"