-Subproject commit 7379716231b9f7e67f710751c839398b7ab5d65e
+Subproject commit 819531ae64c6cba12cb406eb98333fd52988bf3e
"@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",
"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",
"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",
.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;
}
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);
}
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;
}
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
-
.lang-select-action {
width: 100px;
}
.lang-select-action:focus {
width: auto;
}
+em-emoji-picker{
+ width:100%;
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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
import { i18n } from "../../i18next";
import { UserService } from "../../services";
import {
+ customEmojisLookup,
isBrowser,
markdownFieldCharacterLimit,
markdownHelpUrl,
setupTribute,
toast,
} from "../../utils";
+import { EmojiPicker } from "./emoji-picker";
import { Icon, Spinner } from "./icon";
import { LanguageSelect } from "./language-select";
>
<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}`}
);
}
+ 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) {
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 {
banned: PersonViewSafe[];
loading: boolean;
leaveAdminTeamLoading: boolean;
+ currentTab: string;
}
export class AdminSettings extends Component<any, AdminSettingsState> {
banned: [],
loading: true,
leaveAdminTeamLoading: false,
+ currentTab: "site",
};
constructor(props: any, context: any) {
}
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() {
<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>
);
}
+ handleSwitchTab(i: { ctx: AdminSettings; tab: string }, event: any) {
+ i.ctx.setState({ currentTab: i.tab });
+ }
+
handleLeaveAdminTeam(i: AdminSettings) {
let auth = myAuth();
if (auth) {
--- /dev/null
+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 });
+ }
+ }
+}
+
wsClient.communityJoin({ community_id: 0 })
);
}
- const taglines = this.state.siteRes.taglines;
+ const taglines = this.state?.siteRes?.taglines ?? [];
this.state = {
...this.state,
trendingCommunities: trendingRes?.communities ?? [],
import emojiShortName from "emoji-short-name";
+import { Picker } from 'emoji-mart'
import {
BlockCommunityResponse,
BlockPersonResponse,
CommentView,
CommunityModeratorView,
CommunityView,
+ CustomEmojiView,
GetSiteMetadata,
GetSiteResponse,
Language,
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";
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()) {
export const relTags = "noopener nofollow";
+let customEmojis: EmojiMartCategory[] = [];
+export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<string, CustomEmojiView>();
+
const DEFAULT_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
},
};
-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);
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;
}
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
});
}
+
+
+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]");
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 {
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 {
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", {
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));
}
/**
}
}
}
+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
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"
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==
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"
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"