diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75015d8f..e300add3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,3 @@ # 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. diff --git a/lemmy-translations b/lemmy-translations index 7fc71d08..a241fe12 160000 --- a/lemmy-translations +++ b/lemmy-translations @@ -1 +1 @@ -Subproject commit 7fc71d0860bbe5c6d620ec27112350ffe5b9229c +Subproject commit a241fe1255a6363c7ae1ec5a09520c066745e6ce diff --git a/package.json b/package.json index 7b4f4621..9a285463 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", @@ -22,16 +22,9 @@ "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", @@ -66,7 +59,7 @@ "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", diff --git a/src/server/handlers/catch-all-handler.tsx b/src/server/handlers/catch-all-handler.tsx index eb847dc7..025aaa68 100644 --- a/src/server/handlers/catch-all-handler.tsx +++ b/src/server/handlers/catch-all-handler.tsx @@ -28,7 +28,9 @@ export default async (req: Request, res: Response) => { 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; diff --git a/src/server/handlers/manifest-handler.ts b/src/server/handlers/manifest-handler.ts new file mode 100644 index 00000000..55c7b64f --- /dev/null +++ b/src/server/handlers/manifest-handler.ts @@ -0,0 +1,30 @@ +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> | 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); +}; diff --git a/src/server/index.tsx b/src/server/index.tsx index f109fc11..144b596e 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -2,6 +2,7 @@ import express from "express"; 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"; @@ -24,6 +25,7 @@ if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) { 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); diff --git a/src/server/utils/create-ssr-html.tsx b/src/server/utils/create-ssr-html.tsx index 5cc38d7d..2c35aa29 100644 --- a/src/server/utils/create-ssr-html.tsx +++ b/src/server/utils/create-ssr-html.tsx @@ -5,32 +5,35 @@ import sharp from "sharp"; 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" @@ -74,15 +77,7 @@ export async function createSsrHtml( /> - ${ - site && - `` - } + diff --git a/src/server/utils/generate-manifest-base64.ts b/src/server/utils/generate-manifest-json.ts similarity index 69% rename from src/server/utils/generate-manifest-base64.ts rename to src/server/utils/generate-manifest-json.ts index e89b1559..b03fd87b 100644 --- a/src/server/utils/generate-manifest-base64.ts +++ b/src/server/utils/generate-manifest-json.ts @@ -5,7 +5,7 @@ import sharp from "sharp"; 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(), @@ -14,7 +14,7 @@ const defaultLogoPathDirectory = path.join( "icons" ); -export async function generateManifestBase64({ +export default async function ({ my_user, site_view: { site, @@ -25,7 +25,7 @@ export async function generateManifestBase64({ 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, @@ -69,31 +69,24 @@ export async function generateManifestBase64({ 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", @@ -102,6 +95,4 @@ export async function generateManifestBase64({ }, ], }; - - return Buffer.from(JSON.stringify(manifest)).toString("base64"); } diff --git a/src/shared/components/common/emoji-picker.tsx b/src/shared/components/common/emoji-picker.tsx index a7fc7d4f..6c603754 100644 --- a/src/shared/components/common/emoji-picker.tsx +++ b/src/shared/components/common/emoji-picker.tsx @@ -38,16 +38,18 @@ export class EmojiPicker extends Component { {this.state.showPicker && ( <> -
- +
+
+ +
+
-
)} diff --git a/src/shared/components/common/language-select.tsx b/src/shared/components/common/language-select.tsx index 619e1a5a..02deb434 100644 --- a/src/shared/components/common/language-select.tsx +++ b/src/shared/components/common/language-select.tsx @@ -66,10 +66,9 @@ export class LanguageSelect extends Component { {i18n.t(this.props.multiple ? "language_plural" : "language")}
{this.selectBtn} {this.props.multiple && ( diff --git a/src/shared/components/common/tabs.tsx b/src/shared/components/common/tabs.tsx index 17980a47..3c2726a5 100644 --- a/src/shared/components/common/tabs.tsx +++ b/src/shared/components/common/tabs.tsx @@ -1,8 +1,9 @@ +import classNames from "classnames"; import { Component, InfernoNode, linkEvent } from "inferno"; interface TabItem { key: string; - getNode: () => InfernoNode; + getNode: (isSelected: boolean) => InfernoNode; label: string; } @@ -30,24 +31,33 @@ export default class Tabs extends Component { render() { return (
-
    +
      {this.props.tabs.map(({ key, label }) => (
    • ))}
    - {this.props.tabs - .find(tab => tab.key === this.state?.currentTab) - ?.getNode()} +
    + {this.props.tabs.map(({ key, getNode }) => { + return getNode(this.state?.currentTab === key); + })} +
); } diff --git a/src/shared/components/community/communities.tsx b/src/shared/components/community/communities.tsx index 7cf072ef..bf897823 100644 --- a/src/shared/components/community/communities.tsx +++ b/src/shared/components/community/communities.tsx @@ -100,19 +100,17 @@ export class Communities extends Component { const { listingType, page } = this.getCommunitiesQueryParams(); return (
-
-
-

{i18n.t("list_of_communities")}

- - - +

{i18n.t("list_of_communities")}

+
+
+
-
{this.searchForm()}
+
{this.searchForm()}
@@ -220,14 +218,14 @@ export class Communities extends Component { searchForm() { return (
{ -
diff --git a/src/shared/components/home/admin-settings.tsx b/src/shared/components/home/admin-settings.tsx index 1de9f875..23454ab9 100644 --- a/src/shared/components/home/admin-settings.tsx +++ b/src/shared/components/home/admin-settings.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import { Component, linkEvent } from "inferno"; import { BannedPersonsResponse, @@ -130,22 +131,30 @@ export class AdminSettings extends Component { { key: "site", label: i18n.t("site"), - getNode: () => ( -
-
- -
-
- {this.admins()} - {this.bannedUsers()} + getNode: isSelected => ( +
+
+
+ +
+
+ {this.admins()} + {this.bannedUsers()} +
), @@ -153,40 +162,64 @@ export class AdminSettings extends Component { { key: "rate_limiting", label: "Rate Limiting", - getNode: () => ( - - ), - }, - { - key: "taglines", - label: i18n.t("taglines"), - getNode: () => ( -
- ( +
+
), }, + { + key: "taglines", + label: i18n.t("taglines"), + getNode: isSelected => ( +
+
+ +
+
+ ), + }, { key: "emojis", label: i18n.t("emojis"), - getNode: () => ( -
- + getNode: isSelected => ( +
+
+ +
), }, diff --git a/src/shared/components/home/home.tsx b/src/shared/components/home/home.tsx index 765dbf03..4270bd0b 100644 --- a/src/shared/components/home/home.tsx +++ b/src/shared/components/home/home.tsx @@ -570,8 +570,6 @@ export class Home extends Component { data-tippy-content={ subscribedCollapsed ? i18n.t("expand") : i18n.t("collapse") } - data-bs-toggle="collapse" - data-bs-target="#sidebarSubscribedBody" aria-expanded="true" aria-controls="sidebarSubscribedBody" > @@ -582,24 +580,25 @@ export class Home extends Component { )} -
-
-
    - {UserService.Instance.myUserInfo?.follows.map(cfv => ( -
  • - -
  • - ))} -
+ {!subscribedCollapsed && ( +
+
+
    + {UserService.Instance.myUserInfo?.follows.map(cfv => ( +
  • + +
  • + ))} +
+
-
+ )} ); } diff --git a/src/shared/components/home/rate-limit-form.tsx b/src/shared/components/home/rate-limit-form.tsx index 1b2e281b..11c1a8e8 100644 --- a/src/shared/components/home/rate-limit-form.tsx +++ b/src/shared/components/home/rate-limit-form.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import { Component, FormEventHandler, linkEvent } from "inferno"; import { EditSite, LocalSiteRateLimit } from "lemmy-js-client"; import { i18n } from "../../i18next"; @@ -19,6 +20,7 @@ interface RateLimitsProps { handleRateLimitPerSecond: FormEventHandler; rateLimitValue?: number; rateLimitPerSecondValue?: number; + className?: string; } interface RateLimitFormProps { @@ -49,9 +51,10 @@ function RateLimits({ handleRateLimitPerSecond, rateLimitPerSecondValue, rateLimitValue, + className, }: RateLimitsProps) { return ( -
+
({ key: rateLimitType, label: i18n.t(`rate_limit_${rateLimitType}`), - getNode: () => ( + getNode: isSelected => ( { )} -
-
{this.siteInfo()}
-
+ {!this.state.collapsed && ( +
+
{this.siteInfo()}
+
+ )}
); diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx index 5c3fc345..9acba57a 100644 --- a/src/shared/components/person/settings.tsx +++ b/src/shared/components/person/settings.tsx @@ -1,4 +1,5 @@ import { debounce } from "@utils/helpers"; +import classNames from "classnames"; import { NoOptionI18nKeys } from "i18next"; import { Component, linkEvent } from "inferno"; import { @@ -265,34 +266,50 @@ export class Settings extends Component { ); } - userSettings() { + userSettings(isSelected) { return ( -
-
-
-
{this.saveUserSettingsHtmlForm()}
+
+
+
+
+
{this.saveUserSettingsHtmlForm()}
+
-
-
-
-
{this.changePasswordHtmlForm()}
+
+
+
{this.changePasswordHtmlForm()}
+
); } - blockCards() { + blockCards(isSelected) { return ( -
-
-
-
{this.blockUserCard()}
+
+
+
+
+
{this.blockUserCard()}
+
-
-
-
-
{this.blockCommunityCard()}
+
+
+
{this.blockCommunityCard()}
+
diff --git a/src/shared/components/post/create-post.tsx b/src/shared/components/post/create-post.tsx index ebdf9995..5a9a1673 100644 --- a/src/shared/components/post/create-post.tsx +++ b/src/shared/components/post/create-post.tsx @@ -114,7 +114,7 @@ export class CreatePost extends Component< 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, @@ -178,7 +178,7 @@ export class CreatePost extends Component< id="createPostForm" className="col-12 col-lg-6 offset-lg-3 mb-4" > -
{i18n.t("create_post")}
+

{i18n.t("create_post")}

((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 { state: PostFormState = { suggestedPostsRes: { state: "empty" }, @@ -123,16 +260,16 @@ export class PostForm extends Component { ...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 = { @@ -188,15 +325,8 @@ export class PostForm extends Component { const url = this.state.form.url; - // TODO - // const promptCheck = - // !!this.state.form.name || !!this.state.form.url || !!this.state.form.body; - // return ( - + { 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()} @@ -237,7 +367,7 @@ export class PostForm extends Component { name="file" className="d-none" disabled={!UserService.Instance.myUserInfo} - onChange={linkEvent(this, this.handleImageUpload)} + onChange={linkEvent(this, handleImageUpload)} /> {url && validURL(url) && ( @@ -276,7 +406,7 @@ export class PostForm extends Component { {this.state.imageDeleteUrl && (