Merge branch 'custom-emojis' of https://github.com/makotech222/lemmy-ui into makotech222-custom-emojis

This commit is contained in:
Dessalines 2023-03-27 12:49:46 -04:00
commit 4499579380
11 changed files with 1011 additions and 49 deletions

@ -1 +1 @@
Subproject commit 7379716231b9f7e67f710751c839398b7ab5d65e Subproject commit 819531ae64c6cba12cb406eb98333fd52988bf3e

View file

@ -32,6 +32,7 @@
"@babel/preset-env": "7.20.2", "@babel/preset-env": "7.20.2",
"@babel/preset-typescript": "^7.21.0", "@babel/preset-typescript": "^7.21.0",
"@babel/runtime": "^7.21.0", "@babel/runtime": "^7.21.0",
"@emoji-mart/data": "^1.1.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"babel-loader": "^9.1.2", "babel-loader": "^9.1.2",
"babel-plugin-inferno": "^6.6.0", "babel-plugin-inferno": "^6.6.0",
@ -41,6 +42,7 @@
"clean-webpack-plugin": "^4.0.0", "clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"emoji-mart": "^5.4.0",
"emoji-short-name": "^2.0.0", "emoji-short-name": "^2.0.0",
"express": "~4.18.2", "express": "~4.18.2",
"html-to-text": "^9.0.4", "html-to-text": "^9.0.4",
@ -54,9 +56,10 @@
"inferno-server": "^8.0.6", "inferno-server": "^8.0.6",
"isomorphic-cookie": "^1.2.4", "isomorphic-cookie": "^1.2.4",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lemmy-js-client": "0.17.2-rc.4", "lemmy-js-client": "0.17.2-rc.5",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0", "markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.2",
"markdown-it-footnote": "^3.0.3", "markdown-it-footnote": "^3.0.3",
"markdown-it-html5-embed": "^1.0.0", "markdown-it-html5-embed": "^1.0.0",
"markdown-it-sub": "^1.0.0", "markdown-it-sub": "^1.0.0",

View file

@ -62,15 +62,19 @@
.md-div h1 { .md-div h1 {
font-size: 2rem; font-size: 2rem;
} }
.md-div h2 { .md-div h2 {
font-size: 1.8rem; font-size: 1.8rem;
} }
.md-div h3 { .md-div h3 {
font-size: 1.6rem; font-size: 1.6rem;
} }
.md-div h4 { .md-div h4 {
font-size: 1.4rem; font-size: 1.4rem;
} }
.md-div h5 { .md-div h5 {
font-size: 1.2rem; font-size: 1.2rem;
} }
@ -95,7 +99,7 @@
border-bottom: 2px solid var(--dark); border-bottom: 2px solid var(--dark);
} }
.md-div table tbody + tbody { .md-div table tbody+tbody {
border-top: 2px solid var(--dark); border-top: 2px solid var(--dark);
} }
@ -129,10 +133,52 @@
user-select: none; 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 { .icon-inline {
margin-bottom: 2px; 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 { .spinner-large {
display: grid; display: grid;
display: block; display: block;
@ -149,6 +195,7 @@
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
} }
100% { 100% {
transform: rotate(359deg); transform: rotate(359deg);
} }
@ -340,19 +387,24 @@ br.big {
list-style: none; list-style: none;
background: var(--light); background: var(--light);
} }
.tribute-container li { .tribute-container li {
padding: 5px 5px; padding: 5px 5px;
cursor: pointer; cursor: pointer;
} }
.tribute-container li.highlight { .tribute-container li.highlight {
background: var(--primary); background: var(--primary);
} }
.tribute-container li span { .tribute-container li span {
font-weight: bold; font-weight: bold;
} }
.tribute-container li.no-match { .tribute-container li.no-match {
cursor: default; cursor: default;
} }
.tribute-container .menu-highlighted { .tribute-container .menu-highlighted {
font-weight: bold; font-weight: bold;
} }
@ -376,7 +428,6 @@ br.big {
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.lang-select-action { .lang-select-action {
width: 100px; width: 100px;
} }
@ -384,3 +435,6 @@ br.big {
.lang-select-action:focus { .lang-select-action:focus {
width: auto; width: auto;
} }
em-emoji-picker{
width:100%;
}

View file

@ -0,0 +1,30 @@
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);
}
}

View file

@ -0,0 +1,62 @@
import { Component, linkEvent } from "inferno";
import { i18n } from "../../i18next";
import { EmojiMart } from "./emoji-mart";
import { Icon } from "./icon";
interface EmojiPickerProps {
onEmojiClick?(val: any): any;
}
interface EmojiPickerState {
showPicker: boolean;
}
export class EmojiPicker extends Component<EmojiPickerProps, EmojiPickerState> {
private emptyState: EmojiPickerState = {
showPicker: false,
};
state: EmojiPickerState;
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleEmojiClick = this.handleEmojiClick.bind(this);
}
render() {
return (
<span>
<button
className="btn btn-sm text-muted"
data-tippy-content={i18n.t("emoji")}
aria-label={i18n.t("emoji")}
onClick={linkEvent(this, this.togglePicker)}
>
<Icon icon="smile" classes="icon-inline" />
</button>
{this.state.showPicker && (
<>
<div className="emoji-picker-container">
<EmojiMart
onEmojiClick={this.handleEmojiClick}
pickerOptions={{}}
></EmojiMart>
</div>
<div
onClick={linkEvent(this, this.togglePicker)}
className="click-away-container"
/>
</>
)}
</span>
);
}
togglePicker(i: EmojiPicker, e: any) {
e.preventDefault();
i.setState({ showPicker: !i.state.showPicker });
}
handleEmojiClick(e: any) {
this.props.onEmojiClick?.(e);
}
}

View file

@ -5,6 +5,7 @@ import { Language } from "lemmy-js-client";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
import { UserService } from "../../services"; import { UserService } from "../../services";
import { import {
customEmojisLookup,
isBrowser, isBrowser,
markdownFieldCharacterLimit, markdownFieldCharacterLimit,
markdownHelpUrl, markdownHelpUrl,
@ -17,6 +18,7 @@ import {
toast, toast,
uploadImage, uploadImage,
} from "../../utils"; } from "../../utils";
import { EmojiPicker } from "./emoji-picker";
import { Icon, Spinner } from "./icon"; import { Icon, Spinner } from "./icon";
import { LanguageSelect } from "./language-select"; import { LanguageSelect } from "./language-select";
@ -226,6 +228,9 @@ export class MarkdownTextArea extends Component<
> >
<Icon icon="link" classes="icon-inline" /> <Icon icon="link" classes="icon-inline" />
</button> </button>
<EmojiPicker
onEmojiClick={e => this.handleEmoji(this, e)}
></EmojiPicker>
<form className="btn btn-sm text-muted font-weight-bold"> <form className="btn btn-sm text-muted font-weight-bold">
<label <label
htmlFor={`file-upload-${this.id}`} htmlFor={`file-upload-${this.id}`}
@ -328,6 +333,22 @@ export class MarkdownTextArea extends Component<
); );
} }
handleEmoji(i: MarkdownTextArea, e: any) {
let value = e.native;
if (value == null) {
let emoji = customEmojisLookup.get(e.id)?.custom_emoji;
if (emoji) {
value = `![${emoji.alt_text}](${emoji.image_url} "${emoji.shortcode}")`;
}
}
i.setState({
content: `${i.state.content ?? ""} ${value} `,
});
i.contentChange();
let textarea: any = document.getElementById(i.id);
autosize.update(textarea);
}
handleImageUploadPaste(i: MarkdownTextArea, event: any) { handleImageUploadPaste(i: MarkdownTextArea, event: any) {
let image = event.clipboardData.files[0]; let image = event.clipboardData.files[0];
if (image) { if (image) {

View file

@ -28,6 +28,7 @@ import {
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing"; import { PersonListing } from "../person/person-listing";
import { EmojiForm } from "./emojis-form";
import { SiteForm } from "./site-form"; import { SiteForm } from "./site-form";
interface AdminSettingsState { interface AdminSettingsState {
@ -35,6 +36,7 @@ interface AdminSettingsState {
banned: PersonViewSafe[]; banned: PersonViewSafe[];
loading: boolean; loading: boolean;
leaveAdminTeamLoading: boolean; leaveAdminTeamLoading: boolean;
currentTab: string;
} }
export class AdminSettings extends Component<any, AdminSettingsState> { export class AdminSettings extends Component<any, AdminSettingsState> {
@ -46,6 +48,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
banned: [], banned: [],
loading: true, loading: true,
leaveAdminTeamLoading: false, leaveAdminTeamLoading: false,
currentTab: "site",
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
@ -112,21 +115,58 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
<Spinner large /> <Spinner large />
</h5> </h5>
) : ( ) : (
<div className="row"> <div>
<div className="col-12 col-md-6"> <HtmlTags
<HtmlTags title={this.documentTitle}
title={this.documentTitle} path={this.context.router.route.match.url}
path={this.context.router.route.match.url} />
/> <ul className="nav nav-tabs mb-2">
<SiteForm <li className="nav-item">
siteRes={this.state.siteRes} <button
showLocal={showLocal(this.isoData)} className={`nav-link btn ${
/> this.state.currentTab == "site" && "active"
</div> }`}
<div className="col-12 col-md-6"> onClick={linkEvent(
{this.admins()} { ctx: this, tab: "site" },
{this.bannedUsers()} this.handleSwitchTab
</div> )}
>
{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>
)} )}
</div> </div>
@ -179,6 +219,10 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
); );
} }
handleSwitchTab(i: { ctx: AdminSettings; tab: string }) {
i.ctx.setState({ currentTab: i.tab });
}
handleLeaveAdminTeam(i: AdminSettings) { handleLeaveAdminTeam(i: AdminSettings) {
let auth = myAuth(); let auth = myAuth();
if (auth) { if (auth) {

View file

@ -0,0 +1,560 @@
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 { Subscription } from "rxjs";
import { i18n } from "../../i18next";
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";
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 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`}
/> */}
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 });
}
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
);
}
})
.catch(error => {
console.error(error);
toast(error, "danger");
});
}
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 });
}
}
}

View file

@ -170,7 +170,7 @@ export class Home extends Component<any, HomeState> {
wsClient.communityJoin({ community_id: 0 }) wsClient.communityJoin({ community_id: 0 })
); );
} }
const taglines = this.state.siteRes.taglines; const taglines = this.state?.siteRes?.taglines ?? [];
this.state = { this.state = {
...this.state, ...this.state,
trendingCommunities: trendingRes?.communities ?? [], trendingCommunities: trendingRes?.communities ?? [],

View file

@ -1,3 +1,4 @@
import { Picker } from "emoji-mart";
import emojiShortName from "emoji-short-name"; import emojiShortName from "emoji-short-name";
import { import {
BlockCommunityResponse, BlockCommunityResponse,
@ -9,6 +10,7 @@ import {
CommentView, CommentView,
CommunityModeratorView, CommunityModeratorView,
CommunityView, CommunityView,
CustomEmojiView,
GetSiteMetadata, GetSiteMetadata,
GetSiteResponse, GetSiteResponse,
Language, Language,
@ -29,10 +31,13 @@ import {
} from "lemmy-js-client"; } from "lemmy-js-client";
import { default as MarkdownIt } from "markdown-it"; import { default as MarkdownIt } from "markdown-it";
import markdown_it_container from "markdown-it-container"; 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_footnote from "markdown-it-footnote";
import markdown_it_html5_embed from "markdown-it-html5-embed"; import markdown_it_html5_embed from "markdown-it-html5-embed";
import markdown_it_sub from "markdown-it-sub"; import markdown_it_sub from "markdown-it-sub";
import markdown_it_sup from "markdown-it-sup"; import markdown_it_sup from "markdown-it-sup";
import Renderer from "markdown-it/lib/renderer";
import Token from "markdown-it/lib/token";
import moment from "moment"; import moment from "moment";
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { delay, retryWhen, take } from "rxjs/operators"; import { delay, retryWhen, take } from "rxjs/operators";
@ -75,6 +80,12 @@ export const markdownFieldCharacterLimit = 50000;
export const relTags = "noopener nofollow"; export const relTags = "noopener nofollow";
let customEmojis: EmojiMartCategory[] = [];
export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
string,
CustomEmojiView
>();
const DEFAULT_ALPHABET = const DEFAULT_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@ -125,26 +136,9 @@ const spoilerConfig = {
}, },
}; };
const markdownItConfig: MarkdownIt.Options = { export let md: MarkdownIt = new MarkdownIt();
html: false,
linkify: true,
typographer: true,
};
export const md = new MarkdownIt(markdownItConfig) export let mdNoImages: MarkdownIt = new MarkdownIt();
.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 function hotRankComment(comment_view: CommentView): number { export function hotRankComment(comment_view: CommentView): number {
return hotRank(comment_view.counts.score, comment_view.comment.published); return hotRank(comment_view.counts.score, comment_view.comment.published);
@ -632,11 +626,23 @@ export function setupTribute() {
return `${item.original.val} ${shortName}`; return `${item.original.val} ${shortName}`;
}, },
selectTemplate: (item: any) => { selectTemplate: (item: any) => {
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 => { values: Object.entries(emojiShortName)
return { key: e[1], val: e[0] }; .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, allowSpaces: false,
autocompleteMode: true, autocompleteMode: true,
// TODO // TODO
@ -680,6 +686,143 @@ export function setupTribute() {
}); });
} }
function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
let groupedEmojis = groupBy(custom_emoji_views, x => x.custom_emoji.category);
for (const [category, emojis] of Object.entries(groupedEmojis)) {
customEmojis.push({
id: category,
name: category,
emojis: emojis.map(emoji => ({
id: emoji.custom_emoji.shortcode,
name: emoji.custom_emoji.shortcode,
keywords: emoji.keywords.map(x => x.keyword),
skins: [{ src: emoji.custom_emoji.image_url }],
})),
});
}
customEmojisLookup = new Map(
custom_emoji_views.map(view => [view.custom_emoji.shortcode, view])
);
}
export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
const emoji: EmojiMartCustomEmoji = {
id: custom_emoji_view.custom_emoji.shortcode,
name: custom_emoji_view.custom_emoji.shortcode,
keywords: custom_emoji_view.keywords.map(x => x.keyword),
skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
};
let categoryIndex = customEmojis.findIndex(
x => x.id == custom_emoji_view.custom_emoji.category
);
if (categoryIndex == -1) {
customEmojis.push({
id: custom_emoji_view.custom_emoji.category,
name: custom_emoji_view.custom_emoji.category,
emojis: [emoji],
});
} else {
let emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
x => x.id == custom_emoji_view.custom_emoji.shortcode
);
if (emojiIndex == -1) {
customEmojis[categoryIndex].emojis.push(emoji);
} else {
customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
}
}
customEmojisLookup.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);
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 }),
{}
);
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,
});
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,
})
.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; var tippyInstance: any;
if (isBrowser()) { if (isBrowser()) {
tippyInstance = tippy("[data-tippy-content]"); tippyInstance = tippy("[data-tippy-content]");
@ -1302,6 +1445,8 @@ export function personSelectName(pvs: PersonViewSafe): string {
export function initializeSite(site: GetSiteResponse) { export function initializeSite(site: GetSiteResponse) {
UserService.Instance.myUserInfo = site.my_user; UserService.Instance.myUserInfo = site.my_user;
i18n.changeLanguage(getLanguages()[0]); i18n.changeLanguage(getLanguages()[0]);
setupEmojiDataModel(site.custom_emojis);
setupMarkdown();
} }
const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", { const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", {
@ -1407,8 +1552,10 @@ export function nsfwCheck(
return !nsfw || (nsfw && myShowNsfw); return !nsfw || (nsfw && myShowNsfw);
} }
export function getRandomFromList<T>(list?: T[]): T | undefined { export function getRandomFromList<T>(list: T[]): T | undefined {
return list?.at(Math.floor(Math.random() * list.length)); return list.length == 0
? undefined
: list.at(Math.floor(Math.random() * list.length));
} }
/** /**
@ -1448,3 +1595,29 @@ export function uploadImage(image: File): Promise<UploadImageResponse> {
return client.uploadImage({ image }); return client.uploadImage({ image });
} }
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[] });

View file

@ -1220,6 +1220,11 @@
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== 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": "@eslint/eslintrc@^1.4.1":
version "1.4.1" version "1.4.1"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e"
@ -3271,6 +3276,11 @@ electron-to-chromium@^1.4.251:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.264.tgz#2f68a062c38b7a04bf57f3e6954b868672fbdcd3" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.264.tgz#2f68a062c38b7a04bf57f3e6954b868672fbdcd3"
integrity sha512-AZ6ZRkucHOQT8wke50MktxtmcWZr67kE17X/nAXFf62NIdMdgY6xfsaJD5Szoy84lnkuPWH+4tTNE3s2+bPCiw== 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: emoji-regex@^7.0.1:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
@ -5419,10 +5429,10 @@ leac@^0.6.0:
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
lemmy-js-client@0.17.2-rc.4: lemmy-js-client@0.17.2-rc.5:
version "0.17.2-rc.4" version "0.17.2-rc.5"
resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.4.tgz#b4a2d935e2a8d427c8e30ecaac77a46e02354363" resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.5.tgz#8dbfa01fc293d63d72d8294d5584d4e71c9c08be"
integrity sha512-pV9JALCUb7hvdP2my9ksWThuLciHWwg0MkUL5ClDfTl0ql5Xk+UY3FJ6NCpsOWErBjfLQvqoep/23W92ISh1+Q== integrity sha512-B2VibqJvevVDiYK7yfMPZrx0GdC4XgpN2bgouzMgXZsn+HENALIAm5K+sZhD40/NCd69MglWTlYtFYg9d4YxOA==
dependencies: dependencies:
cross-fetch "^3.1.5" cross-fetch "^3.1.5"
form-data "^4.0.0" form-data "^4.0.0"
@ -5753,6 +5763,11 @@ markdown-it-container@^3.0.0:
resolved "https://registry.yarnpkg.com/markdown-it-container/-/markdown-it-container-3.0.0.tgz#1d19b06040a020f9a827577bb7dbf67aa5de9a5b" resolved "https://registry.yarnpkg.com/markdown-it-container/-/markdown-it-container-3.0.0.tgz#1d19b06040a020f9a827577bb7dbf67aa5de9a5b"
integrity sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw== 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: markdown-it-footnote@^3.0.3:
version "3.0.3" version "3.0.3"
resolved "https://registry.yarnpkg.com/markdown-it-footnote/-/markdown-it-footnote-3.0.3.tgz#e0e4c0d67390a4c5f0c75f73be605c7c190ca4d8" resolved "https://registry.yarnpkg.com/markdown-it-footnote/-/markdown-it-footnote-3.0.3.tgz#e0e4c0d67390a4c5f0c75f73be605c7c190ca4d8"