]> Untitled Git - lemmy-ui.git/commitdiff
Merge branch 'custom-emojis' of https://github.com/makotech222/lemmy-ui into makotech...
authorDessalines <tyhou13@gmx.com>
Mon, 27 Mar 2023 16:49:46 +0000 (12:49 -0400)
committerDessalines <tyhou13@gmx.com>
Mon, 27 Mar 2023 16:49:46 +0000 (12:49 -0400)
1  2 
package.json
src/shared/components/common/emoji-mart.tsx
src/shared/components/common/emoji-picker.tsx
src/shared/components/common/markdown-textarea.tsx
src/shared/components/home/admin-settings.tsx
src/shared/components/home/emojis-form.tsx
src/shared/utils.ts
yarn.lock

diff --cc package.json
index d73f3d0c0c7d86c0ce26bf9a68a8dbd8366e87e1,fba9403573cced26f278381511509c0570221b14..0e0426a42956fed12fc43de024931849ac4f9c97
      "inferno-server": "^8.0.6",
      "isomorphic-cookie": "^1.2.4",
      "jwt-decode": "^3.1.2",
-     "lemmy-js-client": "0.17.2-rc.4",
 -    "lemmy-js-client": "0.17.2-rc.3",
++    "lemmy-js-client": "0.17.2-rc.5",
      "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 0000000000000000000000000000000000000000,39b09bc035f126ab8ca060c539ae77d1937ca845..6210366bcb8e4e5918cbaf8b3b61948c50360e86
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,30 @@@
 -
+ import { Component } from "inferno";
+ import { getEmojiMart } from "../../utils";
 -    onEmojiClick?(val: any): any;
 -    pickerOptions: any;
+ interface EmojiMartProps {
 -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));
 -        }
++  onEmojiClick?(val: any): any;
++  pickerOptions: any;
+ }
 -    render() {
 -        return (
 -            <div id="emoji-picker"></div>
 -        );
 -    }
++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)
++      );
+     }
++  }
 -    handleEmojiClick(e: any) {
 -        this.props.onEmojiClick?.(e);
 -    }
++  render() {
++    return <div id="emoji-picker"></div>;
++  }
++  handleEmojiClick(e: any) {
++    this.props.onEmojiClick?.(e);
++  }
+ }
index 0000000000000000000000000000000000000000,cb466515183cb822f4ab4d5798250953c1cd58ac..114958335aa955c2671cb20ba33d2298ec42291c
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,62 +1,62 @@@
 -    onEmojiClick?(val: any): any;
+ import { Component, linkEvent } from "inferno";
+ import { i18n } from "../../i18next";
+ import { EmojiMart } from "./emoji-mart";
+ import { Icon } from "./icon";
+ interface EmojiPickerProps {
 -    showPicker: boolean,
++  onEmojiClick?(val: any): any;
+ }
+ interface EmojiPickerState {
 -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>
++  showPicker: boolean;
+ }
 -                {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>
 -        );
 -    }
++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>
 -    togglePicker(i: EmojiPicker, e: any) {
 -        e.preventDefault();
 -        i.setState({ showPicker: !i.state.showPicker });
 -    }
++        {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>
++    );
++  }
 -    handleEmojiClick(e: any) {
 -        this.props.onEmojiClick?.(e);
 -    }
 -}
++  togglePicker(i: EmojiPicker, e: any) {
++    e.preventDefault();
++    i.setState({ showPicker: !i.state.showPicker });
++  }
++  handleEmojiClick(e: any) {
++    this.props.onEmojiClick?.(e);
++  }
++}
index 001779e8b6f47d516e717b6e8457083bf5ac93e3,07a966c11a280a12f86a14949f4308ca772b91d2..d7bb4c5223c378ff908ab8b37d48d7ce8c7a97d6
@@@ -15,8 -17,8 +16,9 @@@ import 
    setupTippy,
    setupTribute,
    toast,
 +  uploadImage,
  } from "../../utils";
+ import { EmojiPicker } from "./emoji-picker";
  import { Icon, Spinner } from "./icon";
  import { LanguageSelect } from "./language-select";
  
@@@ -226,6 -228,7 +228,9 @@@ export class MarkdownTextArea extends C
              >
                <Icon icon="link" classes="icon-inline" />
              </button>
 -            <EmojiPicker onEmojiClick={(e) => this.handleEmoji(this,e)}></EmojiPicker>
++            <EmojiPicker
++              onEmojiClick={e => this.handleEmoji(this, e)}
++            ></EmojiPicker>
              <form className="btn btn-sm text-muted font-weight-bold">
                <label
                  htmlFor={`file-upload-${this.id}`}
      );
    }
  
 -    if (value == null){
+   handleEmoji(i: MarkdownTextArea, e: any) {
+     let value = e.native;
 -      if (emoji){
++    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..5b16d2ecbac7bf8e024a42eb68293a30fd0f853b
@@@ -112,21 -114,47 +115,58 @@@ export class AdminSettings extends Comp
              <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}
 -                />
++              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) }>
++                  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) }>
++                  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>
++            {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" &&
++            )}
++            {this.state.currentTab == "emojis" && (
+               <div className="row">
+                 <EmojiForm></EmojiForm>
+               </div>
 -            }
++            )}
            </div>
          )}
        </div>
      );
    }
  
 -  handleSwitchTab(i: { ctx: AdminSettings; tab: string }, event: any) {
++  handleSwitchTab(i: { ctx: AdminSettings; tab: string }) {
+     i.ctx.setState({ currentTab: i.tab });
+   }
    handleLeaveAdminTeam(i: AdminSettings) {
      let auth = myAuth();
      if (auth) {
index 0000000000000000000000000000000000000000,35e1ba26405658caef56efa44f4383e6ccf5886b..21634e45cc944bb4465db7f7082589f5dfd680e4
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,445 +1,560 @@@
 -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 { Component, linkEvent } from "inferno";
 -import { customEmojisLookup, isBrowser, myAuth, removeFromEmojiDataModel, setIsoData, toast, updateEmojiDataModel, wsClient, wsSubscribe } from "../../utils";
++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 { Subscription } from "rxjs";
+ import { i18n } from "../../i18next";
 -import { Subscription } from "rxjs";
 -import { pictrsUri } from "../../env";
++import { WebSocketService } from "../../services";
++import {
++  customEmojisLookup,
++  isBrowser,
++  myAuth,
++  pictrsDeleteToast,
++  removeFromEmojiDataModel,
++  setIsoData,
++  toast,
++  updateEmojiDataModel,
++  uploadImage,
++  wsClient,
++  wsSubscribe,
++} from "../../utils";
+ import { EmojiMart } from "../common/emoji-mart";
+ import { HtmlTags } from "../common/html-tags";
+ import { Icon } from "../common/icon";
 -    siteRes: GetSiteResponse;
 -    customEmojis: CustomEmojiViewForm[];
 -    loading: boolean;
 -    page: number;
+ import { Paginator } from "../common/paginator";
+ interface EmojiFormState {
 -    id: number;
 -    category: string;
 -    shortcode: string;
 -    image_url: string;
 -    alt_text: string;
 -    keywords: string;
 -    changed: boolean;
 -    page: number;
++  siteRes: GetSiteResponse;
++  customEmojis: CustomEmojiViewForm[];
++  loading: boolean;
++  page: number;
+ }
+ interface CustomEmojiViewForm {
 -    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;
++  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> {
 -        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");
 -    }
++  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;
 -    componentWillUnmount() {
 -        if (isBrowser()) {
 -            this.subscription?.unsubscribe();
 -        }
++    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");
++  }
 -    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 d-lg-table-cell d-none">{i18n.t("column_imageurl")}</th>
 -                                <th className="text-right">{i18n.t("column_alttext")}</th>
 -                                <th className="text-right 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 d-lg-table-cell d-none">
 -                                            <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-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
++  componentWillUnmount() {
++    if (isBrowser()) {
++      this.subscription?.unsubscribe();
+     }
++  }
 -                                                        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>
++  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 d-lg-table-cell d-none">
++                  {i18n.t("column_imageurl")}
++                </th>
++                <th className="text-right">{i18n.t("column_alttext")}</th>
++                <th className="text-right 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 d-lg-table-cell d-none">
++                      <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-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`}
+                                                         /> */}
 -                    <Paginator
 -                        page={this.state.page}
 -                        onChange={this.handlePageChange}
 -                    />
 -                </div>
 -            </div>
 -        );
 -    }
++                            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>
 -    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;
 -    }
++          <Paginator page={this.state.page} onChange={this.handlePageChange} />
++        </div>
++      </div>
++    );
++  }
 -    getEditTooltip(cv: CustomEmojiViewForm) {
 -        if (this.canEdit(cv))
 -            return i18n.t("save");
 -        else
 -            return i18n.t("custom_emoji_save_validation");
 -    }
++  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;
++  }
 -    handlePageChange(page: number) {
++  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()
 -            }
 -        }
 -    }
++  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 });
 -    }
++  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 });
 -    }
++  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 });
 -    }
++  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 });
 -    }
++  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 });
++  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 });
 -        }
++  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));
 -        }
 -    }
++  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 });
++  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)
++  handleImageUpload(props: { form: EmojiForm; index: number }, event: any) {
++    let file: any;
++    if (event.target) {
++      event.preventDefault();
++      file = event.target.files[0];
++    } else {
++      file = event;
+     }
 -    }
++    uploadImage(file)
++      .then(res => {
++        console.log("pictrs upload:");
++        console.log(res);
++        if (res.msg === "ok") {
++          pictrsDeleteToast(
++            `${i18n.t("click_to_delete_picture")}: ${file.name}`,
++            `${i18n.t("picture_deleted")}: ${file.name}`,
++            `${i18n.t("failed_to_delete_picture")}: ${file.name}`,
++            res.delete_url as string
++          );
++        } else {
++          toast(JSON.stringify(res), "danger");
++          let hash = res.files?.at(0)?.file;
++          let url = `${res.url}/${hash}`;
++          props.form.handleEmojiImageUrlChange(
++            { form: props.form, index: props.index, overrideValue: url },
++            event
++          );
+         }
 -    configurePicker(): any {
 -        return {
 -            data: { categories: [], emojis: [], aliases: [] },
 -            maxFrequentRows: 0,
 -            dynamicWidth: true,
 -        };
 -    }
++      })
++      .catch(error => {
++        console.error(error);
++        toast(error, "danger");
++      });
++  }
 -    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 });
 -        }
++  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 79e2dd49062f08a2f1ebdecf7fe84d61675856e0,6e3856579e890cdc1a34bd9387d2db10c8d1a59f..3b73389d8334cbbf77c59ca8d9ca125df47e9a22
@@@ -1,4 -1,5 +1,5 @@@
++import { Picker } from "emoji-mart";
  import emojiShortName from "emoji-short-name";
 -import { Picker } from 'emoji-mart'
  import {
    BlockCommunityResponse,
    BlockPersonResponse,
  } from "lemmy-js-client";
  import { default as MarkdownIt } from "markdown-it";
  import markdown_it_container from "markdown-it-container";
++import markdown_it_emoji from "markdown-it-emoji/bare";
  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 markdown_it_emoji from 'markdown-it-emoji/bare';
++import Renderer from "markdown-it/lib/renderer";
++import Token from "markdown-it/lib/token";
  import moment from "moment";
  import { Subscription } from "rxjs";
  import { delay, retryWhen, take } from "rxjs/operators";
@@@ -75,6 -79,9 +80,12 @@@ export const markdownFieldCharacterLimi
  
  export const relTags = "noopener nofollow";
  
 -export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<string, CustomEmojiView>();
+ let customEmojis: EmojiMartCategory[] = [];
++export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
++  string,
++  CustomEmojiView
++>();
  const DEFAULT_ALPHABET =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  
@@@ -632,11 -622,20 +626,23 @@@ export function setupTribute() 
            return `${item.original.val} ${shortName}`;
          },
          selectTemplate: (item: any) => {
-           return `${item.original.val}`;
 -          let customEmoji = customEmojisLookup.get(item.original.key)?.custom_emoji;
 -          if (customEmoji == undefined)
 -            return `${item.original.val}`;
++          let customEmoji = customEmojisLookup.get(
++            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(
 -          Array.from(customEmojisLookup.entries()).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}" />`
 -          }))
 -        ),
++        values: Object.entries(emojiShortName)
++          .map(e => {
++            return { key: e[1], val: e[0] };
++          })
++          .concat(
++            Array.from(customEmojisLookup.entries()).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
    });
  }
  
 -
 -
+ 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 =>
 -      ({
++      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 }]
 -      }))
 -    })
++        skins: [{ src: emoji.custom_emoji.image_url }],
++      })),
++    });
+   }
 -  customEmojisLookup = new Map(custom_emoji_views.map(view => [view.custom_emoji.shortcode, view]));
++  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 }]
++    skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
+   };
 -  let categoryIndex = customEmojis.findIndex(x => x.id == custom_emoji_view.custom_emoji.category);
++  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);
++      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.push(emoji);
++    } else {
+       customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
+     }
+   }
 -  customEmojisLookup.set(custom_emoji_view.custom_emoji.shortcode,custom_emoji_view);
++  customEmojisLookup.set(
++    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);
++  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 = Array.from(customEmojisLookup.entries()).reduce((main, [key, value]) => ({...main, [key]: value}), {})
++  const emojiDefs = Array.from(customEmojisLookup.entries()).reduce(
++    (main, [key, value]) => ({ ...main, [key]: value }),
++    {}
++  );
+   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(markdown_it_emoji, {
 -      defs: emojiDefs
++      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(markdown_it_emoji, {
 -      defs: emojiDefs
++      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) {
++  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 }
++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]");
@@@ -1407,8 -1520,8 +1552,10 @@@ 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));
++  return list.length == 0
++    ? undefined
++    : list.at(Math.floor(Math.random() * list.length));
  }
  
  /**
@@@ -1442,9 -1555,25 +1589,35 @@@ export function selectableLanguages
      }
    }
  }
 -  id: string,
 -  name: string,
 -  emojis: EmojiMartCustomEmoji[]
 +
 +export function uploadImage(image: File): Promise<UploadImageResponse> {
 +  const client = new LemmyHttp(httpBase);
 +
 +  return client.uploadImage({ image });
 +}
++
+ interface EmojiMartCategory {
 -  id: string,
 -  name: string,
 -  keywords: string[],
 -  skins: EmojiMartSkin[]
++  id: string;
++  name: string;
++  emojis: EmojiMartCustomEmoji[];
+ }
+ interface EmojiMartCustomEmoji {
 -  src: string
++  id: string;
++  name: string;
++  keywords: string[];
++  skins: EmojiMartSkin[];
+ }
+ interface EmojiMartSkin {
 -const groupBy = <T>(array: T[], predicate: (value: T, index: number, array: T[]) => string) =>
++  src: string;
+ }
 -  }, {} as { [key: string]: T[] });
++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[] });
diff --cc yarn.lock
index 1502167f04054dbe22425b51a43a6b9374973aa0,34eba62dfbbc35610109b7ab88922a8ded8820b4..b32721db9d11a5de7d86448abf04c7c30e1f7e81
+++ b/yarn.lock
@@@ -5419,13 -5439,12 +5429,13 @@@ leac@^0.6.0
    resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
    integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
  
- lemmy-js-client@0.17.2-rc.4:
-   version "0.17.2-rc.4"
-   resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.4.tgz#b4a2d935e2a8d427c8e30ecaac77a46e02354363"
-   integrity sha512-pV9JALCUb7hvdP2my9ksWThuLciHWwg0MkUL5ClDfTl0ql5Xk+UY3FJ6NCpsOWErBjfLQvqoep/23W92ISh1+Q==
 -lemmy-js-client@0.17.2-rc.3:
 -  version "0.17.2-rc.3"
 -  resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.3.tgz#dc2a33e9228aef260b03a6e1f55698a2f975f979"
 -  integrity sha512-FlWEPMrW2Q/FbtihLOHq2YtcRuoX7700LweCnsm6R6dD6SzsnWy9nKJhn24fcjcR2o6tw0oZKgP0ccq9jPDgfQ==
++lemmy-js-client@0.17.2-rc.5:
++  version "0.17.2-rc.5"
++  resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.5.tgz#8dbfa01fc293d63d72d8294d5584d4e71c9c08be"
++  integrity sha512-B2VibqJvevVDiYK7yfMPZrx0GdC4XgpN2bgouzMgXZsn+HENALIAm5K+sZhD40/NCd69MglWTlYtFYg9d4YxOA==
    dependencies:
 -    node-fetch "2.6.6"
 +    cross-fetch "^3.1.5"
 +    form-data "^4.0.0"
  
  levn@^0.4.1:
    version "0.4.1"