# Contributing
-See [here](https://join-lemmy.org/docs/en/contributors/01-overview.html) for contributing Instructions.
+See [here](https://join-lemmy.org/docs/contributors/01-overview.html) for contributing Instructions.
-Subproject commit 7fc71d0860bbe5c6d620ec27112350ffe5b9229c
+Subproject commit a241fe1255a6363c7ae1ec5a09520c066745e6ce
{
"name": "lemmy-ui",
- "version": "0.18.0-rc.3",
+ "version": "0.18.0-rc.4",
"description": "An isomorphic UI for lemmy",
"repository": "https://github.com/LemmyNet/lemmy-ui",
"license": "AGPL-3.0",
"translations:update": "git submodule update --remote --recursive"
},
"lint-staged": {
- "*.{ts,tsx,js}": [
- "prettier --write",
- "eslint --fix"
- ],
- "*.{css, scss}": [
- "prettier --write"
- ],
- "package.json": [
- "sortpack"
- ]
+ "*.{ts,tsx,js}": ["prettier --write", "eslint --fix"],
+ "*.{css, scss}": ["prettier --write"],
+ "package.json": ["sortpack"]
},
"dependencies": {
"@babel/plugin-proposal-decorators": "^7.21.0",
"inferno-server": "^8.1.1",
"isomorphic-cookie": "^1.2.4",
"jwt-decode": "^3.1.2",
- "lemmy-js-client": "0.18.0-rc.1",
+ "lemmy-js-client": "0.18.0-rc.2",
"lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
const getSiteForm: GetSite = { auth };
const headers = setForwardedHeaders(req.headers);
- const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers));
+ const client = wrapClient(
+ new LemmyHttp(getHttpBaseInternal(), { fetchFunction: fetch, headers })
+ );
const { path, url, query } = req;
--- /dev/null
+import type { Request, Response } from "express";
+import { LemmyHttp } from "lemmy-js-client";
+import { getHttpBaseInternal } from "../../shared/env";
+import { wrapClient } from "../../shared/services/HttpService";
+import generateManifestJson from "../utils/generate-manifest-json";
+import { setForwardedHeaders } from "../utils/set-forwarded-headers";
+
+let manifest: Awaited<ReturnType<typeof generateManifestJson>> | undefined =
+ undefined;
+
+export default async (req: Request, res: Response) => {
+ if (!manifest) {
+ const headers = setForwardedHeaders(req.headers);
+ const client = wrapClient(
+ new LemmyHttp(getHttpBaseInternal(), { fetchFunction: fetch, headers })
+ );
+ const site = await client.getSite({});
+
+ if (site.state === "success") {
+ manifest = await generateManifestJson(site.data);
+ } else {
+ res.sendStatus(500);
+ return;
+ }
+ }
+
+ res.setHeader("content-type", "application/manifest+json");
+
+ res.send(manifest);
+};
res.send("Theme must be a css file");
}
- const customTheme = path.resolve(`./${extraThemesFolder}/${theme}`);
+ const customTheme = path.resolve(extraThemesFolder, theme);
if (existsSync(customTheme)) {
res.sendFile(customTheme);
import path from "path";
import process from "process";
import CatchAllHandler from "./handlers/catch-all-handler";
+import ManifestHandler from "./handlers/manifest-handler";
import RobotsHandler from "./handlers/robots-handler";
import ServiceWorkerHandler from "./handlers/service-worker-handler";
import ThemeHandler from "./handlers/theme-handler";
server.get("/robots.txt", RobotsHandler);
server.get("/service-worker.js", ServiceWorkerHandler);
+server.get("/manifest", ManifestHandler);
server.get("/css/themes/:name", ThemeHandler);
server.get("/css/themelist", ThemesListHandler);
server.get("/*", CatchAllHandler);
import { ILemmyConfig, IsoDataOptionalSite } from "../../shared/interfaces";
import { favIconPngUrl, favIconUrl } from "../../shared/utils";
import { fetchIconPng } from "./fetch-icon-png";
-import { generateManifestBase64 } from "./generate-manifest-base64";
const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || "";
+let appleTouchIcon: string | undefined = undefined;
+
export async function createSsrHtml(
root: string,
isoData: IsoDataOptionalSite
) {
const site = isoData.site_res;
- const appleTouchIcon = site?.site_view.site.icon
- ? `data:image/png;base64,${sharp(
- await fetchIconPng(site.site_view.site.icon)
- )
- .resize(180, 180)
- .extend({
- bottom: 20,
- top: 20,
- left: 20,
- right: 20,
- background: "#222222",
- })
- .png()
- .toBuffer()
- .then(buf => buf.toString("base64"))}`
- : favIconPngUrl;
+ if (!appleTouchIcon) {
+ appleTouchIcon = site?.site_view.site.icon
+ ? `data:image/png;base64,${sharp(
+ await fetchIconPng(site.site_view.site.icon)
+ )
+ .resize(180, 180)
+ .extend({
+ bottom: 20,
+ top: 20,
+ left: 20,
+ right: 20,
+ background: "#222222",
+ })
+ .png()
+ .toBuffer()
+ .then(buf => buf.toString("base64"))}`
+ : favIconPngUrl;
+ }
const erudaStr =
process.env["LEMMY_UI_DEBUG"] === "true"
/>
<!-- Web app manifest -->
- ${
- site &&
- `<link
- rel="manifest"
- href=${`data:application/manifest+json;base64,${await generateManifestBase64(
- site
- )}`}
- />`
- }
+ <link rel="manifest" href="/manifest" />
<link rel="apple-touch-icon" href=${appleTouchIcon} />
<link rel="apple-touch-startup-image" href=${appleTouchIcon} />
import { getHttpBaseExternal } from "../../shared/env";
import { fetchIconPng } from "./fetch-icon-png";
-const iconSizes = [72, 96, 144, 192, 512];
+const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512];
const defaultLogoPathDirectory = path.join(
process.cwd(),
"icons"
);
-export async function generateManifestBase64({
+export default async function ({
my_user,
site_view: {
site,
const icon = site.icon ? await fetchIconPng(site.icon) : null;
- const manifest = {
+ return {
name: site.name,
description: site.description ?? "A link aggregator for the fediverse",
start_url: url,
short_name: "Communities",
description: "Browse communities",
},
- ]
- .concat(
- my_user
- ? [
- {
- name: "Create Post",
- url: "/create_post",
- short_name: "Create Post",
- description: "Create a post.",
- },
- ]
- : []
- )
- .concat(
- my_user?.local_user_view.person.admin || !community_creation_admin_only
- ? [
- {
- name: "Create Community",
- url: "/create_community",
- short_name: "Create Community",
- description: "Create a community",
- },
- ]
- : []
- ),
+ {
+ name: "Create Post",
+ url: "/create_post",
+ short_name: "Create Post",
+ description: "Create a post.",
+ },
+ ].concat(
+ my_user?.local_user_view.person.admin || !community_creation_admin_only
+ ? [
+ {
+ name: "Create Community",
+ url: "/create_community",
+ short_name: "Create Community",
+ description: "Create a community",
+ },
+ ]
+ : []
+ ),
related_applications: [
{
platform: "f-droid",
},
],
};
-
- return Buffer.from(JSON.stringify(manifest)).toString("base64");
}
}
function handleCollapseClick(i: Navbar) {
- if (i.collapseButtonRef.current?.ariaExpanded === "true") {
+ if (
+ i.collapseButtonRef.current?.attributes &&
+ i.collapseButtonRef.current?.attributes.getNamedItem("aria-expanded")
+ ?.value === "true"
+ ) {
i.collapseButtonRef.current?.click();
}
}
document.removeEventListener("mouseup", this.handleOutsideMenuClick);
}
+ // TODO class active corresponding to current pages
render() {
- return this.navbar();
- }
-
- // TODO class active corresponding to current page
- navbar() {
const siteView = this.props.siteRes?.site_view;
const person = UserService.Instance.myUserInfo?.local_user_view.person;
return (
{i18n.t(this.props.multiple ? "language_plural" : "language")}
</label>
<div
- className={classNames(
- "input-group",
- `col-sm-${this.props.multiple ? 9 : 10}`
- )}
+ className={classNames(`col-sm-${this.props.multiple ? 9 : 10}`, {
+ "input-group": this.props.multiple,
+ })}
>
{this.selectBtn}
{this.props.multiple && (
+import classNames from "classnames";
import { Component, InfernoNode, linkEvent } from "inferno";
interface TabItem {
key: string;
- getNode: () => InfernoNode;
+ getNode: (isSelected: boolean) => InfernoNode;
label: string;
}
render() {
return (
<div>
- <ul className="nav nav-tabs mb-2">
+ <ul className="nav nav-tabs mb-2" role="tablist">
{this.props.tabs.map(({ key, label }) => (
<li key={key} className="nav-item">
<button
type="button"
- className={`nav-link btn${
- this.state?.currentTab === key ? " active" : ""
- }`}
+ className={classNames("nav-link", {
+ active: this.state?.currentTab === key,
+ })}
onClick={linkEvent({ ctx: this, tab: key }, handleSwitchTab)}
+ aria-controls={`${key}-tab-pane`}
+ {...(this.state?.currentTab === key && {
+ ...{
+ "aria-current": "page",
+ "aria-selected": "true",
+ },
+ })}
>
{label}
</button>
</li>
))}
</ul>
- {this.props.tabs
- .find(tab => tab.key === this.state?.currentTab)
- ?.getNode()}
+ <div className="tab-content">
+ {this.props.tabs.map(({ key, getNode }) => {
+ return getNode(this.state?.currentTab === key);
+ })}
+ </div>
</div>
);
}
const { listingType, page } = this.getCommunitiesQueryParams();
return (
<div>
- <div className="row">
- <div className="col-md-6">
- <h4>{i18n.t("list_of_communities")}</h4>
- <span className="mb-2">
- <ListingTypeSelect
- type_={listingType}
- showLocal={showLocal(this.isoData)}
- showSubscribed
- onChange={this.handleListingTypeChange}
- />
- </span>
+ <h1 className="h4">{i18n.t("list_of_communities")}</h1>
+ <div className="row g-2 justify-content-between">
+ <div className="col-auto">
+ <ListingTypeSelect
+ type_={listingType}
+ showLocal={showLocal(this.isoData)}
+ showSubscribed
+ onChange={this.handleListingTypeChange}
+ />
</div>
- <div className="col-md-6">{this.searchForm()}</div>
+ <div className="col-auto">{this.searchForm()}</div>
</div>
<div className="table-responsive">
searchForm() {
return (
<form
- className="row justify-content-end"
+ className="row mb-2"
onSubmit={linkEvent(this, this.handleSearchSubmit)}
>
<div className="col-auto">
<input
type="text"
id="communities-search"
- className="form-control me-2 mb-2"
+ className="form-control"
value={this.state.searchText}
placeholder={`${i18n.t("search")}...`}
onInput={linkEvent(this, this.handleSearchChange)}
<label className="visually-hidden" htmlFor="communities-search">
{i18n.t("search")}
</label>
- <button type="submit" className="btn btn-secondary mb-2">
+ <button type="submit" className="btn btn-secondary">
<span>{i18n.t("search")}</span>
</button>
</div>
+import classNames from "classnames";
import { Component, linkEvent } from "inferno";
import {
BannedPersonsResponse,
{
key: "site",
label: i18n.t("site"),
- getNode: () => (
- <div className="row">
- <div className="col-12 col-md-6">
- <SiteForm
- showLocal={showLocal(this.isoData)}
- allowedInstances={federationData?.allowed}
- blockedInstances={federationData?.blocked}
- onSaveSite={this.handleEditSite}
- siteRes={this.state.siteRes}
- themeList={this.state.themeList}
- loading={this.state.loading}
- />
- </div>
- <div className="col-12 col-md-6">
- {this.admins()}
- {this.bannedUsers()}
+ getNode: isSelected => (
+ <div
+ className={classNames("tab-pane show", {
+ active: isSelected,
+ })}
+ role="tabpanel"
+ id="site-tab-pane"
+ >
+ <div className="row">
+ <div className="col-12 col-md-6">
+ <SiteForm
+ showLocal={showLocal(this.isoData)}
+ allowedInstances={federationData?.allowed}
+ blockedInstances={federationData?.blocked}
+ onSaveSite={this.handleEditSite}
+ siteRes={this.state.siteRes}
+ themeList={this.state.themeList}
+ loading={this.state.loading}
+ />
+ </div>
+ <div className="col-12 col-md-6">
+ {this.admins()}
+ {this.bannedUsers()}
+ </div>
</div>
</div>
),
{
key: "rate_limiting",
label: "Rate Limiting",
- getNode: () => (
- <RateLimitForm
- rateLimits={
- this.state.siteRes.site_view.local_site_rate_limit
- }
- onSaveSite={this.handleEditSite}
- loading={this.state.loading}
- />
+ getNode: isSelected => (
+ <div
+ className={classNames("tab-pane", {
+ active: isSelected,
+ })}
+ role="tabpanel"
+ id="rate_limiting-tab-pane"
+ >
+ <RateLimitForm
+ rateLimits={
+ this.state.siteRes.site_view.local_site_rate_limit
+ }
+ onSaveSite={this.handleEditSite}
+ loading={this.state.loading}
+ />
+ </div>
),
},
{
key: "taglines",
label: i18n.t("taglines"),
- getNode: () => (
- <div className="row">
- <TaglineForm
- taglines={this.state.siteRes.taglines}
- onSaveSite={this.handleEditSite}
- loading={this.state.loading}
- />
+ getNode: isSelected => (
+ <div
+ className={classNames("tab-pane", {
+ active: isSelected,
+ })}
+ role="tabpanel"
+ id="taglines-tab-pane"
+ >
+ <div className="row">
+ <TaglineForm
+ taglines={this.state.siteRes.taglines}
+ onSaveSite={this.handleEditSite}
+ loading={this.state.loading}
+ />
+ </div>
</div>
),
},
{
key: "emojis",
label: i18n.t("emojis"),
- getNode: () => (
- <div className="row">
- <EmojiForm
- onCreate={this.handleCreateEmoji}
- onDelete={this.handleDeleteEmoji}
- onEdit={this.handleEditEmoji}
- loading={this.state.emojiLoading}
- />
+ getNode: isSelected => (
+ <div
+ className={classNames("tab-pane", {
+ active: isSelected,
+ })}
+ role="tabpanel"
+ id="emojis-tab-pane"
+ >
+ <div className="row">
+ <EmojiForm
+ onCreate={this.handleCreateEmoji}
+ onDelete={this.handleDeleteEmoji}
+ onEdit={this.handleEditEmoji}
+ loading={this.state.emojiLoading}
+ />
+ </div>
</div>
),
},
data-tippy-content={
subscribedCollapsed ? i18n.t("expand") : i18n.t("collapse")
}
- data-bs-toggle="collapse"
- data-bs-target="#sidebarSubscribedBody"
aria-expanded="true"
aria-controls="sidebarSubscribedBody"
>
</button>
)}
</header>
- <div
- id="sidebarSubscribedBody"
- className="collapse show"
- aria-labelledby="sidebarSubscribedHeader"
- >
- <div className="card-body">
- <ul className="list-inline mb-0">
- {UserService.Instance.myUserInfo?.follows.map(cfv => (
- <li
- key={cfv.community.id}
- className="list-inline-item d-inline-block"
- >
- <CommunityLink community={cfv.community} />
- </li>
- ))}
- </ul>
+ {!subscribedCollapsed && (
+ <div
+ id="sidebarSubscribedBody"
+ aria-labelledby="sidebarSubscribedHeader"
+ >
+ <div className="card-body">
+ <ul className="list-inline mb-0">
+ {UserService.Instance.myUserInfo?.follows.map(cfv => (
+ <li
+ key={cfv.community.id}
+ className="list-inline-item d-inline-block"
+ >
+ <CommunityLink community={cfv.community} />
+ </li>
+ ))}
+ </ul>
+ </div>
</div>
- </div>
+ )}
</>
);
}
+import classNames from "classnames";
import { Component, FormEventHandler, linkEvent } from "inferno";
import { EditSite, LocalSiteRateLimit } from "lemmy-js-client";
import { i18n } from "../../i18next";
handleRateLimitPerSecond: FormEventHandler<HTMLInputElement>;
rateLimitValue?: number;
rateLimitPerSecondValue?: number;
+ className?: string;
}
interface RateLimitFormProps {
handleRateLimitPerSecond,
rateLimitPerSecondValue,
rateLimitValue,
+ className,
}: RateLimitsProps) {
return (
- <div className="mb-3 row">
+ <div role="tabpanel" className={classNames("mb-3 row", className)}>
<div className="col-md-6">
<label htmlFor="rate-limit">{i18n.t("rate_limit")}</label>
<input
tabs={rateLimitTypes.map(rateLimitType => ({
key: rateLimitType,
label: i18n.t(`rate_limit_${rateLimitType}`),
- getNode: () => (
+ getNode: isSelected => (
<RateLimits
+ className={classNames("tab-pane show", {
+ active: isSelected,
+ })}
handleRateLimit={linkEvent(
{ rateLimitType, ctx: this },
handleRateLimitChange
)}
</header>
- <div
- id="sidebarInfoBody"
- className="collapse show"
- aria-labelledby="sidebarInfoHeader"
- >
- <div className="card-body">{this.siteInfo()}</div>
- </div>
+ {!this.state.collapsed && (
+ <div id="sidebarInfoBody" aria-labelledby="sidebarInfoHeader">
+ <div className="card-body">{this.siteInfo()}</div>
+ </div>
+ )}
</section>
</div>
);
import { debounce } from "@utils/helpers";
+import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno";
import {
);
}
- userSettings() {
+ userSettings(isSelected) {
return (
- <div className="row">
- <div className="col-12 col-md-6">
- <div className="card border-secondary mb-3">
- <div className="card-body">{this.saveUserSettingsHtmlForm()}</div>
+ <div
+ className={classNames("tab-pane show", {
+ active: isSelected,
+ })}
+ role="tabpanel"
+ id="settings-tab-pane"
+ >
+ <div className="row">
+ <div className="col-12 col-md-6">
+ <div className="card border-secondary mb-3">
+ <div className="card-body">{this.saveUserSettingsHtmlForm()}</div>
+ </div>
</div>
- </div>
- <div className="col-12 col-md-6">
- <div className="card border-secondary mb-3">
- <div className="card-body">{this.changePasswordHtmlForm()}</div>
+ <div className="col-12 col-md-6">
+ <div className="card border-secondary mb-3">
+ <div className="card-body">{this.changePasswordHtmlForm()}</div>
+ </div>
</div>
</div>
</div>
);
}
- blockCards() {
+ blockCards(isSelected) {
return (
- <div className="row">
- <div className="col-12 col-md-6">
- <div className="card border-secondary mb-3">
- <div className="card-body">{this.blockUserCard()}</div>
+ <div
+ className={classNames("tab-pane", {
+ active: isSelected,
+ })}
+ role="tabpanel"
+ id="blocks-tab-pane"
+ >
+ <div className="row">
+ <div className="col-12 col-md-6">
+ <div className="card border-secondary mb-3">
+ <div className="card-body">{this.blockUserCard()}</div>
+ </div>
</div>
- </div>
- <div className="col-12 col-md-6">
- <div className="card border-secondary mb-3">
- <div className="card-body">{this.blockCommunityCard()}</div>
+ <div className="col-12 col-md-6">
+ <div className="card border-secondary mb-3">
+ <div className="card-body">{this.blockCommunityCard()}</div>
+ </div>
</div>
</div>
</div>
if (res.state === "success") {
this.setState({
selectedCommunityChoice: {
- label: res.data.community_view.community.name,
+ label: res.data.community_view.community.title,
value: res.data.community_view.community.id.toString(),
},
loading: false,
id="createPostForm"
className="col-12 col-lg-6 offset-lg-3 mb-4"
>
- <h5>{i18n.t("create_post")}</h5>
+ <h1 className="h4">{i18n.t("create_post")}</h1>
<PostForm
onCreate={this.handlePostCreate}
params={locationState}
submitted: boolean;
}
+function handlePostSubmit(i: PostForm, event: any) {
+ event.preventDefault();
+ // Coerce empty url string to undefined
+ if ((i.state.form.url ?? "") === "") {
+ i.setState(s => ((s.form.url = undefined), s));
+ }
+ i.setState({ loading: true, submitted: true });
+ const auth = myAuthRequired();
+
+ const pForm = i.state.form;
+ const pv = i.props.post_view;
+
+ if (pv) {
+ i.props.onEdit?.({
+ name: pForm.name,
+ url: pForm.url,
+ body: pForm.body,
+ nsfw: pForm.nsfw,
+ post_id: pv.post.id,
+ language_id: pForm.language_id,
+ auth,
+ });
+ } else if (pForm.name && pForm.community_id) {
+ i.props.onCreate?.({
+ name: pForm.name,
+ community_id: pForm.community_id,
+ url: pForm.url,
+ body: pForm.body,
+ nsfw: pForm.nsfw,
+ language_id: pForm.language_id,
+ honeypot: pForm.honeypot,
+ auth,
+ });
+ }
+}
+
+function copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
+ const sTitle = d.suggestedTitle;
+ if (sTitle) {
+ d.i.setState(
+ s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
+ );
+ d.i.setState({ suggestedPostsRes: { state: "empty" } });
+ setTimeout(() => {
+ const textarea: any = document.getElementById("post-title");
+ autosize.update(textarea);
+ }, 10);
+ }
+}
+
+function handlePostUrlChange(i: PostForm, event: any) {
+ const url = event.target.value;
+
+ i.setState(prev => ({
+ ...prev,
+ form: {
+ ...prev.form,
+ url,
+ },
+ imageDeleteUrl: "",
+ }));
+
+ i.fetchPageTitle();
+}
+
+function handlePostNsfwChange(i: PostForm, event: any) {
+ i.setState(s => ((s.form.nsfw = event.target.checked), s));
+}
+
+function handleHoneyPotChange(i: PostForm, event: any) {
+ i.setState(s => ((s.form.honeypot = event.target.value), s));
+}
+
+function handleCancel(i: PostForm) {
+ i.props.onCancel?.();
+}
+
+function handleImageUploadPaste(i: PostForm, event: any) {
+ const image = event.clipboardData.files[0];
+ if (image) {
+ handleImageUpload(i, image);
+ }
+}
+
+function handleImageUpload(i: PostForm, event: any) {
+ let file: any;
+ if (event.target) {
+ event.preventDefault();
+ file = event.target.files[0];
+ } else {
+ file = event;
+ }
+
+ i.setState({ imageLoading: true });
+
+ HttpService.client.uploadImage({ image: file }).then(res => {
+ console.log("pictrs upload:");
+ console.log(res);
+ if (res.state === "success") {
+ if (res.data.msg === "ok") {
+ i.state.form.url = res.data.url;
+ i.setState({
+ imageLoading: false,
+ imageDeleteUrl: res.data.delete_url as string,
+ });
+ } else {
+ toast(JSON.stringify(res), "danger");
+ }
+ } else if (res.state === "failed") {
+ console.error(res.msg);
+ toast(res.msg, "danger");
+ i.setState({ imageLoading: false });
+ }
+ });
+}
+
+function handlePostNameChange(i: PostForm, event: any) {
+ i.setState(s => ((s.form.name = event.target.value), s));
+ i.fetchSimilarPosts();
+}
+
+function handleImageDelete(i: PostForm) {
+ const { imageDeleteUrl } = i.state;
+
+ fetch(imageDeleteUrl);
+
+ i.setState(prev => ({
+ ...prev,
+ imageDeleteUrl: "",
+ imageLoading: false,
+ form: {
+ ...prev.form,
+ url: "",
+ },
+ }));
+}
+
export class PostForm extends Component<PostFormProps, PostFormState> {
state: PostFormState = {
suggestedPostsRes: { state: "empty" },
...this.state.form,
community_id: getIdFromString(selectedCommunityChoice.value),
},
- communitySearchOptions: [selectedCommunityChoice]
- .concat(
+ communitySearchOptions: [selectedCommunityChoice].concat(
+ (
this.props.initialCommunities?.map(
({ community: { id, title } }) => ({
label: title,
value: id.toString(),
})
) ?? []
- )
- .filter(option => option.value !== selectedCommunityChoice.value),
+ ).filter(option => option.value !== selectedCommunityChoice.value)
+ ),
};
} else {
this.state = {
const url = this.state.form.url;
- // TODO
- // const promptCheck =
- // !!this.state.form.name || !!this.state.form.url || !!this.state.form.body;
- // <Prompt when={promptCheck} message={i18n.t("block_leaving")} />
return (
- <form
- className="post-form"
- onSubmit={linkEvent(this, this.handlePostSubmit)}
- >
+ <form className="post-form" onSubmit={linkEvent(this, handlePostSubmit)}>
<NavigationPrompt
when={
!!(
type="url"
id="post-url"
className="form-control"
- value={this.state.form.url}
- onInput={linkEvent(this, this.handlePostUrlChange)}
- onPaste={linkEvent(this, this.handleImageUploadPaste)}
+ value={url}
+ onInput={linkEvent(this, handlePostUrlChange)}
+ onPaste={linkEvent(this, handleImageUploadPaste)}
/>
{this.renderSuggestedTitleCopy()}
<form>
name="file"
className="d-none"
disabled={!UserService.Instance.myUserInfo}
- onChange={linkEvent(this, this.handleImageUpload)}
+ onChange={linkEvent(this, handleImageUpload)}
/>
</form>
{url && validURL(url) && (
{this.state.imageDeleteUrl && (
<button
className="btn btn-danger btn-sm mt-2"
- onClick={linkEvent(this, this.handleImageDelete)}
+ onClick={linkEvent(this, handleImageDelete)}
aria-label={i18n.t("delete")}
data-tippy-content={i18n.t("delete")}
>
<textarea
value={this.state.form.name}
id="post-title"
- onInput={linkEvent(this, this.handlePostNameChange)}
+ onInput={linkEvent(this, handlePostNameChange)}
className={`form-control ${
!validTitle(this.state.form.name) && "is-invalid"
}`}
/>
</div>
</div>
+ <LanguageSelect
+ allLanguages={this.props.allLanguages}
+ siteLanguages={this.props.siteLanguages}
+ selectedLanguageIds={selectedLangs}
+ multiple={false}
+ onChange={this.handleLanguageChange}
+ />
{!this.props.post_view && (
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="post-community">
</div>
)}
{this.props.enableNsfw && (
- <div className="mb-3 row">
- <legend className="col-form-label col-sm-2 pt-0">
- {i18n.t("nsfw")}
- </legend>
- <div className="col-sm-10">
- <div className="form-check">
- <input
- className="form-check-input position-static"
- id="post-nsfw"
- type="checkbox"
- checked={this.state.form.nsfw}
- onChange={linkEvent(this, this.handlePostNsfwChange)}
- />
- </div>
- </div>
+ <div className="form-check mb-3">
+ <input
+ className="form-check-input"
+ id="post-nsfw"
+ type="checkbox"
+ checked={this.state.form.nsfw}
+ onChange={linkEvent(this, handlePostNsfwChange)}
+ />
+ <label className="form-check-label">{i18n.t("nsfw")}</label>
</div>
)}
- <LanguageSelect
- allLanguages={this.props.allLanguages}
- siteLanguages={this.props.siteLanguages}
- selectedLanguageIds={selectedLangs}
- multiple={false}
- onChange={this.handleLanguageChange}
- />
<input
tabIndex={-1}
autoComplete="false"
className="form-control honeypot"
id="register-honey"
value={this.state.form.honeypot}
- onInput={linkEvent(this, this.handleHoneyPotChange)}
+ onInput={linkEvent(this, handleHoneyPotChange)}
/>
<div className="mb-3 row">
<div className="col-sm-10">
<button
type="button"
className="btn btn-secondary"
- onClick={linkEvent(this, this.handleCancel)}
+ onClick={linkEvent(this, handleCancel)}
>
{i18n.t("cancel")}
</button>
role="button"
onClick={linkEvent(
{ i: this, suggestedTitle },
- this.copySuggestedTitle
+ copySuggestedTitle
)}
>
{i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
}
}
- handlePostSubmit(i: PostForm, event: any) {
- event.preventDefault();
- // Coerce empty url string to undefined
- if ((i.state.form.url ?? "") === "") {
- i.setState(s => ((s.form.url = undefined), s));
- }
- i.setState({ loading: true, submitted: true });
- const auth = myAuthRequired();
-
- const pForm = i.state.form;
- const pv = i.props.post_view;
-
- if (pv) {
- i.props.onEdit?.({
- name: pForm.name,
- url: pForm.url,
- body: pForm.body,
- nsfw: pForm.nsfw,
- post_id: pv.post.id,
- language_id: pForm.language_id,
- auth,
- });
- } else if (pForm.name && pForm.community_id) {
- i.props.onCreate?.({
- name: pForm.name,
- community_id: pForm.community_id,
- url: pForm.url,
- body: pForm.body,
- nsfw: pForm.nsfw,
- language_id: pForm.language_id,
- honeypot: pForm.honeypot,
- auth,
- });
- }
- }
-
- copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
- const sTitle = d.suggestedTitle;
- if (sTitle) {
- d.i.setState(
- s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
- );
- d.i.setState({ suggestedPostsRes: { state: "empty" } });
- setTimeout(() => {
- const textarea: any = document.getElementById("post-title");
- autosize.update(textarea);
- }, 10);
- }
- }
-
- handlePostUrlChange(i: PostForm, event: any) {
- const url = event.target.value;
-
- i.setState({
- form: {
- url,
- },
- imageDeleteUrl: "",
- });
-
- i.fetchPageTitle();
- }
-
async fetchPageTitle() {
const url = this.state.form.url;
if (url && validURL(url)) {
}
}
- handlePostNameChange(i: PostForm, event: any) {
- i.setState(s => ((s.form.name = event.target.value), s));
- i.fetchSimilarPosts();
- }
-
async fetchSimilarPosts() {
const q = this.state.form.name;
if (q && q !== "") {
this.setState(s => ((s.form.body = val), s));
}
- handlePostCommunityChange(i: PostForm, event: any) {
- i.setState(s => ((s.form.community_id = Number(event.target.value)), s));
- }
-
- handlePostNsfwChange(i: PostForm, event: any) {
- i.setState(s => ((s.form.nsfw = event.target.checked), s));
- }
-
handleLanguageChange(val: number[]) {
this.setState(s => ((s.form.language_id = val.at(0)), s));
}
- handleHoneyPotChange(i: PostForm, event: any) {
- i.setState(s => ((s.form.honeypot = event.target.value), s));
- }
-
- handleCancel(i: PostForm) {
- i.props.onCancel?.();
- }
-
- handlePreviewToggle(i: PostForm, event: any) {
- event.preventDefault();
- i.setState({ previewMode: !i.state.previewMode });
- }
-
- handleImageUploadPaste(i: PostForm, event: any) {
- const image = event.clipboardData.files[0];
- if (image) {
- i.handleImageUpload(i, image);
- }
- }
-
- handleImageUpload(i: PostForm, event: any) {
- let file: any;
- if (event.target) {
- event.preventDefault();
- file = event.target.files[0];
- } else {
- file = event;
- }
-
- i.setState({ imageLoading: true });
-
- HttpService.client.uploadImage({ image: file }).then(res => {
- console.log("pictrs upload:");
- console.log(res);
- if (res.state === "success") {
- if (res.data.msg === "ok") {
- i.state.form.url = res.data.url;
- i.setState({
- imageLoading: false,
- imageDeleteUrl: res.data.delete_url as string,
- });
- } else {
- toast(JSON.stringify(res), "danger");
- }
- } else if (res.state === "failed") {
- console.error(res.msg);
- toast(res.msg, "danger");
- i.setState({ imageLoading: false });
- }
- });
- }
-
- handleImageDelete(i: PostForm) {
- const { imageDeleteUrl } = i.state;
-
- fetch(imageDeleteUrl);
-
- i.setState({
- imageDeleteUrl: "",
- imageLoading: false,
- form: {
- url: "",
- },
- });
- }
-
handleCommunitySearch = debounce(async (text: string) => {
const { selectedCommunityChoice } = this.props;
this.setState({ communitySearchLoading: true });
get commentsButton() {
const post_view = this.postView;
+ const title = i18n.t("number_of_comments", {
+ count: Number(post_view.counts.comments),
+ formattedCount: Number(post_view.counts.comments),
+ });
+
return (
<Link
- className="btn btn-link text-muted ps-0 text-muted"
- title={i18n.t("number_of_comments", {
- count: Number(post_view.counts.comments),
- formattedCount: Number(post_view.counts.comments),
- })}
+ className="btn btn-link btn-sm text-muted ps-0"
+ title={title}
to={`/post/${post_view.post.id}?scrollToComments=true`}
+ data-tippy-content={title}
>
<Icon icon="message-square" classes="me-1" inline />
- <span className="me-2">
- {i18n.t("number_of_comments", {
- count: Number(post_view.counts.comments),
- formattedCount: numToSI(post_view.counts.comments),
- })}
- </span>
+ {post_view.counts.comments}
{this.unreadCount && (
- <span className="small text-warning">
+ <span className="badge text-bg-warning">
({this.unreadCount} {i18n.t("new")})
</span>
)}
export const repoUrl = "https://github.com/LemmyNet";
export const joinLemmyUrl = "https://join-lemmy.org";
export const donateLemmyUrl = `${joinLemmyUrl}/donate`;
-export const docsUrl = `${joinLemmyUrl}/docs/en/index.html`;
-export const helpGuideUrl = `${joinLemmyUrl}/docs/en/users/01-getting-started.html`; // TODO find a way to redirect to the non-en folder
-export const markdownHelpUrl = `${joinLemmyUrl}/docs/en/users/02-media.html`;
-export const sortingHelpUrl = `${joinLemmyUrl}/docs/en/users/03-votes-and-ranking.html`;
+export const docsUrl = `${joinLemmyUrl}/docs/index.html`;
+export const helpGuideUrl = `${joinLemmyUrl}/docs/users/01-getting-started.html`; // TODO find a way to redirect to the non-en folder
+export const markdownHelpUrl = `${joinLemmyUrl}/docs/users/02-media.html`;
+export const sortingHelpUrl = `${joinLemmyUrl}/docs/users/03-votes-and-ranking.html`;
export const archiveTodayUrl = "https://archive.today";
export const ghostArchiveUrl = "https://ghostarchive.org";
export const webArchiveUrl = "https://web.archive.org";
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["/*"],
- "@utils/*": ["shared/utils/*"],
+ "@utils/*": ["shared/utils/*"]
}
},
"include": [
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
-lemmy-js-client@0.18.0-rc.1:
- version "0.18.0-rc.1"
- resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.18.0-rc.1.tgz#fd0c88810572d90413696011ebaed19e3b8162d8"
- integrity sha512-lQe443Nr5UCSoY+IxmT7mBe0IRF6EAZ/4PJSRoPSL+U8A+egMMBPbuxnisHzLsC+eDOWRUIgOqZlwlaRnbmuig==
+lemmy-js-client@0.18.0-rc.2:
+ version "0.18.0-rc.2"
+ resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.18.0-rc.2.tgz#6cd8b4dc95de8f2a6f99de56819c141a394dca04"
+ integrity sha512-bnYs89MjlQHwVIr1YIoAvgFkCTWrXDjSgPbCJx8ijrxZXqOKW/KAgWEisfqyFpy3dYpA3/sxFjh7b4sdxM+8VA==
dependencies:
cross-fetch "^3.1.5"
form-data "^4.0.0"