├── .husky ├── .gitignore └── pre-commit ├── .dockerignore ├── src ├── shared │ ├── version.ts │ ├── components │ │ ├── payments │ │ │ └── index.ts │ │ ├── app │ │ │ ├── styles.scss │ │ │ ├── theme.tsx │ │ │ ├── error-page.tsx │ │ │ ├── footer.tsx │ │ │ └── app.tsx │ │ ├── common │ │ │ ├── auth-guard.tsx │ │ │ ├── error-guard.tsx │ │ │ ├── emoji-mart.tsx │ │ │ ├── banner-icon-header.tsx │ │ │ ├── paginator.tsx │ │ │ ├── progress-bar.tsx │ │ │ ├── moment-time.tsx │ │ │ ├── tabs.tsx │ │ │ ├── html-tags.tsx │ │ │ ├── comment-sort-select.tsx │ │ │ ├── data-type-select.tsx │ │ │ ├── pictrs-image.tsx │ │ │ ├── emoji-picker.tsx │ │ │ ├── image-upload-form.tsx │ │ │ ├── user-badges.tsx │ │ │ └── listing-type-select.tsx │ │ ├── person │ │ │ ├── cake-day.tsx │ │ │ ├── verify-email.tsx │ │ │ └── person-listing.tsx │ │ ├── home │ │ │ └── legal.tsx │ │ ├── post │ │ │ └── metadata-card.tsx │ │ ├── community │ │ │ ├── community-link.tsx │ │ │ └── create-community.tsx │ │ └── private_message │ │ │ └── private-message-report.tsx │ ├── utils │ │ ├── helpers │ │ │ ├── hsl.ts │ │ │ ├── sleep.ts │ │ │ ├── capitalize-first-letter.ts │ │ │ ├── get-unix-time.ts │ │ │ ├── get-page-from-string.ts │ │ │ ├── valid-url.ts │ │ │ ├── valid-instance-tld.ts │ │ │ ├── hostname.ts │ │ │ ├── get-random-char-from-alphabet.ts │ │ │ ├── get-id-from-string.ts │ │ │ ├── get-random-from-list.ts │ │ │ ├── remove-auth-param.ts │ │ │ ├── valid-email.ts │ │ │ ├── future-days-to-unix-time.ts │ │ │ ├── group-by.ts │ │ │ ├── valid-title.ts │ │ │ ├── num-to-si.ts │ │ │ ├── get-query-string.ts │ │ │ ├── format-past-date.ts │ │ │ ├── poll.ts │ │ │ ├── is-cake-day.ts │ │ │ ├── edit-list-immutable.ts │ │ │ ├── get-query-params.ts │ │ │ ├── debounce.ts │ │ │ ├── random-str.ts │ │ │ └── index.ts │ │ ├── browser │ │ │ ├── is-browser.ts │ │ │ ├── can-share.ts │ │ │ ├── share.ts │ │ │ ├── is-dark.ts │ │ │ ├── save-scroll-position.ts │ │ │ ├── restore-scroll-position.ts │ │ │ ├── clear-auth-cookie.ts │ │ │ ├── set-auth-cookie.ts │ │ │ ├── load-css.ts │ │ │ ├── data-bs-theme.ts │ │ │ └── index.ts │ │ ├── types │ │ │ ├── query-params.ts │ │ │ ├── choice.ts │ │ │ ├── error-page-data.ts │ │ │ ├── person-tribute.ts │ │ │ ├── community-tribute.ts │ │ │ ├── route-data-response.ts │ │ │ ├── with-comment.ts │ │ │ ├── theme-color.ts │ │ │ └── index.ts │ │ ├── media │ │ │ ├── index.ts │ │ │ ├── is-video.ts │ │ │ └── is-image.ts │ │ ├── env │ │ │ ├── is-https.ts │ │ │ ├── get-base-local.ts │ │ │ ├── get-http-base.ts │ │ │ ├── get-http-base-internal.ts │ │ │ ├── get-http-base-external.ts │ │ │ ├── get-static-dir.ts │ │ │ ├── get-host.ts │ │ │ ├── get-secure.ts │ │ │ ├── get-internal-host.ts │ │ │ ├── http-external-path.ts │ │ │ ├── get-external-host.ts │ │ │ └── index.ts │ │ ├── app │ │ │ ├── fetch-theme-list.ts │ │ │ ├── my-auth.ts │ │ │ ├── get-id-from-props.ts │ │ │ ├── my-auth-required.ts │ │ │ ├── get-comment-id-from-props.ts │ │ │ ├── get-data-type-string.ts │ │ │ ├── is-auth-path.ts │ │ │ ├── enable-nsfw.ts │ │ │ ├── show-local.ts │ │ │ ├── community-rss-url.ts │ │ │ ├── enable-downvotes.ts │ │ │ ├── color-list.ts │ │ │ ├── get-recipient-id-from-props.ts │ │ │ ├── fetch-users.ts │ │ │ ├── show-scores.ts │ │ │ ├── show-avatars.ts │ │ │ ├── get-depth-from-comment.ts │ │ │ ├── fetch-communities.ts │ │ │ ├── edit-post.ts │ │ │ ├── new-vote.ts │ │ │ ├── edit-comment.ts │ │ │ ├── get-updated-search-id.ts │ │ │ ├── edit-post-report.ts │ │ │ ├── edit-community.ts │ │ │ ├── community-select-name.ts │ │ │ ├── edit-mention.ts │ │ │ ├── edit-comment-reply.ts │ │ │ ├── person-to-choice.ts │ │ │ ├── edit-comment-report.ts │ │ │ ├── edit-private-message.ts │ │ │ ├── get-comment-parent-id.ts │ │ │ ├── community-to-choice.ts │ │ │ ├── person-select-name.ts │ │ │ ├── edit-with.ts │ │ │ ├── edit-private-message-report.ts │ │ │ ├── edit-registration-application.ts │ │ │ ├── set-iso-data.ts │ │ │ ├── comments-to-flat-nodes.ts │ │ │ ├── nsfw-check.ts │ │ │ ├── post-to-comment-sort-type.ts │ │ │ ├── site-banner-css.ts │ │ │ ├── person-search.ts │ │ │ ├── initialize-site.ts │ │ │ ├── community-search.ts │ │ │ ├── search-comment-tree.ts │ │ │ ├── fetch-search-results.ts │ │ │ ├── is-post-blocked.ts │ │ │ ├── convert-comment-sort-type.ts │ │ │ ├── insert-comment-into-tree.ts │ │ │ ├── update-person-block.ts │ │ │ ├── update-community-block.ts │ │ │ ├── set-theme.ts │ │ │ ├── selectable-languages.ts │ │ │ ├── setup-date-fns.ts │ │ │ └── build-comments-tree.ts │ │ └── roles │ │ │ ├── am-admin.ts │ │ │ ├── is-admin.ts │ │ │ ├── is-mod.ts │ │ │ ├── am-top-mod.ts │ │ │ ├── am-mod.ts │ │ │ ├── am-site-creator.ts │ │ │ ├── can-admin.ts │ │ │ ├── is-banned.ts │ │ │ ├── am-community-creator.ts │ │ │ ├── can-create-community.ts │ │ │ ├── index.ts │ │ │ └── can-mod.ts │ ├── services │ │ ├── index.ts │ │ ├── FirstLoadService.ts │ │ ├── HomeCacheService.ts │ │ ├── UserService.ts │ │ └── HttpService.ts │ ├── axios.ts │ ├── tippy.ts │ ├── toast.ts │ ├── config.ts │ └── interfaces.ts ├── assets │ ├── css │ │ └── themes │ │ │ ├── _variables.darkly-compact.scss │ │ │ ├── _variables.litely-compact.scss │ │ │ ├── _variables.scss │ │ │ ├── darkly.scss │ │ │ ├── litely.scss │ │ │ ├── darkly-red.scss │ │ │ ├── litely-red.scss │ │ │ ├── _variables.litely-red.scss │ │ │ ├── darkly-pureblack.scss │ │ │ ├── _variables.darkly-red.scss │ │ │ ├── vaporwave-light.scss │ │ │ ├── _variables.vaporwave-light.scss │ │ │ ├── i386.scss │ │ │ ├── vaporwave-dark.scss │ │ │ ├── _variables.vaporwave-dark.scss │ │ │ ├── _variables.vaporwave.scss │ │ │ ├── darkly-compact.scss │ │ │ ├── litely-compact.scss │ │ │ ├── _variables.litely.scss │ │ │ ├── _variables.i386.scss │ │ │ ├── _variables.darkly.scss │ │ │ └── _variables.darkly-pureblack.scss │ ├── icons │ │ ├── favicon.ico │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ ├── apple-touch-icon.png │ │ └── favicon.svg │ ├── manifest.webmanifest │ ├── privacy.txt │ └── tos.txt ├── favicon.ico ├── server │ ├── utils │ │ ├── fetch-icon-png.ts │ │ ├── has-jwt-cookie.ts │ │ ├── set-forwarded-headers.ts │ │ ├── get-error-page-data.ts │ │ ├── build-themes-list.ts │ │ ├── generate-manifest-json.ts │ │ └── create-ssr-html.tsx │ ├── handlers │ │ ├── themes-list-handler.ts │ │ ├── service-worker-handler.ts │ │ ├── security-handler.ts │ │ ├── robots-handler.ts │ │ ├── theme-handler.ts │ │ └── manifest-handler.ts │ ├── middleware.ts │ └── index.tsx ├── client │ └── index.tsx └── favicon.svg ├── .github ├── CODEOWNERS ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── config.yml │ ├── FEATURE_REQUEST.yml │ └── BUG_REPORT.yml ├── .prettierrc.json ├── .prettierignore ├── .eslintignore ├── CONTRIBUTING.md ├── test_deploy.sh ├── .gitmodules ├── .gitignore ├── deploy.sh ├── accessibility_tests.sh ├── .babelrc ├── update_translations.sh ├── tsconfig.json ├── dev.dockerfile ├── .woodpecker.yml ├── .eslintrc.json ├── Dockerfile ├── README.md └── generate_translations.js /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /src/shared/version.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = "unknown version"; 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dessalines @SleeplessOne1917 @alectrocute @jsit 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": true 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/components/payments/index.ts: -------------------------------------------------------------------------------- 1 | export { Payments } from "./Payments"; 2 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.darkly-compact.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly"; 2 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.litely-compact.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely"; 2 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /src/assets/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/favicon.ico -------------------------------------------------------------------------------- /src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/shared/translations 2 | lemmy-translations 3 | src/assets/css/themes/*.css 4 | stats.json 5 | dist 6 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.scss: -------------------------------------------------------------------------------- 1 | $link-decoration: none; 2 | $min-contrast-ratio: 3; 3 | $font-size-root: 93.75%; 4 | -------------------------------------------------------------------------------- /src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/shared/utils/helpers/hsl.ts: -------------------------------------------------------------------------------- 1 | export default function hsl(num: number) { 2 | return `hsla(${num}, 35%, 50%, 0.5)`; 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/css/themes/darkly.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /src/assets/css/themes/litely.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /src/assets/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piconnectdev/wepi-ui/HEAD/src/assets/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /src/shared/utils/browser/is-browser.ts: -------------------------------------------------------------------------------- 1 | export default function isBrowser() { 2 | return typeof window !== "undefined"; 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | generate_translations.js 2 | webpack.config.js 3 | src/api_tests 4 | **/*.png 5 | **/*.css 6 | **/*.scss 7 | **/*.svg 8 | -------------------------------------------------------------------------------- /src/assets/css/themes/darkly-red.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly-red"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /src/assets/css/themes/litely-red.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely-red"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /src/shared/utils/types/query-params.ts: -------------------------------------------------------------------------------- 1 | export type QueryParams> = { 2 | [key in keyof T]?: string; 3 | }; 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | See [here](https://join-lemmy.org/docs/contributors/01-overview.html) for contributing Instructions. 4 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.litely-red.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely"; 2 | 3 | $secondary: #c80000; 4 | $danger: darken($primary, 24%); 5 | -------------------------------------------------------------------------------- /src/shared/utils/media/index.ts: -------------------------------------------------------------------------------- 1 | import isImage from "./is-image"; 2 | import isVideo from "./is-video"; 3 | 4 | export { isImage, isVideo }; 5 | -------------------------------------------------------------------------------- /src/shared/utils/types/choice.ts: -------------------------------------------------------------------------------- 1 | export default interface Choice { 2 | value: string; 3 | label: string; 4 | disabled?: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/types/error-page-data.ts: -------------------------------------------------------------------------------- 1 | export default interface ErrorPageData { 2 | error?: string; 3 | adminMatrixIds?: string[]; 4 | } 5 | -------------------------------------------------------------------------------- /test_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | sudo docker build . --tag dessalines/lemmy-ui:dev 5 | sudo docker push dessalines/lemmy-ui:dev 6 | -------------------------------------------------------------------------------- /src/assets/css/themes/darkly-pureblack.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly-pureblack"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lemmy-translations"] 2 | path = lemmy-translations 3 | url = https://github.com/lemmynet/lemmy-translations 4 | branch = main 5 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.darkly-red.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly"; 2 | 3 | $primary: $blue; 4 | $light: $gray-800; 5 | 6 | $link-color: $red; 7 | -------------------------------------------------------------------------------- /src/shared/utils/env/is-https.ts: -------------------------------------------------------------------------------- 1 | import { getSecure } from "@utils/env"; 2 | 3 | export default function isHttps() { 4 | return getSecure() === "s"; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/sleep.ts: -------------------------------------------------------------------------------- 1 | export default function sleep(millis: number): Promise { 2 | return new Promise(resolve => setTimeout(resolve, millis)); 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/utils/app/fetch-theme-list.ts: -------------------------------------------------------------------------------- 1 | export default async function fetchThemeList(): Promise { 2 | return fetch("/css/themelist").then(res => res.json()); 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-base-local.ts: -------------------------------------------------------------------------------- 1 | import { getHost } from "@utils/env"; 2 | 3 | export default function getBaseLocal(s = "") { 4 | return `http${s}://${getHost()}`; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/browser/can-share.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | 3 | export default function canShare() { 4 | return isBrowser() && !!navigator.canShare; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/capitalize-first-letter.ts: -------------------------------------------------------------------------------- 1 | export default function capitalizeFirstLetter(str: string): string { 2 | return str.charAt(0).toUpperCase() + str.slice(1); 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/utils/app/my-auth.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from "../../services"; 2 | 3 | export default function myAuth(): string | undefined { 4 | return UserService.Instance.auth(); 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-http-base.ts: -------------------------------------------------------------------------------- 1 | import { getBaseLocal, getSecure } from "@utils/env"; 2 | 3 | export default function getHttpBase() { 4 | return getBaseLocal(getSecure()); 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/get-unix-time.ts: -------------------------------------------------------------------------------- 1 | export default function getUnixTime(text?: string): number | undefined { 2 | return text ? new Date(text).getTime() / 1000 : undefined; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/utils/types/person-tribute.ts: -------------------------------------------------------------------------------- 1 | import { PersonView } from "lemmy-js-client"; 2 | 3 | export default interface PersonTribute { 4 | key: string; 5 | view: PersonView; 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/get-page-from-string.ts: -------------------------------------------------------------------------------- 1 | export default function getPageFromString(page?: string): number { 2 | return page && !Number.isNaN(Number(page)) ? Number(page) : 1; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/valid-url.ts: -------------------------------------------------------------------------------- 1 | export default function validURL(str: string) { 2 | try { 3 | new URL(str); 4 | return true; 5 | } catch { 6 | return false; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/utils/media/is-video.ts: -------------------------------------------------------------------------------- 1 | const videoRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp4|webm))/; 2 | 3 | export default function isVideo(url: string) { 4 | return videoRegex.test(url); 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/app/get-id-from-props.ts: -------------------------------------------------------------------------------- 1 | export default function getIdFromProps(props: any): string | undefined { 2 | const id = props.match.params.post_id; 3 | return id ? id : undefined; 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/utils/types/community-tribute.ts: -------------------------------------------------------------------------------- 1 | import { CommunityView } from "lemmy-js-client"; 2 | 3 | export default interface CommunityTribute { 4 | key: string; 5 | view: CommunityView; 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/utils/app/my-auth-required.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from "../../services"; 2 | 3 | export default function myAuthRequired(): string { 4 | return UserService.Instance.auth(true) ?? ""; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/valid-instance-tld.ts: -------------------------------------------------------------------------------- 1 | const tldRegex = /([a-z0-9]+\.)*[a-z0-9]+\.[a-z]+/; 2 | 3 | export default function validInstanceTLD(str: string) { 4 | return tldRegex.test(str); 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-http-base-internal.ts: -------------------------------------------------------------------------------- 1 | import { getBaseLocal } from "@utils/env"; 2 | 3 | export default function getHttpBaseInternal() { 4 | return getBaseLocal(); // Don't use secure here 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/media/is-image.ts: -------------------------------------------------------------------------------- 1 | const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/; 2 | 3 | export default function isImage(url: string) { 4 | return imageRegex.test(url); 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/hostname.ts: -------------------------------------------------------------------------------- 1 | export default function hostname(url: string): string { 2 | const cUrl = new URL(url); 3 | return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`; 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/utils/app/get-comment-id-from-props.ts: -------------------------------------------------------------------------------- 1 | export default function getCommentIdFromProps(props: any): string | undefined { 2 | const id = props.match.params.comment_id; 3 | return id ? id : undefined; 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/utils/app/get-data-type-string.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from "../../interfaces"; 2 | 3 | export default function getDataTypeString(dt: DataType) { 4 | return dt === DataType.Post ? "Post" : "Comment"; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/app/is-auth-path.ts: -------------------------------------------------------------------------------- 1 | export default function isAuthPath(pathname: string) { 2 | return /^\/(create_.*?|inbox|settings|admin|reports|registration_applications)\b/g.test( 3 | pathname 4 | ); 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/browser/share.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | 3 | export default function share(shareData: ShareData) { 4 | if (isBrowser()) { 5 | navigator.share(shareData); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/get-random-char-from-alphabet.ts: -------------------------------------------------------------------------------- 1 | export default function getRandomCharFromAlphabet(alphabet: string): string { 2 | return alphabet.charAt(Math.floor(Math.random() * alphabet.length)); 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-http-base-external.ts: -------------------------------------------------------------------------------- 1 | import { getExternalHost, getSecure } from "@utils/env"; 2 | 3 | export default function getHttpBaseExternal() { 4 | return `http${getSecure()}://${getExternalHost()}`; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/app/enable-nsfw.ts: -------------------------------------------------------------------------------- 1 | import { GetSiteResponse } from "lemmy-js-client"; 2 | 3 | export default function enableNsfw(siteRes: GetSiteResponse): boolean { 4 | return siteRes.site_view.local_site.enable_nsfw; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/app/show-local.ts: -------------------------------------------------------------------------------- 1 | import { IsoData } from "../../interfaces"; 2 | 3 | export default function showLocal(isoData: IsoData): boolean { 4 | return isoData.site_res.site_view.local_site.federation_enabled; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/browser/is-dark.ts: -------------------------------------------------------------------------------- 1 | import isBrowser from "./is-browser"; 2 | 3 | export default function isDark() { 4 | return ( 5 | isBrowser() && window.matchMedia("(prefers-color-scheme: dark)").matches 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/utils/types/route-data-response.ts: -------------------------------------------------------------------------------- 1 | import { RequestState } from "../../services/HttpService"; 2 | 3 | export type RouteDataResponse> = { 4 | [K in keyof T]: RequestState; 5 | }; 6 | -------------------------------------------------------------------------------- /src/shared/utils/app/community-rss-url.ts: -------------------------------------------------------------------------------- 1 | export default function communityRSSUrl(actorId: string, sort: string): string { 2 | const url = new URL(actorId); 3 | return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`; 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/get-id-from-string.ts: -------------------------------------------------------------------------------- 1 | export default function getIdFromString(id?: string): string | undefined { 2 | return id; 3 | //return id && id !== "0" && !Number.isNaN(Number(id)) ? Number(id) : undefined; 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/get-random-from-list.ts: -------------------------------------------------------------------------------- 1 | export default function getRandomFromList(list: T[]): T | undefined { 2 | return list.length == 0 3 | ? undefined 4 | : list.at(Math.floor(Math.random() * list.length)); 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/app/enable-downvotes.ts: -------------------------------------------------------------------------------- 1 | import { GetSiteResponse } from "lemmy-js-client"; 2 | 3 | export default function enableDownvotes(siteRes: GetSiteResponse): boolean { 4 | return siteRes.site_view.local_site.enable_downvotes; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-static-dir.ts: -------------------------------------------------------------------------------- 1 | // Returns path to static directory, intended 2 | // for cache-busting based on latest commit hash. 3 | export default function getStaticDir() { 4 | return `/static/${process.env.COMMIT_HASH}`; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/remove-auth-param.ts: -------------------------------------------------------------------------------- 1 | export default function (err: any) { 2 | return err 3 | .toString() 4 | .replace(new RegExp("[?&]auth=[^&#]*(#.*)?$"), "$1") 5 | .replace(new RegExp("([?&])auth=[^&]*&"), "$1"); 6 | } 7 | -------------------------------------------------------------------------------- /src/server/utils/fetch-icon-png.ts: -------------------------------------------------------------------------------- 1 | import fetch from "cross-fetch"; 2 | 3 | export async function fetchIconPng(iconUrl: string) { 4 | return await fetch(iconUrl) 5 | .then(res => res.blob()) 6 | .then(blob => blob.arrayBuffer()); 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/utils/app/color-list.ts: -------------------------------------------------------------------------------- 1 | import { hsl } from "@utils/helpers"; 2 | 3 | export const colorList: string[] = [ 4 | hsl(0), 5 | hsl(50), 6 | hsl(100), 7 | hsl(150), 8 | hsl(200), 9 | hsl(250), 10 | hsl(300), 11 | ]; 12 | -------------------------------------------------------------------------------- /src/server/utils/has-jwt-cookie.ts: -------------------------------------------------------------------------------- 1 | import * as cookie from "cookie"; 2 | import type { Request } from "express"; 3 | 4 | export function hasJwtCookie(req: Request): boolean { 5 | return Boolean(cookie.parse(req.headers.cookie ?? "").jwt?.length); 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/utils/types/with-comment.ts: -------------------------------------------------------------------------------- 1 | import { Comment, CommentAggregates } from "lemmy-js-client"; 2 | 3 | export default interface WithComment { 4 | comment: Comment; 5 | counts: CommentAggregates; 6 | my_vote?: number; 7 | saved: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/utils/app/get-recipient-id-from-props.ts: -------------------------------------------------------------------------------- 1 | export default function getRecipientIdFromProps(props: any): string { 2 | return props.match.params.recipient_id 3 | ? props.match.params.recipient_id 4 | : "00000000-0000-0000-0000-000000000000"; 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-host.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | import { getExternalHost, getInternalHost } from "@utils/env"; 3 | 4 | export default function getHost() { 5 | return isBrowser() ? getExternalHost() : getInternalHost(); 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/utils/roles/am-admin.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from "../../services"; 2 | 3 | export default function amAdmin( 4 | myUserInfo = UserService.Instance.myUserInfo 5 | ): boolean { 6 | return myUserInfo?.local_user_view.person.admin ?? false; 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/utils/app/fetch-users.ts: -------------------------------------------------------------------------------- 1 | import { fetchSearchResults } from "@utils/app"; 2 | 3 | export default async function fetchUsers(q: string) { 4 | const res = await fetchSearchResults(q, "Users"); 5 | 6 | return res.state === "success" ? res.data.users : []; 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/utils/app/show-scores.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from "../../services"; 2 | 3 | export default function showScores( 4 | myUserInfo = UserService.Instance.myUserInfo 5 | ): boolean { 6 | return myUserInfo?.local_user_view.local_user.show_scores ?? true; 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/utils/roles/is-admin.ts: -------------------------------------------------------------------------------- 1 | import { PersonView } from "lemmy-js-client"; 2 | 3 | export default function isAdmin( 4 | creatorId: string, 5 | admins?: PersonView[] 6 | ): boolean { 7 | return admins?.map(a => a.person.id).includes(creatorId) ?? false; 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/utils/app/show-avatars.ts: -------------------------------------------------------------------------------- 1 | import { UserService } from "../../services"; 2 | 3 | export default function showAvatars( 4 | myUserInfo = UserService.Instance.myUserInfo 5 | ): boolean { 6 | return myUserInfo?.local_user_view.local_user.show_avatars ?? true; 7 | } 8 | -------------------------------------------------------------------------------- /src/server/handlers/themes-list-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | import { buildThemeList } from "../utils/build-themes-list"; 3 | 4 | export default async ({ res }: { res: Response }) => { 5 | res.type("json").send(JSON.stringify(await buildThemeList())); 6 | }; 7 | -------------------------------------------------------------------------------- /src/shared/utils/browser/save-scroll-position.ts: -------------------------------------------------------------------------------- 1 | export default function saveScrollPosition(context: any) { 2 | const path: string = context.router.route.location.pathname; 3 | const y = window.scrollY; 4 | 5 | sessionStorage.setItem(`scrollPosition_${path}`, y.toString()); 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/utils/app/get-depth-from-comment.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from "lemmy-js-client"; 2 | 3 | export default function getDepthFromComment( 4 | comment?: Comment 5 | ): number | undefined { 6 | const len = comment?.path.split(".").length; 7 | return len ? len - 2 : undefined; 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/utils/browser/restore-scroll-position.ts: -------------------------------------------------------------------------------- 1 | export default function restoreScrollPosition(context: any) { 2 | const path: string = context.router.route.location.pathname; 3 | const y = Number(sessionStorage.getItem(`scrollPosition_${path}`)); 4 | 5 | window.scrollTo(0, y); 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/utils/roles/is-mod.ts: -------------------------------------------------------------------------------- 1 | import { CommunityModeratorView } from "lemmy-js-client"; 2 | 3 | export default function isMod( 4 | creatorId: string, 5 | mods?: CommunityModeratorView[] 6 | ): boolean { 7 | return mods?.map(m => m.moderator.id).includes(creatorId) ?? false; 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/services/index.ts: -------------------------------------------------------------------------------- 1 | export { FirstLoadService } from "./FirstLoadService"; 2 | export { HomeCacheService } from "./HomeCacheService"; 3 | export { HttpService } from "./HttpService"; 4 | export { I18NextService } from "./I18NextService"; 5 | export { UserService } from "./UserService"; 6 | -------------------------------------------------------------------------------- /src/shared/utils/app/fetch-communities.ts: -------------------------------------------------------------------------------- 1 | import { fetchSearchResults } from "@utils/app"; 2 | 3 | export default async function fetchCommunities(q: string) { 4 | const res = await fetchSearchResults(q, "Communities"); 5 | 6 | return res.state === "success" ? res.data.communities : []; 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/valid-email.ts: -------------------------------------------------------------------------------- 1 | export default function validEmail(email: string) { 2 | const re = 3 | /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/; 4 | return re.test(String(email).toLowerCase()); 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/css/themes/vaporwave-light.scss: -------------------------------------------------------------------------------- 1 | @import "variables.vaporwave-light"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | 4 | .form-control::placeholder { 5 | color: $gray-500; 6 | } 7 | 8 | .dropdown-item:hover:not(.active) { 9 | background-color: $secondary; 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-post.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { PostView } from "lemmy-js-client"; 3 | 4 | export default function editPost( 5 | data: PostView, 6 | posts: PostView[] 7 | ): PostView[] { 8 | return editListImmutable("post", data, posts); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/future-days-to-unix-time.ts: -------------------------------------------------------------------------------- 1 | export default function futureDaysToUnixTime( 2 | days?: number 3 | ): number | undefined { 4 | return days 5 | ? Math.trunc( 6 | new Date(Date.now() + 1000 * 60 * 60 * 24 * days).getTime() / 1000 7 | ) 8 | : undefined; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/app/new-vote.ts: -------------------------------------------------------------------------------- 1 | import { VoteType } from "../../interfaces"; 2 | 3 | export default function newVote(voteType: VoteType, myVote?: number): number { 4 | if (voteType == VoteType.Upvote) { 5 | return myVote == 1 ? 0 : 1; 6 | } else { 7 | return myVote == -1 ? 0 : -1; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-secure.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | 3 | export default function getSecure() { 4 | return ( 5 | isBrowser() 6 | ? window.location.protocol.includes("https") 7 | : process.env.LEMMY_UI_HTTPS === "true" 8 | ) 9 | ? "s" 10 | : ""; 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-internal-host.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | import { testHost } from "../../config"; 3 | 4 | export default function getInternalHost() { 5 | return !isBrowser() 6 | ? process.env.LEMMY_UI_LEMMY_INTERNAL_HOST ?? testHost 7 | : testHost; // used for local dev 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/group-by.ts: -------------------------------------------------------------------------------- 1 | export const groupBy = ( 2 | array: T[], 3 | predicate: (value: T, index: number, array: T[]) => string 4 | ) => 5 | array.reduce((acc, value, index, array) => { 6 | (acc[predicate(value, index, array)] ||= []).push(value); 7 | return acc; 8 | }, {} as { [key: string]: T[] }); 9 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-comment.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { CommentView } from "lemmy-js-client"; 3 | 4 | export default function editComment( 5 | data: CommentView, 6 | comments: CommentView[] 7 | ): CommentView[] { 8 | return editListImmutable("comment", data, comments); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/app/get-updated-search-id.ts: -------------------------------------------------------------------------------- 1 | export default function getUpdatedSearchId( 2 | id?: string | null, 3 | urlId?: string | null 4 | ) { 5 | return id === null ? undefined : id; 6 | // return id === null 7 | // ? undefined 8 | // : ((id ?? urlId) === 0 ? undefined : id ?? urlId)?.toString(); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/browser/clear-auth-cookie.ts: -------------------------------------------------------------------------------- 1 | import * as cookie from "cookie"; 2 | import { authCookieName } from "../../config"; 3 | 4 | export default function clearAuthCookie() { 5 | document.cookie = cookie.serialize(authCookieName, "", { 6 | maxAge: -1, 7 | sameSite: true, 8 | path: "/", 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/valid-title.ts: -------------------------------------------------------------------------------- 1 | export default function validTitle(title?: string): boolean { 2 | // Initial title is null, minimum length is taken care of by textarea's minLength={3} 3 | if (!title || title.length < 3) return true; 4 | 5 | const regex = new RegExp(/.*\S.*/, "g"); 6 | 7 | return regex.test(title); 8 | } 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 5 | 6 | ## Screenshots 7 | 8 | 9 | 10 | ### Before 11 | 12 | ### After 13 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.vaporwave-light.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.vaporwave"; 2 | 3 | // Colors 4 | $gray-600: #6c757d; 5 | $gray-700: #495057; 6 | $gray-800: #343a40; 7 | $gray-900: #212529; 8 | 9 | $light: darken($gray-300, 1.5); 10 | 11 | $body-bg: $gray-100; 12 | $body-color: $gray-700; 13 | $text-muted: $gray-500; 14 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-post-report.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { PostReportView } from "lemmy-js-client"; 3 | 4 | export default function editPostReport( 5 | data: PostReportView, 6 | reports: PostReportView[] 7 | ) { 8 | return editListImmutable("post_report", data, reports); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/env/http-external-path.ts: -------------------------------------------------------------------------------- 1 | import { getExternalHost, getSecure } from "@utils/env"; 2 | 3 | // This is for html tags, don't include port 4 | export default function httpExternalPath(path: string) { 5 | return `http${getSecure()}://${getExternalHost().replace( 6 | /:\d+/g, 7 | "" 8 | )}${path}`; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/num-to-si.ts: -------------------------------------------------------------------------------- 1 | const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", { 2 | maximumSignificantDigits: 3, 3 | //@ts-ignore 4 | notation: "compact", 5 | compactDisplay: "short", 6 | }); 7 | 8 | export default function numToSI(value: number): string { 9 | return SHORTNUM_SI_FORMAT.format(value); 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-community.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { CommunityView } from "lemmy-js-client"; 3 | 4 | export default function editCommunity( 5 | data: CommunityView, 6 | communities: CommunityView[] 7 | ): CommunityView[] { 8 | return editListImmutable("community", data, communities); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/app/community-select-name.ts: -------------------------------------------------------------------------------- 1 | import { hostname } from "@utils/helpers"; 2 | import { CommunityView } from "lemmy-js-client"; 3 | 4 | export default function communitySelectName(cv: CommunityView): string { 5 | return cv.community.local 6 | ? cv.community.title 7 | : `${hostname(cv.community.actor_id)}/${cv.community.title}`; 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-mention.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { PersonMentionView } from "lemmy-js-client"; 3 | 4 | export default function editMention( 5 | data: PersonMentionView, 6 | comments: PersonMentionView[] 7 | ): PersonMentionView[] { 8 | return editListImmutable("person_mention", data, comments); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-comment-reply.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { CommentReplyView } from "lemmy-js-client"; 3 | 4 | export default function editCommentReply( 5 | data: CommentReplyView, 6 | replies: CommentReplyView[] 7 | ): CommentReplyView[] { 8 | return editListImmutable("comment_reply", data, replies); 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/css/themes/i386.scss: -------------------------------------------------------------------------------- 1 | @import "variables.i386"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | 4 | .btn-outline-secondary { 5 | color: $gray-500; 6 | } 7 | 8 | .dropdown-item.active, 9 | .dropdown-item:hover, 10 | option:disabled { 11 | color: $secondary; 12 | } 13 | 14 | .input-group-text { 15 | background: $gray-500; 16 | } 17 | -------------------------------------------------------------------------------- /src/shared/utils/app/person-to-choice.ts: -------------------------------------------------------------------------------- 1 | import { personSelectName } from "@utils/app"; 2 | import { Choice } from "@utils/types"; 3 | import { PersonView } from "lemmy-js-client"; 4 | 5 | export default function personToChoice(pvs: PersonView): Choice { 6 | return { 7 | value: pvs.person.id.toString(), 8 | label: personSelectName(pvs), 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question 4 | url: https://lemmy.ml/c/lemmy_support 5 | about: Please ask and answer general questions here. 6 | - name: Technical Discussion 7 | url: https://github.com/LemmyNet/lemmy-ui/discussions 8 | about: Please discuss technical topics with other contributors here. 9 | -------------------------------------------------------------------------------- /src/shared/components/app/styles.scss: -------------------------------------------------------------------------------- 1 | // Custom css 2 | @import "../../../../node_modules/tributejs/dist/tribute.css"; 3 | @import "../../../../node_modules/toastify-js/src/toastify.css"; 4 | @import "../../../../node_modules/tippy.js/dist/tippy.css"; 5 | @import "../../../../node_modules/bootstrap/dist/css/bootstrap-utilities.min.css"; 6 | @import "../../../assets/css/main.css"; 7 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-comment-report.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { CommentReportView } from "lemmy-js-client"; 3 | 4 | export default function editCommentReport( 5 | data: CommentReportView, 6 | reports: CommentReportView[] 7 | ): CommentReportView[] { 8 | return editListImmutable("comment_report", data, reports); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-private-message.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { PrivateMessageView } from "lemmy-js-client"; 3 | 4 | export default function editPrivateMessage( 5 | data: PrivateMessageView, 6 | messages: PrivateMessageView[] 7 | ): PrivateMessageView[] { 8 | return editListImmutable("private_message", data, messages); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/app/get-comment-parent-id.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from "lemmy-js-client"; 2 | 3 | export default function getCommentParentId( 4 | comment?: Comment 5 | ): string | undefined { 6 | const split = comment?.path.split("."); 7 | // remove the 0 8 | split?.shift(); 9 | 10 | return split && split.length > 1 ? split.at(split.length - 2) : undefined; 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/utils/roles/am-top-mod.ts: -------------------------------------------------------------------------------- 1 | import { CommunityModeratorView } from "lemmy-js-client"; 2 | import { UserService } from "../../services"; 3 | 4 | export default function amTopMod( 5 | mods: CommunityModeratorView[], 6 | myUserInfo = UserService.Instance.myUserInfo 7 | ): boolean { 8 | return mods.at(0)?.moderator.id == myUserInfo?.local_user_view.person.id; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/app/community-to-choice.ts: -------------------------------------------------------------------------------- 1 | import { communitySelectName } from "@utils/app"; 2 | import { Choice } from "@utils/types"; 3 | import { CommunityView } from "lemmy-js-client"; 4 | 5 | export default function communityToChoice(cv: CommunityView): Choice { 6 | return { 7 | value: cv.community.id.toString(), 8 | label: communitySelectName(cv), 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/utils/app/person-select-name.ts: -------------------------------------------------------------------------------- 1 | import { hostname } from "@utils/helpers"; 2 | import { PersonView } from "lemmy-js-client"; 3 | 4 | export default function personSelectName({ 5 | person: { display_name, name, local, actor_id }, 6 | }: PersonView): string { 7 | const pName = display_name ?? name; 8 | return local ? pName : `${hostname(actor_id)}/${pName}`; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/get-query-string.ts: -------------------------------------------------------------------------------- 1 | export default function getQueryString< 2 | T extends Record 3 | >(obj: T) { 4 | return Object.entries(obj) 5 | .filter(([, val]) => val !== undefined && val !== null) 6 | .reduce( 7 | (acc, [key, val], index) => `${acc}${index > 0 ? "&" : ""}${key}=${val}`, 8 | "?" 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/format-past-date.ts: -------------------------------------------------------------------------------- 1 | import formatDistanceStrict from "date-fns/formatDistanceStrict"; 2 | import parseISO from "date-fns/parseISO"; 3 | 4 | export default function (dateString?: string) { 5 | const parsed = parseISO((dateString ?? Date.now().toString()) + "Z"); 6 | return formatDistanceStrict(parsed, new Date(), { 7 | addSuffix: true, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/poll.ts: -------------------------------------------------------------------------------- 1 | import sleep from "./sleep"; 2 | 3 | /** 4 | * Polls / repeatedly runs a promise, every X milliseconds 5 | */ 6 | export default async function poll(promiseFn: any, millis: number) { 7 | if (window.document.visibilityState !== "hidden") { 8 | await promiseFn(); 9 | } 10 | await sleep(millis); 11 | return poll(promiseFn, millis); 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { httpBase } from "./env"; 3 | export const DEV_URL = `${httpBase}/api/v3`; 4 | //export const DEV_URL = `http://localhost:8536/api/v3` 5 | export const PROD_URL = `${httpBase}/api/v3`; 6 | const isDev = process.env.NODE_ENV === "development"; 7 | 8 | export default axios.create({ 9 | baseURL: isDev ? DEV_URL : PROD_URL, 10 | }); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .fusebox 3 | _site 4 | .alm 5 | .history 6 | .git 7 | build 8 | .build 9 | .idea 10 | .jshintrc 11 | .nyc_output 12 | .sass-cache 13 | .vscode 14 | coverage 15 | jsconfig.json 16 | Gemfile.lock 17 | node_modules 18 | .DS_Store 19 | *.map 20 | *.log 21 | *.swp 22 | *~ 23 | test/data/result.json 24 | 25 | package-lock.json 26 | *.orig 27 | 28 | src/shared/translations 29 | 30 | stats.json 31 | 32 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-with.ts: -------------------------------------------------------------------------------- 1 | import { WithComment } from "@utils/types"; 2 | 3 | export default function editWith( 4 | { comment, counts, saved, my_vote }: D, 5 | list: L[] 6 | ) { 7 | return [ 8 | ...list.map(c => 9 | c.comment.id === comment.id 10 | ? { ...c, comment, counts, saved, my_vote } 11 | : c 12 | ), 13 | ]; 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-private-message-report.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { PrivateMessageReportView } from "lemmy-js-client"; 3 | 4 | export default function editPrivateMessageReport( 5 | data: PrivateMessageReportView, 6 | reports: PrivateMessageReportView[] 7 | ): PrivateMessageReportView[] { 8 | return editListImmutable("private_message_report", data, reports); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/roles/am-mod.ts: -------------------------------------------------------------------------------- 1 | import { isMod } from "@utils/roles"; 2 | import { CommunityModeratorView } from "lemmy-js-client"; 3 | import { UserService } from "../../services"; 4 | 5 | export default function amMod( 6 | mods?: CommunityModeratorView[], 7 | myUserInfo = UserService.Instance.myUserInfo 8 | ): boolean { 9 | return myUserInfo ? isMod(myUserInfo.local_user_view.person.id, mods) : false; 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/utils/types/theme-color.ts: -------------------------------------------------------------------------------- 1 | export type ThemeColor = 2 | | "primary" 3 | | "secondary" 4 | | "light" 5 | | "dark" 6 | | "success" 7 | | "danger" 8 | | "warning" 9 | | "info" 10 | | "blue" 11 | | "indigo" 12 | | "purple" 13 | | "pink" 14 | | "red" 15 | | "orange" 16 | | "yellow" 17 | | "green" 18 | | "teal" 19 | | "cyan" 20 | | "white" 21 | | "gray" 22 | | "gray-dark"; 23 | -------------------------------------------------------------------------------- /src/shared/utils/browser/set-auth-cookie.ts: -------------------------------------------------------------------------------- 1 | import { isHttps } from "@utils/env"; 2 | import * as cookie from "cookie"; 3 | import { authCookieName } from "../../config"; 4 | 5 | export default function setAuthCookie(jwt: string) { 6 | document.cookie = cookie.serialize(authCookieName, jwt, { 7 | maxAge: 365 * 24 * 60 * 60 * 1000, 8 | secure: isHttps(), 9 | sameSite: true, 10 | path: "/", 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/components/common/auth-guard.tsx: -------------------------------------------------------------------------------- 1 | import { InfernoNode } from "inferno"; 2 | import { Redirect } from "inferno-router"; 3 | import { UserService } from "../../services"; 4 | 5 | function AuthGuard(props: { children?: InfernoNode }) { 6 | if (!UserService.Instance.myUserInfo) { 7 | return ; 8 | } else { 9 | return props.children; 10 | } 11 | } 12 | 13 | export default AuthGuard; 14 | -------------------------------------------------------------------------------- /src/shared/utils/app/edit-registration-application.ts: -------------------------------------------------------------------------------- 1 | import { editListImmutable } from "@utils/helpers"; 2 | import { RegistrationApplicationView } from "lemmy-js-client"; 3 | 4 | export default function editRegistrationApplication( 5 | data: RegistrationApplicationView, 6 | apps: RegistrationApplicationView[] 7 | ): RegistrationApplicationView[] { 8 | return editListImmutable("registration_application", data, apps); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/utils/app/set-iso-data.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | import { IsoData, RouteData } from "../../interfaces"; 3 | 4 | export default function setIsoData( 5 | context: any 6 | ): IsoData { 7 | // If its the browser, you need to deserialize the data from the window 8 | if (isBrowser()) { 9 | return window.isoData; 10 | } else return context.router.staticContext; 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/utils/browser/load-css.ts: -------------------------------------------------------------------------------- 1 | export default function loadCss(id: string, loc: string) { 2 | if (!document.getElementById(id)) { 3 | var head = document.getElementsByTagName("head")[0]; 4 | var link = document.createElement("link"); 5 | link.id = id; 6 | link.rel = "stylesheet"; 7 | link.type = "text/css"; 8 | link.href = loc; 9 | link.media = "all"; 10 | head.appendChild(link); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/utils/app/comments-to-flat-nodes.ts: -------------------------------------------------------------------------------- 1 | import { CommentView } from "lemmy-js-client"; 2 | import { CommentNodeI } from "../../interfaces"; 3 | 4 | export default function commentsToFlatNodes( 5 | comments: CommentView[] 6 | ): CommentNodeI[] { 7 | const nodes: CommentNodeI[] = []; 8 | for (const comment of comments) { 9 | nodes.push({ comment_view: comment, children: [], depth: 0 }); 10 | } 11 | return nodes; 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/utils/roles/am-site-creator.ts: -------------------------------------------------------------------------------- 1 | import { PersonView } from "lemmy-js-client"; 2 | import { UserService } from "../../services"; 3 | 4 | export default function amSiteCreator( 5 | creator_id: string, 6 | admins?: PersonView[], 7 | myUserInfo = UserService.Instance.myUserInfo 8 | ): boolean { 9 | const myId = myUserInfo?.local_user_view.person.id; 10 | return myId == admins?.at(0)?.person.id && myId != creator_id; 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/utils/roles/can-admin.ts: -------------------------------------------------------------------------------- 1 | import { canMod } from "@utils/roles"; 2 | import { PersonView } from "lemmy-js-client"; 3 | import { UserService } from "../../services"; 4 | 5 | export default function canAdmin( 6 | creatorId: string, 7 | admins?: PersonView[], 8 | myUserInfo = UserService.Instance.myUserInfo, 9 | onSelf = false 10 | ): boolean { 11 | return canMod(creatorId, undefined, admins, myUserInfo, onSelf); 12 | } 13 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | new_tag="$1" 5 | 6 | # Old deploy 7 | # sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64 --push 8 | # sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64 9 | # sudo docker push dessalines/lemmy-ui:$new_tag 10 | 11 | # Upgrade version 12 | yarn version --new-version $new_tag 13 | git push 14 | 15 | git tag $new_tag 16 | git push origin $new_tag 17 | -------------------------------------------------------------------------------- /accessibility_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | ignores="WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail" 5 | base_url="http://192.168.50.60:1234" 6 | 7 | test_urls=( 8 | $base_url 9 | $base_url/communities 10 | $base_url/login 11 | $base_url/search 12 | $base_url/c/announcements 13 | $base_url/u/dessalines 14 | $base_url/post/34286 15 | ) 16 | 17 | for i in "${test_urls[@]}"; do 18 | pa11y --ignore="$ignores" "$i" 19 | done 20 | -------------------------------------------------------------------------------- /src/shared/utils/app/nsfw-check.ts: -------------------------------------------------------------------------------- 1 | import { PostView } from "lemmy-js-client"; 2 | import { UserService } from "../../services"; 3 | 4 | export default function nsfwCheck( 5 | pv: PostView, 6 | myUserInfo = UserService.Instance.myUserInfo 7 | ): boolean { 8 | const nsfw = pv.post.nsfw || pv.community.nsfw; 9 | const myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false; 10 | return !nsfw || (nsfw && myShowNsfw); 11 | } 12 | -------------------------------------------------------------------------------- /src/server/handlers/service-worker-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | import path from "path"; 3 | 4 | export default async ({ res }: { res: Response }) => { 5 | res 6 | .setHeader("Content-Type", "application/javascript") 7 | .sendFile( 8 | path.resolve( 9 | `./dist/service-worker${ 10 | process.env.NODE_ENV === "development" ? "-development" : "" 11 | }.js` 12 | ) 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/shared/utils/app/post-to-comment-sort-type.ts: -------------------------------------------------------------------------------- 1 | import { CommentSortType, SortType } from "lemmy-js-client"; 2 | 3 | export default function postToCommentSortType(sort: SortType): CommentSortType { 4 | switch (sort) { 5 | case "Active": 6 | case "Hot": 7 | return "Hot"; 8 | case "New": 9 | case "NewComments": 10 | return "New"; 11 | case "Old": 12 | return "Old"; 13 | default: 14 | return "Top"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/shared/utils/app/site-banner-css.ts: -------------------------------------------------------------------------------- 1 | export default function siteBannerCss(banner: string): string { 2 | return ` \ 3 | background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \ 4 | background-attachment: fixed; \ 5 | background-position: top; \ 6 | background-repeat: no-repeat; \ 7 | background-size: 100% cover; \ 8 | 9 | width: 100%; \ 10 | max-height: 100vh; \ 11 | `; 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/utils/app/person-search.ts: -------------------------------------------------------------------------------- 1 | import { fetchUsers } from "@utils/app"; 2 | import { hostname } from "@utils/helpers"; 3 | import { PersonTribute } from "@utils/types"; 4 | 5 | export default async function personSearch( 6 | text: string 7 | ): Promise { 8 | const usersResponse = await fetchUsers(text); 9 | 10 | return usersResponse.map(pv => ({ 11 | key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`, 12 | view: pv, 13 | })); 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/is-cake-day.ts: -------------------------------------------------------------------------------- 1 | import getDayOfYear from "date-fns/getDayOfYear"; 2 | import getYear from "date-fns/getYear"; 3 | import parseISO from "date-fns/parseISO"; 4 | 5 | export default function isCakeDay(published: string): boolean { 6 | const createDate = parseISO(published); 7 | const currentDate = new Date(); 8 | 9 | return ( 10 | getDayOfYear(createDate) === getDayOfYear(currentDate) && 11 | getYear(createDate) !== getYear(currentDate) 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/utils/roles/is-banned.ts: -------------------------------------------------------------------------------- 1 | import { Person } from "lemmy-js-client"; 2 | 3 | export default function isBanned(ps: Person): boolean { 4 | const expires = ps.ban_expires; 5 | // Add Z to convert from UTC date 6 | // TODO this check probably isn't necessary anymore 7 | if (expires) { 8 | if (ps.banned && new Date(expires + "Z") > new Date()) { 9 | return true; 10 | } else { 11 | return false; 12 | } 13 | } else { 14 | return ps.banned; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/shared/utils/app/initialize-site.ts: -------------------------------------------------------------------------------- 1 | import { GetSiteResponse } from "lemmy-js-client"; 2 | import { setupEmojiDataModel, setupMarkdown } from "../../markdown"; 3 | import { I18NextService, UserService } from "../../services"; 4 | 5 | export default function initializeSite(site?: GetSiteResponse) { 6 | UserService.Instance.myUserInfo = site?.my_user; 7 | I18NextService.i18n.changeLanguage(); 8 | if (site) { 9 | setupEmojiDataModel(site.custom_emojis ?? []); 10 | } 11 | setupMarkdown(); 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/utils/roles/am-community-creator.ts: -------------------------------------------------------------------------------- 1 | import { CommunityModeratorView } from "lemmy-js-client"; 2 | import { UserService } from "../../services"; 3 | 4 | export default function amCommunityCreator( 5 | creator_id: string, 6 | mods?: CommunityModeratorView[], 7 | myUserInfo = UserService.Instance.myUserInfo 8 | ): boolean { 9 | const myId = myUserInfo?.local_user_view.person.id; 10 | // Don't allow mod actions on yourself 11 | return myId == mods?.at(0)?.moderator.id && myId != creator_id; 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/utils/app/community-search.ts: -------------------------------------------------------------------------------- 1 | import { fetchCommunities } from "@utils/app"; 2 | import { hostname } from "@utils/helpers"; 3 | import { CommunityTribute } from "@utils/types"; 4 | 5 | export default async function communitySearch( 6 | text: string 7 | ): Promise { 8 | const communitiesResponse = await fetchCommunities(text); 9 | 10 | return communitiesResponse.map(cv => ({ 11 | key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`, 12 | view: cv, 13 | })); 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/utils/env/get-external-host.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | import { testHost } from "../../config"; 3 | 4 | export default function getExternalHost() { 5 | return isBrowser() 6 | ? `${window.location.hostname}${ 7 | ["1234", "1235"].includes(window.location.port) 8 | ? ":8536" 9 | : window.location.port == "" 10 | ? "" 11 | : `:${window.location.port}` 12 | }` 13 | : process.env.LEMMY_UI_LEMMY_EXTERNAL_HOST || testHost; 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/tippy.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | import tippy from "tippy.js"; 3 | 4 | export let tippyInstance: any; 5 | 6 | if (isBrowser()) { 7 | tippyInstance = tippy("[data-tippy-content]"); 8 | } 9 | 10 | export function setupTippy() { 11 | if (isBrowser()) { 12 | tippyInstance.forEach((e: any) => e.destroy()); 13 | tippyInstance = tippy("[data-tippy-content]", { 14 | delay: [500, 0], 15 | // Display on "long press" 16 | touch: ["hold", 500], 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "compact": false, 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "loose": true, 8 | "targets": { 9 | "browsers": ["ie >= 11", "safari > 10"] 10 | } 11 | } 12 | ], 13 | ["@babel/typescript", { "isTSX": true, "allExtensions": true }] 14 | ], 15 | "plugins": [ 16 | "@babel/plugin-transform-runtime", 17 | ["babel-plugin-inferno", { "imports": true }], 18 | ["@babel/plugin-proposal-class-properties", { "loose": true }] 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/server/handlers/security-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | 3 | export default async ({ res }: { res: Response }) => { 4 | res.setHeader("content-type", "text/plain; charset=utf-8"); 5 | 6 | res.send( 7 | `Contact: mailto:security@lemmy.ml 8 | Contact: mailto:admin@` + 9 | process.env.LEMMY_UI_LEMMY_EXTERNAL_HOST + 10 | ` 11 | Contact: mailto:security@` + 12 | process.env.LEMMY_UI_LEMMY_EXTERNAL_HOST + 13 | ` 14 | Expires: 2024-01-01T04:59:00.000Z 15 | ` 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/shared/utils/roles/can-create-community.ts: -------------------------------------------------------------------------------- 1 | import { amAdmin } from "@utils/roles"; 2 | import { GetSiteResponse } from "lemmy-js-client"; 3 | import { UserService } from "../../services"; 4 | 5 | export default function canCreateCommunity( 6 | siteRes: GetSiteResponse, 7 | myUserInfo = UserService.Instance.myUserInfo 8 | ): boolean { 9 | const adminOnly = siteRes.site_view.local_site.community_creation_admin_only; 10 | // TODO: Make this check if user is logged on as well 11 | return !adminOnly || amAdmin(myUserInfo); 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/utils/app/search-comment-tree.ts: -------------------------------------------------------------------------------- 1 | import { CommentNodeI } from "../../interfaces"; 2 | 3 | export default function searchCommentTree( 4 | tree: CommentNodeI[], 5 | id: number 6 | ): CommentNodeI | undefined { 7 | for (const node of tree) { 8 | if (node.comment_view.comment.id === id) { 9 | return node; 10 | } 11 | 12 | for (const child of node.children) { 13 | const res = searchCommentTree([child], id); 14 | 15 | if (res) { 16 | return res; 17 | } 18 | } 19 | } 20 | return undefined; 21 | } 22 | -------------------------------------------------------------------------------- /update_translations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | pushd ../lemmy-translations 5 | git fetch weblate 6 | git merge weblate/main 7 | git push 8 | popd 9 | 10 | # look for unused translations 11 | for langfile in lemmy-translations/translations/*.json; do 12 | lang=$(basename $langfile .json) 13 | if ! grep -q "\"./translations/$lang\"" src/shared/i18next.ts; then 14 | echo "Unused language $lang" 15 | fi 16 | done 17 | 18 | git submodule update --remote 19 | git add lemmy-translations 20 | git commit -m"Updating translations." 21 | git push 22 | -------------------------------------------------------------------------------- /src/shared/utils/app/fetch-search-results.ts: -------------------------------------------------------------------------------- 1 | import { myAuth } from "@utils/app"; 2 | import { Search, SearchType } from "lemmy-js-client"; 3 | import { fetchLimit } from "../../config"; 4 | import { HttpService } from "../../services"; 5 | 6 | export default function fetchSearchResults(q: string, type_: SearchType) { 7 | const form: Search = { 8 | q, 9 | type_, 10 | sort: "TopAll", 11 | listing_type: "All", 12 | page: 1, 13 | limit: fetchLimit, 14 | auth: myAuth(), 15 | }; 16 | 17 | return HttpService.client.search(form); 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/utils/browser/data-bs-theme.ts: -------------------------------------------------------------------------------- 1 | import { MyUserInfo } from "lemmy-js-client"; 2 | import isDark from "./is-dark"; 3 | 4 | export default function dataBsTheme(user?: MyUserInfo) { 5 | return (isDark() && user?.local_user_view.local_user.theme === "browser") || 6 | (user && 7 | [ 8 | "darkly", 9 | "darkly-red", 10 | "darkly-pureblack", 11 | "darkly-compact", 12 | "i386", 13 | "vaporwave-dark", 14 | ].includes(user.local_user_view.local_user.theme)) 15 | ? "dark" 16 | : "light"; 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/utils/app/is-post-blocked.ts: -------------------------------------------------------------------------------- 1 | import { MyUserInfo, PostView } from "lemmy-js-client"; 2 | import { UserService } from "../../services"; 3 | 4 | export default function isPostBlocked( 5 | pv: PostView, 6 | myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo 7 | ): boolean { 8 | return ( 9 | (myUserInfo?.community_blocks 10 | .map(c => c.community.id) 11 | .includes(pv.community.id) || 12 | myUserInfo?.person_blocks 13 | .map(p => p.target.id) 14 | .includes(pv.creator.id)) ?? 15 | false 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/server/handlers/robots-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from "express"; 2 | 3 | export default async ({ res }: { res: Response }) => { 4 | res.setHeader("content-type", "text/plain; charset=utf-8"); 5 | 6 | res.send(`User-Agent: * 7 | Disallow: /login 8 | Disallow: /login_reset 9 | Disallow: /settings 10 | Disallow: /create_community 11 | Disallow: /create_post 12 | Disallow: /create_private_message 13 | Disallow: /inbox 14 | Disallow: /setup 15 | Disallow: /admin 16 | Disallow: /password_change 17 | Disallow: /search/ 18 | Disallow: /modlog 19 | `); 20 | }; 21 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/edit-list-immutable.ts: -------------------------------------------------------------------------------- 1 | type ImmutableListKey = 2 | | "comment" 3 | | "comment_reply" 4 | | "person_mention" 5 | | "community" 6 | | "private_message" 7 | | "post" 8 | | "post_report" 9 | | "comment_report" 10 | | "private_message_report" 11 | | "registration_application"; 12 | 13 | export default function editListImmutable< 14 | T extends { [key in F]: { id: number } }, 15 | F extends ImmutableListKey 16 | >(fieldName: F, data: T, list: T[]): T[] { 17 | return [ 18 | ...list.map(c => (c[fieldName].id === data[fieldName].id ? data : c)), 19 | ]; 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/get-query-params.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | 3 | export default function getQueryParams< 4 | T extends Record 5 | >(processors: { 6 | [K in keyof T]: (param: string) => T[K]; 7 | }): T { 8 | if (isBrowser()) { 9 | const searchParams = new URLSearchParams(window.location.search); 10 | 11 | return Array.from(Object.entries(processors)).reduce( 12 | (acc, [key, process]) => ({ 13 | ...acc, 14 | [key]: process(searchParams.get(key)), 15 | }), 16 | {} as T 17 | ); 18 | } 19 | 20 | return {} as T; 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/utils/types/index.ts: -------------------------------------------------------------------------------- 1 | import Choice from "./choice"; 2 | import CommunityTribute from "./community-tribute"; 3 | import ErrorPageData from "./error-page-data"; 4 | import PersonTribute from "./person-tribute"; 5 | import { QueryParams } from "./query-params"; 6 | import { RouteDataResponse } from "./route-data-response"; 7 | import { ThemeColor } from "./theme-color"; 8 | import WithComment from "./with-comment"; 9 | 10 | export { 11 | Choice, 12 | CommunityTribute, 13 | ErrorPageData, 14 | PersonTribute, 15 | QueryParams, 16 | RouteDataResponse, 17 | ThemeColor, 18 | WithComment, 19 | }; 20 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/debounce.ts: -------------------------------------------------------------------------------- 1 | export default function debounce( 2 | func: (...e: T) => R, 3 | wait = 1000, 4 | immediate = false 5 | ) { 6 | let timeout: NodeJS.Timeout | null; 7 | 8 | return function () { 9 | const args = arguments; 10 | const callNow = immediate && !timeout; 11 | 12 | clearTimeout(timeout ?? undefined); 13 | 14 | timeout = setTimeout(function () { 15 | timeout = null; 16 | 17 | if (!immediate) { 18 | func.apply(this, args); 19 | } 20 | }, wait); 21 | 22 | if (callNow) func.apply(this, args); 23 | } as (...e: T) => R; 24 | } 25 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/random-str.ts: -------------------------------------------------------------------------------- 1 | import { getRandomCharFromAlphabet } from "@utils/helpers"; 2 | 3 | const DEFAULT_ALPHABET = 4 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 5 | 6 | export default function randomStr( 7 | idDesiredLength = 20, 8 | alphabet = DEFAULT_ALPHABET 9 | ): string { 10 | /** 11 | * Create n-long array and map it to random chars from given alphabet. 12 | * Then join individual chars as string 13 | */ 14 | return Array.from({ length: idDesiredLength }) 15 | .map(() => { 16 | return getRandomCharFromAlphabet(alphabet); 17 | }) 18 | .join(""); 19 | } 20 | -------------------------------------------------------------------------------- /src/server/utils/set-forwarded-headers.ts: -------------------------------------------------------------------------------- 1 | import { IncomingHttpHeaders } from "http"; 2 | 3 | export function setForwardedHeaders(headers: IncomingHttpHeaders): { 4 | [key: string]: string; 5 | } { 6 | const out: { [key: string]: string } = {}; 7 | 8 | if (headers.host) { 9 | out.host = headers.host; 10 | } 11 | 12 | const realIp = headers["x-real-ip"]; 13 | 14 | if (realIp) { 15 | out["x-real-ip"] = realIp as string; 16 | } 17 | 18 | const forwardedFor = headers["x-forwarded-for"]; 19 | 20 | if (forwardedFor) { 21 | out["x-forwarded-for"] = forwardedFor as string; 22 | } 23 | 24 | return out; 25 | } 26 | -------------------------------------------------------------------------------- /src/server/utils/get-error-page-data.ts: -------------------------------------------------------------------------------- 1 | import { ErrorPageData } from "@utils/types"; 2 | import { GetSiteResponse } from "lemmy-js-client"; 3 | 4 | export function getErrorPageData(error: Error, site?: GetSiteResponse) { 5 | const errorPageData: ErrorPageData = {}; 6 | 7 | if (site) { 8 | errorPageData.error = error.message; 9 | } 10 | 11 | const adminMatrixIds = site?.admins 12 | .map(({ person: { matrix_user_id } }) => matrix_user_id) 13 | .filter(id => id) as string[] | undefined; 14 | 15 | if (adminMatrixIds && adminMatrixIds.length > 0) { 16 | errorPageData.adminMatrixIds = adminMatrixIds; 17 | } 18 | 19 | return errorPageData; 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/components/common/error-guard.tsx: -------------------------------------------------------------------------------- 1 | import { setIsoData } from "@utils/app"; 2 | import { Component } from "inferno"; 3 | import { ErrorPage } from "../app/error-page"; 4 | 5 | class ErrorGuard extends Component { 6 | private isoData = setIsoData(this.context); 7 | 8 | constructor(props: any, context: any) { 9 | super(props, context); 10 | } 11 | 12 | render() { 13 | const errorPageData = this.isoData.errorPageData; 14 | const siteRes = this.isoData.site_res; 15 | 16 | if (errorPageData || !siteRes) { 17 | return ; 18 | } else { 19 | return this.props.children; 20 | } 21 | } 22 | } 23 | 24 | export default ErrorGuard; 25 | -------------------------------------------------------------------------------- /src/shared/utils/browser/index.ts: -------------------------------------------------------------------------------- 1 | import canShare from "./can-share"; 2 | import clearAuthCookie from "./clear-auth-cookie"; 3 | import dataBsTheme from "./data-bs-theme"; 4 | import isBrowser from "./is-browser"; 5 | import isDark from "./is-dark"; 6 | import loadCss from "./load-css"; 7 | import restoreScrollPosition from "./restore-scroll-position"; 8 | import saveScrollPosition from "./save-scroll-position"; 9 | import setAuthCookie from "./set-auth-cookie"; 10 | import share from "./share"; 11 | 12 | export { 13 | canShare, 14 | clearAuthCookie, 15 | dataBsTheme, 16 | isBrowser, 17 | isDark, 18 | loadCss, 19 | restoreScrollPosition, 20 | saveScrollPosition, 21 | setAuthCookie, 22 | share, 23 | }; 24 | -------------------------------------------------------------------------------- /src/shared/utils/roles/index.ts: -------------------------------------------------------------------------------- 1 | import amAdmin from "./am-admin"; 2 | import amCommunityCreator from "./am-community-creator"; 3 | import amMod from "./am-mod"; 4 | import amSiteCreator from "./am-site-creator"; 5 | import amTopMod from "./am-top-mod"; 6 | import canAdmin from "./can-admin"; 7 | import canCreateCommunity from "./can-create-community"; 8 | import canMod from "./can-mod"; 9 | import isAdmin from "./is-admin"; 10 | import isBanned from "./is-banned"; 11 | import isMod from "./is-mod"; 12 | 13 | export { 14 | amAdmin, 15 | amCommunityCreator, 16 | amMod, 17 | amSiteCreator, 18 | amTopMod, 19 | canAdmin, 20 | canCreateCommunity, 21 | canMod, 22 | isAdmin, 23 | isBanned, 24 | isMod, 25 | }; 26 | -------------------------------------------------------------------------------- /src/assets/css/themes/vaporwave-dark.scss: -------------------------------------------------------------------------------- 1 | @import "variables.vaporwave-dark"; 2 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 3 | 4 | .shadow-sm { 5 | background: linear-gradient( 6 | 180deg, 7 | rgba(255, 255, 255, 0.15), 8 | rgba(255, 255, 255, 0) 9 | ); 10 | } 11 | 12 | .navbar { 13 | background: none; 14 | } 15 | 16 | .dropdown-item:hover, 17 | option:disabled { 18 | color: $secondary; 19 | } 20 | 21 | .btn-sm { 22 | margin: 0.25rem; 23 | } 24 | 25 | .form-control::placeholder { 26 | text-shadow: 0.5px 0.5px 0 $secondary, 0.5px -0.5px 0 $secondary, 27 | -0.5px 0.5px 0 $secondary, -0.5px -0.5px 0 $secondary; 28 | } 29 | 30 | .input-group-text { 31 | background: $gray-500; 32 | } 33 | -------------------------------------------------------------------------------- /src/shared/components/person/cake-day.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { I18NextService } from "../../services"; 3 | import { Icon } from "../common/icon"; 4 | 5 | interface CakeDayProps { 6 | creatorName: string; 7 | } 8 | 9 | export class CakeDay extends Component { 10 | render() { 11 | return ( 12 |
16 | 17 |
18 | ); 19 | } 20 | 21 | cakeDayTippy(): string { 22 | return I18NextService.i18n.t("cake_day_info", { 23 | creator_name: this.props.creatorName, 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/shared/utils/app/convert-comment-sort-type.ts: -------------------------------------------------------------------------------- 1 | import { CommentSortType, SortType } from "lemmy-js-client"; 2 | 3 | export default function convertCommentSortType( 4 | sort: SortType 5 | ): CommentSortType { 6 | switch (sort) { 7 | case "TopAll": 8 | case "TopHour": 9 | case "TopSixHour": 10 | case "TopTwelveHour": 11 | case "TopDay": 12 | case "TopWeek": 13 | case "TopMonth": 14 | case "TopThreeMonths": 15 | case "TopSixMonths": 16 | case "TopNineMonths": 17 | case "TopYear": { 18 | return "Top"; 19 | } 20 | case "New": { 21 | return "New"; 22 | } 23 | case "Hot": 24 | case "Active": { 25 | return "Hot"; 26 | } 27 | default: { 28 | return "Hot"; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.vaporwave-dark.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.vaporwave"; 2 | 3 | // Colors 4 | $white: #fff; 5 | $gray-200: #ebebeb; 6 | $gray-600: #888; 7 | $gray-700: #444; 8 | $gray-800: #303030; 9 | $gray-900: #222; 10 | 11 | $light: $gray-700; 12 | $dark: $gray-200; 13 | 14 | $body-bg: $gray-900; 15 | $body-color: $gray-200; 16 | 17 | $card-bg: $body-bg; 18 | $navbar-dark-color: rgba($body-bg, 0.5); 19 | $navbar-light-active-color: rgba($gray-200, 0.9); 20 | $navbar-light-disabled-color: rgba($gray-200, 0.3); 21 | $navbar-light-color: rgba($white, 0.5); 22 | $nav-tabs-link-active-color: $purple; 23 | $input-bg: $gray-600; 24 | $input-color: $white; 25 | $input-disabled-bg: $gray-800; 26 | $input-border-color: $gray-800; 27 | $mark-bg: $gray-600; 28 | $pre-color: $gray-200; 29 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import { initializeSite, setupDateFns } from "@utils/app"; 2 | import { hydrate } from "inferno-hydrate"; 3 | import { BrowserRouter } from "inferno-router"; 4 | import { App } from "../shared/components/app/app"; 5 | import { UserService } from "../shared/services"; 6 | 7 | import "bootstrap/js/dist/collapse"; 8 | import "bootstrap/js/dist/dropdown"; 9 | 10 | async function startClient() { 11 | initializeSite(window.isoData.site_res); 12 | 13 | await setupDateFns(); 14 | 15 | const wrapper = ( 16 | 17 | 18 | 19 | ); 20 | 21 | const root = document.getElementById("root"); 22 | 23 | if (root) { 24 | hydrate(wrapper, root); 25 | } 26 | } 27 | 28 | startClient(); 29 | -------------------------------------------------------------------------------- /src/shared/services/FirstLoadService.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | 3 | export class FirstLoadService { 4 | #isFirstLoad: boolean; 5 | static #instance: FirstLoadService; 6 | 7 | private constructor() { 8 | this.#isFirstLoad = true; 9 | } 10 | 11 | get isFirstLoad() { 12 | const isFirst = this.#isFirstLoad; 13 | if (isFirst) { 14 | this.#isFirstLoad = false; 15 | } 16 | 17 | return isFirst; 18 | } 19 | 20 | falsify() { 21 | this.#isFirstLoad = false; 22 | } 23 | 24 | static get #Instance() { 25 | return this.#instance ?? (this.#instance = new this()); 26 | } 27 | 28 | static get isFirstLoad() { 29 | return !isBrowser() || this.#Instance.isFirstLoad; 30 | } 31 | 32 | static falsify() { 33 | this.#Instance.falsify(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/utils/env/index.ts: -------------------------------------------------------------------------------- 1 | import getBaseLocal from "./get-base-local"; 2 | import getExternalHost from "./get-external-host"; 3 | import getHost from "./get-host"; 4 | import getHttpBase from "./get-http-base"; 5 | import getHttpBaseExternal from "./get-http-base-external"; 6 | import getHttpBaseInternal from "./get-http-base-internal"; 7 | import getInternalHost from "./get-internal-host"; 8 | import getSecure from "./get-secure"; 9 | import getStaticDir from "./get-static-dir"; 10 | import httpExternalPath from "./http-external-path"; 11 | import isHttps from "./is-https"; 12 | 13 | export { 14 | getBaseLocal, 15 | getExternalHost, 16 | getHost, 17 | getHttpBase, 18 | getHttpBaseExternal, 19 | getHttpBaseInternal, 20 | getInternalHost, 21 | getSecure, 22 | getStaticDir, 23 | httpExternalPath, 24 | isHttps, 25 | }; 26 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.vaporwave.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | // Colors 4 | $gray-100: #f8f9fa; 5 | $gray-300: #dee2e6; 6 | $gray-500: #adb5bd; 7 | 8 | $blue: #01cdfe; 9 | $indigo: #b967ff; 10 | $purple: #b967ff; 11 | $pink: rgb(255, 64, 186); 12 | $red: rgb(255, 95, 110); 13 | $orange: rgb(255, 167, 93); 14 | $yellow: #fffb96; 15 | $green: #05ffa1; 16 | $teal: #01cdfe; 17 | $cyan: #01cdfe; 18 | 19 | $primary: $pink; 20 | $secondary: $blue; 21 | 22 | $enable-shadows: true; 23 | $enable-gradients: true; 24 | $enable-responsive-font-sizes: true; 25 | 26 | $border-radius: 1rem; 27 | $border-radius-lg: 1rem; 28 | $font-family-monospace: Arial, "Noto Sans", sans-serif; 29 | $yiq-text-light: $gray-300; 30 | $text-muted: $gray-500; 31 | $navbar-light-hover-color: rgba($primary, 0.7); 32 | $font-family-sans-serif: "Lucida Console", Monaco, monospace; 33 | -------------------------------------------------------------------------------- /src/server/utils/build-themes-list.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "fs"; 2 | import { readdir } from "fs/promises"; 3 | 4 | const extraThemesFolder = 5 | process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; 6 | 7 | const themes: ReadonlyArray = [ 8 | "darkly", 9 | "darkly-red", 10 | "darkly-compact", 11 | "darkly-pureblack", 12 | "litely", 13 | "litely-red", 14 | "litely-compact", 15 | "vaporwave-dark", 16 | "vaporwave-light", 17 | "i386", 18 | ]; 19 | 20 | export async function buildThemeList(): Promise> { 21 | if (existsSync(extraThemesFolder)) { 22 | const dirThemes = await readdir(extraThemesFolder); 23 | const cssThemes = dirThemes 24 | .filter(d => d.endsWith(".css")) 25 | .map(d => d.replace(".css", "")); 26 | return themes.concat(cssThemes); 27 | } 28 | return themes; 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/components/common/emoji-mart.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { getEmojiMart } from "../../markdown"; 3 | 4 | interface EmojiMartProps { 5 | onEmojiClick?(val: any): any; 6 | pickerOptions: any; 7 | } 8 | 9 | export class EmojiMart extends Component { 10 | constructor(props: any, context: any) { 11 | super(props, context); 12 | this.handleEmojiClick = this.handleEmojiClick.bind(this); 13 | } 14 | componentDidMount() { 15 | const div: any = document.getElementById("emoji-picker"); 16 | if (div) { 17 | div.appendChild( 18 | getEmojiMart(this.handleEmojiClick, this.props.pickerOptions) 19 | ); 20 | } 21 | } 22 | 23 | render() { 24 | return
; 25 | } 26 | 27 | handleEmojiClick(e: any) { 28 | this.props.onEmojiClick?.(e); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/shared/utils/app/insert-comment-into-tree.ts: -------------------------------------------------------------------------------- 1 | import { getCommentParentId, searchCommentTree } from "@utils/app"; 2 | import { CommentView } from "lemmy-js-client"; 3 | import { CommentNodeI } from "../../interfaces"; 4 | 5 | export default function insertCommentIntoTree( 6 | tree: CommentNodeI[], 7 | cv: CommentView, 8 | parentComment: boolean 9 | ) { 10 | // Building a fake node to be used for later 11 | const node: CommentNodeI = { 12 | comment_view: cv, 13 | children: [], 14 | depth: 0, 15 | }; 16 | 17 | const parentId = getCommentParentId(cv.comment); 18 | if (parentId) { 19 | const parent_comment = searchCommentTree(tree, parentId); 20 | if (parent_comment) { 21 | node.depth = parent_comment.depth + 1; 22 | parent_comment.children.unshift(node); 23 | } 24 | } else if (!parentComment) { 25 | tree.unshift(node); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "pretty": true, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "allowSyntheticDefaultImports": true, 7 | "preserveConstEnums": true, 8 | "sourceMap": true, 9 | "moduleResolution": "node", 10 | "lib": ["es2017", "dom"], 11 | "types": ["inferno"], 12 | "jsx": "preserve", 13 | "noUnusedLocals": true, 14 | "baseUrl": "./src", 15 | "noEmit": true, 16 | "skipLibCheck": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "experimentalDecorators": true, 20 | "strictNullChecks": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "paths": { 23 | "@/*": ["/*"], 24 | "@utils/*": ["shared/utils/*"] 25 | } 26 | }, 27 | "include": [ 28 | "src/**/*.ts", 29 | "src/**/*.tsx", 30 | "node_modules/inferno/dist/index.d.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/shared/components/common/banner-icon-header.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { PictrsImage } from "./pictrs-image"; 3 | 4 | interface BannerIconHeaderProps { 5 | banner?: string; 6 | icon?: string; 7 | } 8 | 9 | export class BannerIconHeader extends Component { 10 | constructor(props: any, context: any) { 11 | super(props, context); 12 | } 13 | 14 | render() { 15 | const banner = this.props.banner; 16 | const icon = this.props.icon; 17 | return ( 18 | (banner || icon) && ( 19 |
20 | {banner && } 21 | {icon && ( 22 | 28 | )} 29 |
30 | ) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/shared/utils/app/update-person-block.ts: -------------------------------------------------------------------------------- 1 | import { BlockPersonResponse, MyUserInfo } from "lemmy-js-client"; 2 | import { I18NextService, UserService } from "../../services"; 3 | import { toast } from "../../toast"; 4 | 5 | export default function updatePersonBlock( 6 | data: BlockPersonResponse, 7 | myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo 8 | ) { 9 | if (myUserInfo) { 10 | if (data.blocked) { 11 | myUserInfo.person_blocks.push({ 12 | person: myUserInfo.local_user_view.person, 13 | target: data.person_view.person, 14 | }); 15 | toast( 16 | `${I18NextService.i18n.t("blocked")} ${data.person_view.person.name}` 17 | ); 18 | } else { 19 | myUserInfo.person_blocks = myUserInfo.person_blocks.filter( 20 | i => i.target.id !== data.person_view.person.id 21 | ); 22 | toast( 23 | `${I18NextService.i18n.t("unblocked")} ${data.person_view.person.name}` 24 | ); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.2-alpine as builder 2 | RUN apk update && apk add curl yarn python3 build-base gcc wget git --no-cache 3 | 4 | WORKDIR /usr/src/app 5 | 6 | ENV npm_config_target_arch=x64 7 | ENV npm_config_target_platform=linux 8 | ENV npm_config_target_libc=musl 9 | 10 | # Cache deps 11 | COPY package.json yarn.lock ./ 12 | RUN yarn --prefer-offline --pure-lockfile 13 | 14 | # Build 15 | COPY generate_translations.js \ 16 | tsconfig.json \ 17 | webpack.config.js \ 18 | .babelrc \ 19 | ./ 20 | 21 | COPY lemmy-translations lemmy-translations 22 | COPY src src 23 | COPY .git .git 24 | 25 | # Set UI version 26 | RUN echo "export const VERSION = 'dev';" > "src/shared/version.ts" 27 | 28 | RUN yarn --prefer-offline 29 | RUN yarn build:dev 30 | 31 | FROM node:alpine as runner 32 | COPY --from=builder /usr/src/app/dist /app/dist 33 | COPY --from=builder /usr/src/app/node_modules /app/node_modules 34 | 35 | EXPOSE 1234 36 | WORKDIR /app 37 | CMD node dist/js/server.js -------------------------------------------------------------------------------- /src/server/handlers/theme-handler.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { existsSync } from "fs"; 3 | import path from "path"; 4 | 5 | const extraThemesFolder = 6 | process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; 7 | 8 | export default async (req: Request, res: Response) => { 9 | res.contentType("text/css"); 10 | 11 | const theme = req.params.name; 12 | 13 | if (!theme.endsWith(".css")) { 14 | return res.status(400).send("Theme must be a css file"); 15 | } 16 | 17 | const customTheme = path.resolve(extraThemesFolder, theme); 18 | 19 | if (existsSync(customTheme)) { 20 | return res.sendFile(customTheme); 21 | } else { 22 | const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`); 23 | 24 | // If the theme doesn't exist, just send litely 25 | if (existsSync(internalTheme)) { 26 | return res.sendFile(internalTheme); 27 | } else { 28 | return res.sendFile(path.resolve("./dist/assets/css/themes/litely.css")); 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/shared/utils/roles/can-mod.ts: -------------------------------------------------------------------------------- 1 | import { CommunityModeratorView, PersonView } from "lemmy-js-client"; 2 | import { UserService } from "../../services"; 3 | 4 | export default function canMod( 5 | creator_id: string, 6 | mods?: CommunityModeratorView[], 7 | admins?: PersonView[], 8 | myUserInfo = UserService.Instance.myUserInfo, 9 | onSelf = false 10 | ): boolean { 11 | // You can do moderator actions only on the mods added after you. 12 | let adminsThenMods = 13 | admins 14 | ?.map(a => a.person.id) 15 | .concat(mods?.map(m => m.moderator.id) ?? []) ?? []; 16 | 17 | if (myUserInfo) { 18 | const myIndex = adminsThenMods.findIndex( 19 | id => id == myUserInfo.local_user_view.person.id 20 | ); 21 | if (myIndex == -1) { 22 | return false; 23 | } else { 24 | // onSelf +1 on mod actions not for yourself, IE ban, remove, etc 25 | adminsThenMods = adminsThenMods.slice(0, myIndex + (onSelf ? 0 : 1)); 26 | return !adminsThenMods.includes(creator_id); 27 | } 28 | } else { 29 | return false; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/shared/utils/app/update-community-block.ts: -------------------------------------------------------------------------------- 1 | import { BlockCommunityResponse, MyUserInfo } from "lemmy-js-client"; 2 | import { I18NextService, UserService } from "../../services"; 3 | import { toast } from "../../toast"; 4 | 5 | export default function updateCommunityBlock( 6 | data: BlockCommunityResponse, 7 | myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo 8 | ) { 9 | if (myUserInfo) { 10 | if (data.blocked) { 11 | myUserInfo.community_blocks.push({ 12 | person: myUserInfo.local_user_view.person, 13 | community: data.community_view.community, 14 | }); 15 | toast( 16 | `${I18NextService.i18n.t("blocked")} ${ 17 | data.community_view.community.name 18 | }` 19 | ); 20 | } else { 21 | myUserInfo.community_blocks = myUserInfo.community_blocks.filter( 22 | i => i.community.id !== data.community_view.community.id 23 | ); 24 | toast( 25 | `${I18NextService.i18n.t("unblocked")} ${ 26 | data.community_view.community.name 27 | }` 28 | ); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/css/themes/darkly-compact.scss: -------------------------------------------------------------------------------- 1 | @import "variables.darkly-compact"; 2 | 3 | /* 4 | GENERAL 5 | */ 6 | 7 | // Desktop Breakpoint 8 | $container-max-widths: ( 9 | lg: 1920px, 10 | ); 11 | 12 | // Reduce hr height 13 | hr.my-3 { 14 | margin-top: 0.5rem !important; 15 | margin-bottom: 0.5rem !important; 16 | } 17 | 18 | /* 19 | POST-LISTING 20 | */ 21 | 22 | .post-listing { 23 | line-height: 1; 24 | 25 | .post-title h5 { 26 | margin: 0; 27 | } 28 | 29 | .post-title + p { 30 | padding-top: 0.125rem !important; 31 | padding-bottom: 0.125rem !important; 32 | } 33 | 34 | .community-link { 35 | padding-left: 0.125rem; 36 | } 37 | 38 | .person-listing { 39 | padding-right: 0.125rem; 40 | } 41 | 42 | ul.list-inline { 43 | &.mt-2 { 44 | margin-top: 0.125rem !important; 45 | } 46 | &.mb-1 { 47 | margin-bottom: 0.125rem !important; 48 | } 49 | } 50 | 51 | .btn-sm { 52 | --bs-btn-padding-y: 0; 53 | } 54 | .img-icon { 55 | display: none; 56 | } 57 | } 58 | 59 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 60 | -------------------------------------------------------------------------------- /src/assets/css/themes/litely-compact.scss: -------------------------------------------------------------------------------- 1 | @import "variables.litely-compact"; 2 | 3 | /* 4 | GENERAL 5 | */ 6 | 7 | // Desktop Breakpoint 8 | $container-max-widths: ( 9 | lg: 1920px, 10 | ); 11 | 12 | // Reduce hr height 13 | hr.my-3 { 14 | margin-top: 0.5rem !important; 15 | margin-bottom: 0.5rem !important; 16 | } 17 | 18 | /* 19 | POST-LISTING 20 | */ 21 | 22 | .post-listing { 23 | line-height: 1; 24 | 25 | .post-title h5 { 26 | margin: 0; 27 | } 28 | 29 | .post-title + p { 30 | padding-top: 0.125rem !important; 31 | padding-bottom: 0.125rem !important; 32 | } 33 | 34 | .community-link { 35 | padding-left: 0.125rem; 36 | } 37 | 38 | .person-listing { 39 | padding-right: 0.125rem; 40 | } 41 | 42 | ul.list-inline { 43 | &.mt-2 { 44 | margin-top: 0.125rem !important; 45 | } 46 | &.mb-1 { 47 | margin-bottom: 0.125rem !important; 48 | } 49 | } 50 | 51 | .btn-sm { 52 | --bs-btn-padding-y: 0; 53 | } 54 | .img-icon { 55 | display: none; 56 | } 57 | } 58 | 59 | @import "../../../../node_modules/bootstrap/scss/bootstrap"; 60 | -------------------------------------------------------------------------------- /.woodpecker.yml: -------------------------------------------------------------------------------- 1 | pipeline: 2 | fetch_git_submodules: 3 | image: node:alpine 4 | commands: 5 | - apk add git 6 | - git submodule init 7 | - git submodule update --recursive --remote 8 | # - git fetch --tags 9 | 10 | yarn: 11 | image: node:alpine 12 | commands: 13 | - yarn 14 | 15 | yarn_lint: 16 | image: node:alpine 17 | commands: 18 | - yarn lint 19 | 20 | yarn_build_dev: 21 | image: node:alpine 22 | commands: 23 | - yarn build:dev 24 | 25 | publish_release_docker: 26 | image: woodpeckerci/plugin-docker-buildx 27 | secrets: [docker_username, docker_password] 28 | settings: 29 | repo: dessalines/lemmy-ui 30 | dockerfile: Dockerfile 31 | platforms: linux/amd64 32 | auto_tag: true 33 | when: 34 | event: tag 35 | 36 | nightly_build: 37 | image: woodpeckerci/plugin-docker-buildx 38 | secrets: [docker_username, docker_password] 39 | settings: 40 | repo: dessalines/lemmy-ui 41 | dockerfile: Dockerfile 42 | platforms: linux/amd64 43 | tag: dev 44 | when: 45 | event: cron 46 | -------------------------------------------------------------------------------- /src/shared/components/common/paginator.tsx: -------------------------------------------------------------------------------- 1 | import { Component, linkEvent } from "inferno"; 2 | import { I18NextService } from "../../services"; 3 | 4 | interface PaginatorProps { 5 | page: number; 6 | onChange(val: number): any; 7 | } 8 | 9 | export class Paginator extends Component { 10 | constructor(props: any, context: any) { 11 | super(props, context); 12 | } 13 | render() { 14 | return ( 15 |
16 | 23 | 29 |
30 | ); 31 | } 32 | 33 | handlePrev(i: Paginator) { 34 | i.props.onChange(i.props.page - 1); 35 | } 36 | 37 | handleNext(i: Paginator) { 38 | i.props.onChange(i.props.page + 1); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/shared/utils/app/set-theme.ts: -------------------------------------------------------------------------------- 1 | import { fetchThemeList } from "@utils/app"; 2 | import { isBrowser, loadCss } from "@utils/browser"; 3 | 4 | export default async function setTheme(theme: string, forceReload = false) { 5 | if (!isBrowser()) { 6 | return; 7 | } 8 | if (theme === "browser" && !forceReload) { 9 | return; 10 | } 11 | // This is only run on a force reload 12 | if (theme == "browser") { 13 | theme = "darkly"; 14 | } 15 | 16 | const themeList = await fetchThemeList(); 17 | 18 | // Unload all the other themes 19 | for (var i = 0; i < themeList.length; i++) { 20 | const styleSheet = document.getElementById(themeList[i]); 21 | if (styleSheet) { 22 | styleSheet.setAttribute("disabled", "disabled"); 23 | } 24 | } 25 | 26 | document 27 | .getElementById("default-light") 28 | ?.setAttribute("disabled", "disabled"); 29 | document.getElementById("default-dark")?.setAttribute("disabled", "disabled"); 30 | 31 | // Load the theme dynamically 32 | const cssLoc = `/css/themes/${theme}.css`; 33 | 34 | loadCss(theme, cssLoc); 35 | document.getElementById(theme)?.removeAttribute("disabled"); 36 | } 37 | -------------------------------------------------------------------------------- /src/shared/components/common/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeColor } from "@utils/types"; 2 | import classNames from "classnames"; 3 | 4 | interface ProgressBarProps { 5 | className?: string; 6 | backgroundColor?: ThemeColor; 7 | barColor?: ThemeColor; 8 | striped?: boolean; 9 | animated?: boolean; 10 | min?: number; 11 | max?: number; 12 | value: number; 13 | text?: string; 14 | } 15 | 16 | const ProgressBar = ({ 17 | value, 18 | animated = false, 19 | backgroundColor = "secondary", 20 | barColor = "primary", 21 | className, 22 | max = 100, 23 | min = 0, 24 | striped = false, 25 | text, 26 | }: ProgressBarProps) => ( 27 |
28 |
39 | {text} 40 |
41 |
42 | ); 43 | 44 | export default ProgressBar; 45 | -------------------------------------------------------------------------------- /src/shared/utils/app/selectable-languages.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "lemmy-js-client"; 2 | import { UserService } from "../../services"; 3 | 4 | /** 5 | * This shows what language you can select 6 | * 7 | * Use showAll for the site form 8 | * Use showSite for the profile and community forms 9 | * Use false for both those to filter on your profile and site ones 10 | */ 11 | export default function selectableLanguages( 12 | allLanguages: Language[], 13 | siteLanguages: number[], 14 | showAll?: boolean, 15 | showSite?: boolean, 16 | myUserInfo = UserService.Instance.myUserInfo 17 | ): Language[] { 18 | const allLangIds = allLanguages.map(l => l.id); 19 | let myLangs = myUserInfo?.discussion_languages ?? allLangIds; 20 | myLangs = myLangs.length == 0 ? allLangIds : myLangs; 21 | const siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages; 22 | 23 | if (showAll) { 24 | return allLanguages; 25 | } else { 26 | if (showSite) { 27 | return allLanguages.filter(x => siteLangs.includes(x.id)); 28 | } else { 29 | return allLanguages 30 | .filter(x => siteLangs.includes(x.id)) 31 | .filter(x => myLangs.includes(x.id)); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/shared/utils/app/setup-date-fns.ts: -------------------------------------------------------------------------------- 1 | import setDefaultOptions from "date-fns/setDefaultOptions"; 2 | import { I18NextService } from "../../services"; 3 | 4 | const EN_US = "en-US"; 5 | 6 | export default async function () { 7 | let lang = I18NextService.i18n.language; 8 | if (lang === "en") { 9 | lang = EN_US; 10 | } 11 | 12 | // if lang and country are the same, then date-fns expects only the lang 13 | // eg: instead of "fr-FR", we should import just "fr" 14 | 15 | if (lang.includes("-")) { 16 | const parts = lang.split("-"); 17 | if (parts[0] === parts[1].toLowerCase()) { 18 | lang = parts[0]; 19 | } 20 | } 21 | 22 | let locale; 23 | 24 | try { 25 | locale = ( 26 | await import( 27 | /* webpackExclude: /\.js\.flow$/ */ 28 | `date-fns/locale/${lang}` 29 | ) 30 | ).default; 31 | } catch (e) { 32 | console.log( 33 | `Could not load locale ${lang} from date-fns, falling back to ${EN_US}` 34 | ); 35 | locale = ( 36 | await import( 37 | /* webpackExclude: /\.js\.flow$/ */ 38 | `date-fns/locale/${EN_US}` 39 | ) 40 | ).default; 41 | } 42 | setDefaultOptions({ 43 | locale, 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/server/handlers/manifest-handler.ts: -------------------------------------------------------------------------------- 1 | import { getHttpBaseExternal, getHttpBaseInternal } from "@utils/env"; 2 | import fetch from "cross-fetch"; 3 | import type { Request, Response } from "express"; 4 | import { LemmyHttp } from "lemmy-js-client"; 5 | import { wrapClient } from "../../shared/services/HttpService"; 6 | import generateManifestJson from "../utils/generate-manifest-json"; 7 | import { setForwardedHeaders } from "../utils/set-forwarded-headers"; 8 | 9 | let manifest: Awaited> | undefined = 10 | undefined; 11 | 12 | export default async (req: Request, res: Response) => { 13 | if (!manifest || manifest.start_url !== getHttpBaseExternal()) { 14 | const headers = setForwardedHeaders(req.headers); 15 | const client = wrapClient( 16 | new LemmyHttp(getHttpBaseInternal(), { fetchFunction: fetch, headers }) 17 | ); 18 | const site = await client.getSite({}); 19 | 20 | if (site.state === "success") { 21 | manifest = await generateManifestJson(site.data); 22 | } else { 23 | res.sendStatus(500); 24 | return; 25 | } 26 | } 27 | 28 | res.setHeader("content-type", "application/manifest+json"); 29 | 30 | res.send(manifest); 31 | }; 32 | -------------------------------------------------------------------------------- /src/shared/components/home/legal.tsx: -------------------------------------------------------------------------------- 1 | import { setIsoData } from "@utils/app"; 2 | import { Component } from "inferno"; 3 | import { GetSiteResponse } from "lemmy-js-client"; 4 | import { mdToHtml } from "../../markdown"; 5 | import { I18NextService } from "../../services"; 6 | import { HtmlTags } from "../common/html-tags"; 7 | 8 | interface LegalState { 9 | siteRes: GetSiteResponse; 10 | } 11 | 12 | export class Legal extends Component { 13 | private isoData = setIsoData(this.context); 14 | state: LegalState = { 15 | siteRes: this.isoData.site_res, 16 | }; 17 | 18 | constructor(props: any, context: any) { 19 | super(props, context); 20 | } 21 | 22 | get documentTitle(): string { 23 | return I18NextService.i18n.t("legal_information"); 24 | } 25 | 26 | render() { 27 | const legal = this.state.siteRes.site_view.local_site.legal_information; 28 | return ( 29 |
30 | 34 | {legal && ( 35 |
36 | )} 37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 Feature request" 2 | description: Suggest an idea for Lemmy-UI. 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to help improve Lemmy-UI by suggesting a feature! 9 | - type: checkboxes 10 | attributes: 11 | label: Requirements 12 | description: Before you create a feature request, please carefully check the following – 13 | options: 14 | - label: This is a feature request and not a bug report. Otherwise, please create a new [bug report](https://github.com/LemmyNet/lemmy-ui/issues/new?assignees=&labels=bug%2Ctriage&projects=&template=BUG_REPORT.yml) instead. 15 | required: true 16 | - label: Please [check](https://github.com/LemmyNet/lemmy-ui/issues) to see if this request (or a similar one) already exists. 17 | required: true 18 | - label: It's a single feature. Please don't request multiple features in one issue. 19 | required: true 20 | - type: textarea 21 | id: solution 22 | attributes: 23 | label: Describe the feature you'd like 24 | description: | 25 | Provide a clear and concise description of the feature. Explain why it's needed. 26 | validations: 27 | required: true 28 | -------------------------------------------------------------------------------- /src/assets/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WePi", 3 | "description": "A link aggregator for the fediverse", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#222222", 7 | "icons": [ 8 | { 9 | "src": "/static/assets/icons/icon-72x72.png", 10 | "sizes": "72x72", 11 | "type": "image/png" 12 | }, 13 | { 14 | "src": "/static/assets/icons/icon-96x96.png", 15 | "sizes": "96x96", 16 | "type": "image/png" 17 | }, 18 | { 19 | "src": "/static/assets/icons/icon-128x128.png", 20 | "sizes": "128x128", 21 | "type": "image/png" 22 | }, 23 | { 24 | "src": "/static/assets/icons/icon-144x144.png", 25 | "sizes": "144x144", 26 | "type": "image/png" 27 | }, 28 | { 29 | "src": "/static/assets/icons/icon-152x152.png", 30 | "sizes": "152x152", 31 | "type": "image/png" 32 | }, 33 | { 34 | "src": "/static/assets/icons/icon-192x192.png", 35 | "sizes": "192x192", 36 | "type": "image/png" 37 | }, 38 | { 39 | "src": "/static/assets/icons/icon-384x384.png", 40 | "sizes": "384x384", 41 | "type": "image/png" 42 | }, 43 | { 44 | "src": "/static/assets/icons/icon-512x512.png", 45 | "sizes": "512x512", 46 | "type": "image/png" 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.litely.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | // Colors 4 | $gray-100: #f8f9fa; 5 | $gray-600: #6c757d; 6 | $gray-700: #495057; 7 | $gray-800: #343a40; 8 | $gray-900: #212529; 9 | $black: #222; 10 | 11 | $blue: #007bff; 12 | $indigo: #6610f2; 13 | $red: #d8486a; 14 | $orange: #f1641e; 15 | $green: #00a846; 16 | $cyan: #02bdc2; 17 | 18 | $primary: $orange; 19 | $secondary: $green; 20 | $success: $indigo; 21 | $info: $blue; 22 | $danger: darken($primary, 25%); 23 | 24 | $body-color: $gray-700; 25 | $body-bg: #fff; 26 | $link-color: $primary; 27 | $border-color: rgba($body-color, 0.25); 28 | $mark-bg: rgb(255, 252, 239); 29 | $headings-color: $gray-700; 30 | 31 | $font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Droid Sans", 32 | "Segoe UI", "Helvetica", Arial, sans-serif; 33 | $font-weight-bold: 600; 34 | 35 | $card-color: $gray-700; 36 | $card-cap-color: $gray-700; 37 | $card-bg: $gray-100; 38 | 39 | $navbar-dark-toggler-border-color: rgba($black, 0.1); 40 | $navbar-light-color: $gray-600; 41 | $navbar-light-hover-color: $gray-900; 42 | $navbar-light-active-color: $gray-900; 43 | 44 | $form-feedback-valid-color: $info; 45 | $input-btn-focus-color: rgba($primary, 0.75); 46 | 47 | $border-radius: 0.5rem; 48 | $border-radius-lg: 0.5rem; 49 | $border-radius-sm: 1rem; 50 | $rounded-pill: 0.25rem; 51 | 52 | $hr-border-color: rgba($body-color, 0.25); 53 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true 5 | }, 6 | "plugins": ["@typescript-eslint", "jsx-a11y", "prettier"], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:inferno/recommended", 11 | "plugin:jsx-a11y/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": "./tsconfig.json", 16 | "warnOnUnsupportedTypeScriptVersion": false 17 | }, 18 | "rules": { 19 | "@typescript-eslint/ban-ts-comment": 0, 20 | "@typescript-eslint/no-explicit-any": 0, 21 | "@typescript-eslint/explicit-module-boundary-types": 0, 22 | "@typescript-eslint/no-empty-function": 0, 23 | "arrow-body-style": 0, 24 | "curly": 0, 25 | "eol-last": 0, 26 | "eqeqeq": 0, 27 | "func-style": 0, 28 | "import/no-duplicates": 0, 29 | "max-statements": 0, 30 | "max-params": 0, 31 | "new-cap": 0, 32 | "no-console": 0, 33 | "no-duplicate-imports": 0, 34 | "no-extra-parens": 0, 35 | "no-return-assign": 0, 36 | "no-throw-literal": 0, 37 | "no-trailing-spaces": 0, 38 | "no-unused-expressions": 0, 39 | "no-useless-constructor": 0, 40 | "no-useless-escape": 0, 41 | "no-var": 0, 42 | "prefer-const": 1, 43 | "prefer-rest-params": 0, 44 | "prettier/prettier": "error", 45 | "quote-props": 0, 46 | "unicorn/filename-case": 0 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.2-alpine as builder 2 | RUN apk update && apk add curl yarn python3 build-base gcc wget git --no-cache 3 | RUN curl -sf https://gobinaries.com/tj/node-prune | sh 4 | 5 | WORKDIR /usr/src/app 6 | 7 | ENV npm_config_target_arch=x64 8 | ENV npm_config_target_platform=linux 9 | ENV npm_config_target_libc=musl 10 | 11 | # Cache deps 12 | COPY package.json yarn.lock ./ 13 | RUN yarn --production --prefer-offline --pure-lockfile 14 | 15 | # Build 16 | COPY generate_translations.js \ 17 | tsconfig.json \ 18 | webpack.config.js \ 19 | .babelrc \ 20 | ./ 21 | 22 | COPY lemmy-translations lemmy-translations 23 | COPY src src 24 | COPY .git .git 25 | 26 | # Set UI version 27 | RUN echo "export const VERSION = '$(git describe --tag)';" > "src/shared/version.ts" 28 | 29 | RUN yarn --production --prefer-offline 30 | RUN NODE_OPTIONS="--max-old-space-size=8192" yarn build:prod 31 | 32 | # Prune the image 33 | RUN node-prune /usr/src/app/node_modules 34 | 35 | RUN rm -rf ./node_modules/import-sort-parser-typescript 36 | RUN rm -rf ./node_modules/typescript 37 | RUN rm -rf ./node_modules/npm 38 | 39 | RUN du -sh ./node_modules/* | sort -nr | grep '\dM.*' 40 | 41 | FROM node:alpine as runner 42 | COPY --from=builder /usr/src/app/dist /app/dist 43 | COPY --from=builder /usr/src/app/node_modules /app/node_modules 44 | 45 | RUN chown -R node:node /app 46 | 47 | USER node 48 | EXPOSE 1234 49 | WORKDIR /app 50 | CMD node dist/js/server.js 51 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.i386.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | // Colors 4 | $white: #fff; 5 | $gray-100: #f8f9fa; 6 | $gray-200: #ebebeb; 7 | $gray-300: #bbb; 8 | $gray-500: #adb5bd; 9 | $gray-800: #303030; 10 | $gray-900: #222; 11 | 12 | $blue: #5555ff; 13 | $cyan: #55ffff; 14 | $green: #55ff55; 15 | $indigo: #ff55ff; 16 | $red: #ff5555; 17 | $yellow: #fefe54; 18 | $orange: #a85400; 19 | $pink: #fe54fe; 20 | $purple: #fe5454; 21 | 22 | $primary: #fefe54; 23 | $secondary: $gray-900; 24 | $success: #00aa00; 25 | $danger: #aa0000; 26 | $info: #00aaaa; 27 | $warning: #aa00aa; 28 | $light: $gray-800; 29 | $dark: $gray-300; 30 | 31 | $body-bg: #000084; 32 | $body-color: $gray-300; 33 | 34 | $link-hover-color: $white; 35 | 36 | $font-family-sans-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace; 37 | $font-family-monospace: DOS, Monaco, Menlo, Consolas, "Courier New", monospace; 38 | 39 | $navbar-dark-color: $gray-300; 40 | $navbar-light-brand-color: $gray-300; 41 | $navbar-dark-active-color: $gray-100; 42 | $nav-tabs-link-active-color: $gray-100; 43 | $navbar-dark-hover-color: rgba($gray-300, 0.75); 44 | $navbar-light-disabled-color: $gray-800; 45 | $navbar-light-active-color: $gray-100; 46 | $navbar-light-hover-color: $gray-200; 47 | $navbar-light-color: $gray-300; 48 | 49 | $enable-rounded: false; 50 | 51 | $input-color: $white; 52 | $input-bg: rgb(102, 102, 102); 53 | $input-placeholder-color: $gray-500; 54 | $input-disabled-bg: $gray-800; 55 | 56 | $card-bg: $gray-800; 57 | $card-border-color: $white; 58 | $mark-bg: #463b00; 59 | -------------------------------------------------------------------------------- /src/shared/services/HomeCacheService.ts: -------------------------------------------------------------------------------- 1 | import { GetPostsResponse } from "lemmy-js-client"; 2 | import { RequestState } from "./HttpService.js"; 3 | 4 | /** 5 | * Service to cache home post listings and restore home state when user uses the browser back buttons. 6 | */ 7 | export class HomeCacheService { 8 | static #_instance: HomeCacheService; 9 | historyIdx = 0; 10 | scrollY = 0; 11 | posts: RequestState = { state: "empty" }; 12 | 13 | get active() { 14 | return ( 15 | this.historyIdx === window.history.state.idx + 1 && 16 | this.posts.state === "success" 17 | ); 18 | } 19 | 20 | deactivate() { 21 | this.historyIdx = 0; 22 | } 23 | 24 | activate() { 25 | this.scrollY = window.scrollY; 26 | this.historyIdx = window.history.state.idx; 27 | } 28 | 29 | static get #Instance() { 30 | return this.#_instance ?? (this.#_instance = new this()); 31 | } 32 | 33 | public static get scrollY() { 34 | return this.#Instance.scrollY; 35 | } 36 | 37 | public static get historyIdx() { 38 | return this.#Instance.historyIdx; 39 | } 40 | 41 | public static set postsRes(posts: RequestState) { 42 | this.#Instance.posts = posts; 43 | } 44 | 45 | public static get postsRes() { 46 | return this.#Instance.posts; 47 | } 48 | 49 | public static get active() { 50 | return this.#Instance.active; 51 | } 52 | 53 | public static deactivate() { 54 | this.#Instance.deactivate(); 55 | } 56 | 57 | public static activate() { 58 | this.#Instance.activate(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/shared/utils/app/build-comments-tree.ts: -------------------------------------------------------------------------------- 1 | import { getCommentParentId, getDepthFromComment } from "@utils/app"; 2 | import { CommentView } from "lemmy-js-client"; 3 | import { CommentNodeI } from "../../interfaces"; 4 | 5 | export default function buildCommentsTree( 6 | comments: CommentView[], 7 | parentComment: boolean 8 | ): CommentNodeI[] { 9 | const map = new Map(); 10 | const depthOffset = !parentComment 11 | ? 0 12 | : getDepthFromComment(comments[0].comment) ?? 0; 13 | 14 | for (const comment_view of comments) { 15 | const depthI = getDepthFromComment(comment_view.comment) ?? 0; 16 | const depth = depthI ? depthI - depthOffset : 0; 17 | const node: CommentNodeI = { 18 | comment_view, 19 | children: [], 20 | depth, 21 | }; 22 | map.set(comment_view.comment.id, { ...node }); 23 | } 24 | 25 | const tree: CommentNodeI[] = []; 26 | 27 | // if its a parent comment fetch, then push the first comment to the top node. 28 | if (parentComment) { 29 | const cNode = map.get(comments[0].comment.id); 30 | if (cNode) { 31 | tree.push(cNode); 32 | } 33 | } 34 | 35 | for (const comment_view of comments) { 36 | const child = map.get(comment_view.comment.id); 37 | if (child) { 38 | const parent_id = getCommentParentId(comment_view.comment); 39 | if (parent_id) { 40 | const parent = map.get(parent_id); 41 | // Necessary because blocked comment might not exist 42 | if (parent) { 43 | parent.children.push(child); 44 | } 45 | } else { 46 | if (!parentComment) { 47 | tree.push(child); 48 | } 49 | } 50 | } 51 | } 52 | 53 | return tree; 54 | } 55 | -------------------------------------------------------------------------------- /src/shared/toast.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "@utils/browser"; 2 | import { ThemeColor } from "@utils/types"; 3 | import Toastify from "toastify-js"; 4 | import { I18NextService } from "./services"; 5 | 6 | export function toast(text: string, background: ThemeColor = "success") { 7 | if (isBrowser()) { 8 | const backgroundColor = `var(--bs-${background})`; 9 | Toastify({ 10 | text: text, 11 | backgroundColor: backgroundColor, 12 | gravity: "bottom", 13 | position: "left", 14 | duration: 5000, 15 | }).showToast(); 16 | } 17 | } 18 | 19 | export function pictrsDeleteToast(filename: string, deleteUrl: string) { 20 | if (isBrowser()) { 21 | const clickToDeleteText = I18NextService.i18n.t("click_to_delete_picture", { 22 | filename, 23 | }); 24 | const deletePictureText = I18NextService.i18n.t("picture_deleted", { 25 | filename, 26 | }); 27 | const failedDeletePictureText = I18NextService.i18n.t( 28 | "failed_to_delete_picture", 29 | { 30 | filename, 31 | } 32 | ); 33 | 34 | const backgroundColor = `var(--bs-light)`; 35 | 36 | const toast = Toastify({ 37 | text: clickToDeleteText, 38 | backgroundColor: backgroundColor, 39 | gravity: "top", 40 | position: "right", 41 | duration: 10000, 42 | onClick: () => { 43 | if (toast) { 44 | fetch(deleteUrl).then(res => { 45 | toast.hideToast(); 46 | if (res.ok === true) { 47 | alert(deletePictureText); 48 | } else { 49 | alert(failedDeletePictureText); 50 | } 51 | }); 52 | } 53 | }, 54 | close: true, 55 | }); 56 | 57 | toast.showToast(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/shared/utils/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import capitalizeFirstLetter from "./capitalize-first-letter"; 2 | import debounce from "./debounce"; 3 | import editListImmutable from "./edit-list-immutable"; 4 | import formatPastDate from "./format-past-date"; 5 | import futureDaysToUnixTime from "./future-days-to-unix-time"; 6 | import getIdFromString from "./get-id-from-string"; 7 | import getPageFromString from "./get-page-from-string"; 8 | import getQueryParams from "./get-query-params"; 9 | import getQueryString from "./get-query-string"; 10 | import getRandomCharFromAlphabet from "./get-random-char-from-alphabet"; 11 | import getRandomFromList from "./get-random-from-list"; 12 | import getUnixTime from "./get-unix-time"; 13 | import { groupBy } from "./group-by"; 14 | import hostname from "./hostname"; 15 | import hsl from "./hsl"; 16 | import isCakeDay from "./is-cake-day"; 17 | import numToSI from "./num-to-si"; 18 | import poll from "./poll"; 19 | import randomStr from "./random-str"; 20 | import removeAuthParam from "./remove-auth-param"; 21 | import sleep from "./sleep"; 22 | import validEmail from "./valid-email"; 23 | import validInstanceTLD from "./valid-instance-tld"; 24 | import validTitle from "./valid-title"; 25 | import validURL from "./valid-url"; 26 | 27 | export { 28 | capitalizeFirstLetter, 29 | debounce, 30 | editListImmutable, 31 | formatPastDate, 32 | futureDaysToUnixTime, 33 | getIdFromString, 34 | getPageFromString, 35 | getQueryParams, 36 | getQueryString, 37 | getRandomCharFromAlphabet, 38 | getRandomFromList, 39 | getUnixTime, 40 | groupBy, 41 | hostname, 42 | hsl, 43 | isCakeDay, 44 | numToSI, 45 | poll, 46 | randomStr, 47 | removeAuthParam, 48 | sleep, 49 | validEmail, 50 | validInstanceTLD, 51 | validTitle, 52 | validURL, 53 | }; 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lemmy-UI 2 | 3 | The official web app for [Lemmy](https://github.com/LemmyNet/lemmy), written in inferno. 4 | 5 | Based off of MrFoxPro's [inferno-isomorphic-template](https://github.com/MrFoxPro/inferno-isomorphic-template). 6 | 7 | ## Configuration 8 | 9 | The following environment variables can be used to configure lemmy-ui: 10 | 11 | | `ENV_VAR` | type | default | description | 12 | | ------------------------------ | -------- | ---------------- | ----------------------------------------------------------------------------------- | 13 | | `LEMMY_UI_HOST` | `string` | `0.0.0.0:1234` | The IP / port that the lemmy-ui isomorphic node server is hosted at. | 14 | | `LEMMY_UI_LEMMY_INTERNAL_HOST` | `string` | `0.0.0.0:8536` | The internal IP / port that lemmy is hosted at. Often `lemmy:8536` if using docker. | 15 | | `LEMMY_UI_LEMMY_EXTERNAL_HOST` | `string` | `0.0.0.0:8536` | The external IP / port that lemmy is hosted at. Often `DOMAIN.TLD`. | 16 | | `LEMMY_UI_HTTPS` | `bool` | `false` | Whether to use https. | 17 | | `LEMMY_UI_EXTRA_THEMES_FOLDER` | `string` | `./extra_themes` | A location for additional lemmy css themes. | 18 | | `LEMMY_UI_DEBUG` | `bool` | `false` | Loads the [Eruda](https://github.com/liriliri/eruda) debugging utility. | 19 | | `LEMMY_UI_DISABLE_CSP` | `bool` | `false` | Disables CSP security headers | 20 | | `LEMMY_UI_CUSTOM_HTML_HEADER` | `string` | | Injects a custom script into ``. | 21 | -------------------------------------------------------------------------------- /src/shared/config.ts: -------------------------------------------------------------------------------- 1 | import { getStaticDir } from "@utils/env"; 2 | 3 | export const favIconUrl = `${getStaticDir()}/assets/icons/favicon.svg`; 4 | export const favIconPngUrl = `${getStaticDir()}/assets/icons/apple-touch-icon.png`; 5 | 6 | export const repoUrl = "https://github.com/LemmyNet"; 7 | export const joinLemmyUrl = "https://join-lemmy.org"; 8 | export const donateLemmyUrl = `${joinLemmyUrl}/donate`; 9 | export const docsUrl = `${joinLemmyUrl}/docs/en/index.html`; 10 | export const helpGuideUrl = `${joinLemmyUrl}/docs/en/users/01-getting-started.html`; // TODO find a way to redirect to the non-en folder 11 | export const markdownHelpUrl = `${joinLemmyUrl}/docs/en/users/02-media.html`; 12 | export const sortingHelpUrl = `${joinLemmyUrl}/docs/en/users/03-votes-and-ranking.html`; 13 | export const archiveTodayUrl = "https://archive.today"; 14 | export const ghostArchiveUrl = "https://ghostarchive.org"; 15 | export const webArchiveUrl = "https://web.archive.org"; 16 | export const elementUrl = "https://element.io"; 17 | 18 | export const postRefetchSeconds: number = 60 * 1000; 19 | export const trendingFetchLimit = 6; 20 | export const mentionDropdownFetchLimit = 10; 21 | export const commentTreeMaxDepth = 8; 22 | export const markdownFieldCharacterLimit = 50000; 23 | export const maxUploadImages = 20; 24 | export const concurrentImageUpload = 4; 25 | export const updateUnreadCountsInterval = 30000; 26 | export const fetchLimit = 20; 27 | export const relTags = "noopener nofollow"; 28 | export const emDash = "\u2014"; 29 | export const authCookieName = "jwt"; 30 | 31 | /** 32 | * Accepted formats: 33 | * !community@server.com 34 | * /c/community@server.com 35 | * /m/community@server.com 36 | * /u/username@server.com 37 | */ 38 | export const instanceLinkRegex = 39 | /(\/[cmu]\/|!)[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; 40 | 41 | export const testHost = "0.0.0.0:8536"; 42 | -------------------------------------------------------------------------------- /src/shared/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { ErrorPageData } from "@utils/types"; 2 | import { CommentView, GetSiteResponse } from "lemmy-js-client"; 3 | import type { ParsedQs } from "qs"; 4 | import { RequestState, WrappedLemmyHttp } from "./services/HttpService"; 5 | 6 | /** 7 | * This contains serialized data, it needs to be deserialized before use. 8 | */ 9 | export interface IsoData { 10 | path: string; 11 | routeData: T; 12 | site_res: GetSiteResponse; 13 | errorPageData?: ErrorPageData; 14 | } 15 | 16 | export type IsoDataOptionalSite = Partial< 17 | IsoData 18 | > & 19 | Pick, Exclude, "site_res">>; 20 | 21 | declare global { 22 | interface Window { 23 | isoData: IsoData; 24 | } 25 | } 26 | 27 | export interface InitialFetchRequest { 28 | auth?: string; 29 | client: WrappedLemmyHttp; 30 | path: string; 31 | query: T; 32 | site: GetSiteResponse; 33 | } 34 | 35 | export interface PostFormParams { 36 | name?: string; 37 | url?: string; 38 | body?: string; 39 | } 40 | 41 | export enum CommentViewType { 42 | Tree, 43 | Flat, 44 | } 45 | 46 | export enum DataType { 47 | Post, 48 | Comment, 49 | } 50 | 51 | export enum BanType { 52 | Community, 53 | Site, 54 | } 55 | 56 | export enum PersonDetailsView { 57 | Overview = "Overview", 58 | Comments = "Comments", 59 | Posts = "Posts", 60 | Saved = "Saved", 61 | } 62 | 63 | export enum PurgeType { 64 | Person, 65 | Community, 66 | Post, 67 | Comment, 68 | } 69 | 70 | export enum VoteType { 71 | Upvote, 72 | Downvote, 73 | } 74 | 75 | export enum VoteContentType { 76 | Post, 77 | Comment, 78 | } 79 | 80 | export interface CommentNodeI { 81 | comment_view: CommentView; 82 | children: Array; 83 | depth: number; 84 | } 85 | 86 | export type RouteData = Record>; 87 | -------------------------------------------------------------------------------- /src/shared/components/common/moment-time.tsx: -------------------------------------------------------------------------------- 1 | import { capitalizeFirstLetter, formatPastDate } from "@utils/helpers"; 2 | import { format } from "date-fns"; 3 | import parseISO from "date-fns/parseISO"; 4 | import { Component } from "inferno"; 5 | import { I18NextService } from "../../services"; 6 | import { Icon } from "./icon"; 7 | 8 | interface MomentTimeProps { 9 | published: string; 10 | updated?: string; 11 | showAgo?: boolean; 12 | ignoreUpdated?: boolean; 13 | } 14 | 15 | function formatDate(input: string) { 16 | const parsed = parseISO(input + "Z"); 17 | return format(parsed, "PPPPpppp"); 18 | } 19 | 20 | export class MomentTime extends Component { 21 | constructor(props: any, context: any) { 22 | super(props, context); 23 | } 24 | 25 | createdAndModifiedTimes() { 26 | const updated = this.props.updated; 27 | let line = `${capitalizeFirstLetter( 28 | I18NextService.i18n.t("created") 29 | )}: ${formatDate(this.props.published)}`; 30 | if (updated) { 31 | line += `\n\n\n${capitalizeFirstLetter( 32 | I18NextService.i18n.t("modified") 33 | )} ${formatDate(updated)}`; 34 | } 35 | return line; 36 | } 37 | 38 | render() { 39 | if (!this.props.ignoreUpdated && this.props.updated) { 40 | return ( 41 | 45 | 46 | {formatPastDate(this.props.updated)} 47 | 48 | ); 49 | } else { 50 | const published = this.props.published; 51 | return ( 52 | 56 | {formatPastDate(published)} 57 | 58 | ); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/shared/components/common/tabs.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { Component, InfernoNode, linkEvent } from "inferno"; 3 | 4 | interface TabItem { 5 | key: string; 6 | getNode: (isSelected: boolean) => InfernoNode; 7 | label: string; 8 | } 9 | 10 | interface TabsProps { 11 | tabs: TabItem[]; 12 | } 13 | 14 | interface TabsState { 15 | currentTab: string; 16 | } 17 | 18 | function handleSwitchTab({ ctx, tab }: { ctx: Tabs; tab: string }) { 19 | ctx.setState({ currentTab: tab }); 20 | } 21 | 22 | export default class Tabs extends Component { 23 | constructor(props: TabsProps, context) { 24 | super(props, context); 25 | 26 | this.state = { 27 | currentTab: props.tabs.length > 0 ? props.tabs[0].key : "", 28 | }; 29 | } 30 | 31 | render() { 32 | return ( 33 |
34 |
    35 | {this.props.tabs.map(({ key, label }) => ( 36 |
  • 37 | 53 |
  • 54 | ))} 55 |
56 |
57 | {this.props.tabs.map(({ key, getNode }) => { 58 | return getNode(this.state?.currentTab === key); 59 | })} 60 |
61 |
62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/server/middleware.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | import type { NextFunction, Request, Response } from "express"; 3 | import { hasJwtCookie } from "./utils/has-jwt-cookie"; 4 | 5 | export function setDefaultCsp({ 6 | res, 7 | next, 8 | }: { 9 | res: Response; 10 | next: NextFunction; 11 | }) { 12 | res.locals.cspNonce = crypto.randomBytes(16).toString("hex"); 13 | 14 | res.setHeader( 15 | "Content-Security-Policy", 16 | `default-src 'self'; 17 | manifest-src *; 18 | connect-src *; 19 | img-src * data:; 20 | script-src 'self' 'nonce-${res.locals.cspNonce}'; 21 | style-src 'self' 'unsafe-inline'; 22 | form-action 'self'; 23 | base-uri 'self'; 24 | frame-src *; 25 | media-src * data:`.replace(/\s+/g, " ") 26 | ); 27 | 28 | next(); 29 | } 30 | 31 | // Set cache-control headers. If user is logged in, set `private` to prevent storing data in 32 | // shared caches (eg nginx) and leaking of private data. If user is not logged in, allow caching 33 | // all responses for 5 seconds to reduce load on backend and database. The specific cache 34 | // interval is rather arbitrary and could be set higher (less server load) or lower (fresher data). 35 | // 36 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control 37 | export function setCacheControl( 38 | req: Request, 39 | res: Response, 40 | next: NextFunction 41 | ) { 42 | if (process.env.NODE_ENV !== "production") { 43 | return next(); 44 | } 45 | 46 | let caching: string; 47 | 48 | if ( 49 | req.path.match(/\.(js|css|txt|manifest\.webmanifest)\/?$/) || 50 | req.path.includes("/css/themelist") 51 | ) { 52 | // Static content gets cached publicly for a day 53 | caching = "public, max-age=86400"; 54 | } else { 55 | if (hasJwtCookie(req)) { 56 | caching = "private"; 57 | } else { 58 | caching = "public, max-age=5"; 59 | } 60 | } 61 | 62 | res.setHeader("Cache-Control", caching); 63 | 64 | next(); 65 | } 66 | -------------------------------------------------------------------------------- /src/server/index.tsx: -------------------------------------------------------------------------------- 1 | import { setupDateFns } from "@utils/app"; 2 | import { getStaticDir } from "@utils/env"; 3 | import express from "express"; 4 | import path from "path"; 5 | import process from "process"; 6 | import CatchAllHandler from "./handlers/catch-all-handler"; 7 | import ManifestHandler from "./handlers/manifest-handler"; 8 | import RobotsHandler from "./handlers/robots-handler"; 9 | import SecurityHandler from "./handlers/security-handler"; 10 | import ServiceWorkerHandler from "./handlers/service-worker-handler"; 11 | import ThemeHandler from "./handlers/theme-handler"; 12 | import ThemesListHandler from "./handlers/themes-list-handler"; 13 | import { setCacheControl, setDefaultCsp } from "./middleware"; 14 | 15 | const server = express(); 16 | 17 | const [hostname, port] = process.env["LEMMY_UI_HOST"] 18 | ? process.env["LEMMY_UI_HOST"].split(":") 19 | : ["0.0.0.0", "1234"]; 20 | 21 | server.use(express.json()); 22 | server.use(express.urlencoded({ extended: false })); 23 | server.use( 24 | getStaticDir(), 25 | express.static(path.resolve("./dist"), { 26 | maxAge: 24 * 60 * 60 * 1000, // 1 day 27 | immutable: true, 28 | }) 29 | ); 30 | server.use(setCacheControl); 31 | 32 | if ( 33 | !process.env["LEMMY_UI_DISABLE_CSP"] && 34 | !process.env["LEMMY_UI_DEBUG"] && 35 | process.env["NODE_ENV"] !== "development" 36 | ) { 37 | server.use(setDefaultCsp); 38 | } 39 | 40 | server.get("/.well-known/security.txt", SecurityHandler); 41 | server.get("/robots.txt", RobotsHandler); 42 | server.get("/service-worker.js", ServiceWorkerHandler); 43 | server.get("/manifest.webmanifest", ManifestHandler); 44 | server.get("/css/themes/:name", ThemeHandler); 45 | server.get("/css/themelist", ThemesListHandler); 46 | server.get("/*", CatchAllHandler); 47 | 48 | server.listen(Number(port), hostname, () => { 49 | setupDateFns(); 50 | console.log(`http://${hostname}:${port}`); 51 | }); 52 | 53 | process.on("SIGINT", () => { 54 | console.info("Interrupted"); 55 | process.exit(0); 56 | }); 57 | -------------------------------------------------------------------------------- /src/shared/components/common/html-tags.tsx: -------------------------------------------------------------------------------- 1 | import { httpExternalPath } from "@utils/env"; 2 | import { htmlToText } from "html-to-text"; 3 | import { Component } from "inferno"; 4 | import { Helmet } from "inferno-helmet"; 5 | import { md } from "../../markdown"; 6 | import { I18NextService } from "../../services"; 7 | 8 | interface HtmlTagsProps { 9 | title: string; 10 | path: string; 11 | canonicalPath?: string; 12 | description?: string; 13 | image?: string; 14 | } 15 | 16 | /// Taken from https://metatags.io/ 17 | export class HtmlTags extends Component { 18 | render() { 19 | const url = httpExternalPath(this.props.path); 20 | const canonicalUrl = 21 | this.props.canonicalPath ?? httpExternalPath(this.props.path); 22 | const desc = this.props.description; 23 | const image = this.props.image; 24 | 25 | return ( 26 | 27 | 28 | 29 | {["title", "og:title", "twitter:title"].map(t => ( 30 | 31 | ))} 32 | {["og:url", "twitter:url"].map(u => ( 33 | 34 | ))} 35 | 36 | 37 | 38 | {/* Open Graph / Facebook */} 39 | 40 | 41 | {/* Twitter */} 42 | 43 | 44 | {/* Optional desc and images */} 45 | {["description", "og:description", "twitter:description"].map( 46 | n => 47 | desc && ( 48 | 53 | ) 54 | )} 55 | {["og:image", "twitter:image"].map( 56 | p => image && 57 | )} 58 | 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 22 | 23 | 24 | 29 | 30 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/assets/icons/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 22 | 23 | 24 | 29 | 30 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/shared/components/post/metadata-card.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { Post } from "lemmy-js-client"; 3 | import * as sanitizeHtml from "sanitize-html"; 4 | import { relTags } from "../../config"; 5 | import { Icon } from "../common/icon"; 6 | 7 | interface MetadataCardProps { 8 | post: Post; 9 | } 10 | 11 | export class MetadataCard extends Component { 12 | constructor(props: any, context: any) { 13 | super(props, context); 14 | } 15 | 16 | render() { 17 | const post = this.props.post; 18 | 19 | if (post.embed_title && post.url) { 20 | return ( 21 |
22 |
23 |
24 |
25 | {post.name !== post.embed_title && ( 26 | <> 27 |
28 | 29 | {post.embed_title} 30 | 31 |
32 | 33 | 38 | {new URL(post.url).hostname} 39 | 40 | 41 | 42 | 43 | )} 44 | {post.embed_description && ( 45 |
51 | )} 52 |
53 |
54 |
55 |
56 | ); 57 | } else { 58 | return <>; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/shared/components/person/verify-email.tsx: -------------------------------------------------------------------------------- 1 | import { setIsoData } from "@utils/app"; 2 | import { Component } from "inferno"; 3 | import { GetSiteResponse, VerifyEmailResponse } from "lemmy-js-client"; 4 | import { I18NextService } from "../../services"; 5 | import { HttpService, RequestState } from "../../services/HttpService"; 6 | import { toast } from "../../toast"; 7 | import { HtmlTags } from "../common/html-tags"; 8 | import { Spinner } from "../common/icon"; 9 | 10 | interface State { 11 | verifyRes: RequestState; 12 | siteRes: GetSiteResponse; 13 | } 14 | 15 | export class VerifyEmail extends Component { 16 | private isoData = setIsoData(this.context); 17 | 18 | state: State = { 19 | verifyRes: { state: "empty" }, 20 | siteRes: this.isoData.site_res, 21 | }; 22 | 23 | constructor(props: any, context: any) { 24 | super(props, context); 25 | } 26 | 27 | async verify() { 28 | this.setState({ 29 | verifyRes: { state: "loading" }, 30 | }); 31 | 32 | this.setState({ 33 | verifyRes: await HttpService.client.verifyEmail({ 34 | token: this.props.match.params.token, 35 | }), 36 | }); 37 | 38 | if (this.state.verifyRes.state == "success") { 39 | toast(I18NextService.i18n.t("email_verified")); 40 | this.props.history.push("/login"); 41 | } 42 | } 43 | 44 | async componentDidMount() { 45 | await this.verify(); 46 | } 47 | 48 | get documentTitle(): string { 49 | return `${I18NextService.i18n.t("verify_email")} - ${ 50 | this.state.siteRes.site_view.site.name 51 | }`; 52 | } 53 | 54 | render() { 55 | return ( 56 |
57 | 61 |
62 |
63 |

{I18NextService.i18n.t("verify_email")}

64 | {this.state.verifyRes.state == "loading" && ( 65 |
66 | 67 |
68 | )} 69 |
70 |
71 |
72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/shared/components/common/comment-sort-select.tsx: -------------------------------------------------------------------------------- 1 | import { randomStr } from "@utils/helpers"; 2 | import { Component, linkEvent } from "inferno"; 3 | import { CommentSortType } from "lemmy-js-client"; 4 | import { relTags, sortingHelpUrl } from "../../config"; 5 | import { I18NextService } from "../../services"; 6 | import { Icon } from "./icon"; 7 | 8 | interface CommentSortSelectProps { 9 | sort: CommentSortType; 10 | onChange?(val: CommentSortType): any; 11 | } 12 | 13 | interface CommentSortSelectState { 14 | sort: CommentSortType; 15 | } 16 | 17 | export class CommentSortSelect extends Component< 18 | CommentSortSelectProps, 19 | CommentSortSelectState 20 | > { 21 | private id = `sort-select-${randomStr()}`; 22 | state: CommentSortSelectState = { 23 | sort: this.props.sort, 24 | }; 25 | 26 | constructor(props: any, context: any) { 27 | super(props, context); 28 | } 29 | 30 | static getDerivedStateFromProps(props: any): CommentSortSelectState { 31 | return { 32 | sort: props.sort, 33 | }; 34 | } 35 | 36 | render() { 37 | return ( 38 | <> 39 | 55 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | handleSortChange(i: CommentSortSelect, event: any) { 68 | i.props.onChange?.(event.target.value); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/shared/services/UserService.ts: -------------------------------------------------------------------------------- 1 | // import Cookies from 'js-cookie'; 2 | import { isAuthPath } from "@utils/app"; 3 | import { clearAuthCookie, isBrowser, setAuthCookie } from "@utils/browser"; 4 | import * as cookie from "cookie"; 5 | import jwt_decode from "jwt-decode"; 6 | import { LoginResponse, MyUserInfo } from "lemmy-js-client"; 7 | import { toast } from "../toast"; 8 | import { I18NextService } from "./I18NextService"; 9 | 10 | interface Claims { 11 | sub: string; 12 | iss: string; 13 | iat: number; 14 | } 15 | 16 | interface JwtInfo { 17 | claims: Claims; 18 | jwt: string; 19 | } 20 | 21 | export class UserService { 22 | static #instance: UserService; 23 | public myUserInfo?: MyUserInfo; 24 | public jwtInfo?: JwtInfo; 25 | 26 | private constructor() { 27 | this.#setJwtInfo(); 28 | } 29 | 30 | public login({ 31 | res, 32 | showToast = true, 33 | }: { 34 | res: LoginResponse; 35 | showToast?: boolean; 36 | }) { 37 | const expires = new Date(); 38 | expires.setDate(expires.getDate() + 365); 39 | 40 | if (isBrowser() && res.jwt) { 41 | showToast && toast(I18NextService.i18n.t("logged_in")); 42 | setAuthCookie(res.jwt); 43 | this.#setJwtInfo(); 44 | } 45 | } 46 | 47 | public logout() { 48 | this.jwtInfo = undefined; 49 | this.myUserInfo = undefined; 50 | 51 | if (isBrowser()) { 52 | clearAuthCookie(); 53 | } 54 | 55 | if (isAuthPath(location.pathname)) { 56 | location.replace("/"); 57 | } else { 58 | location.reload(); 59 | } 60 | } 61 | 62 | public auth(throwErr = false): string | undefined { 63 | const jwt = this.jwtInfo?.jwt; 64 | 65 | if (jwt) { 66 | return jwt; 67 | } else { 68 | const msg = "No JWT cookie found"; 69 | 70 | if (throwErr && isBrowser()) { 71 | console.error(msg); 72 | toast(I18NextService.i18n.t("not_logged_in"), "danger"); 73 | } 74 | 75 | return undefined; 76 | // throw msg; 77 | } 78 | } 79 | 80 | #setJwtInfo() { 81 | if (isBrowser()) { 82 | const { jwt } = cookie.parse(document.cookie); 83 | 84 | if (jwt) { 85 | this.jwtInfo = { jwt, claims: jwt_decode(jwt) }; 86 | } 87 | } 88 | } 89 | 90 | public static get Instance() { 91 | return this.#instance || (this.#instance = new this()); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/shared/components/community/community-link.tsx: -------------------------------------------------------------------------------- 1 | import { showAvatars } from "@utils/app"; 2 | import { hostname } from "@utils/helpers"; 3 | import { Component } from "inferno"; 4 | import { Link } from "inferno-router"; 5 | import { Community } from "lemmy-js-client"; 6 | import { relTags } from "../../config"; 7 | import { PictrsImage } from "../common/pictrs-image"; 8 | 9 | interface CommunityLinkProps { 10 | community: Community; 11 | realLink?: boolean; 12 | useApubName?: boolean; 13 | muted?: boolean; 14 | hideAvatar?: boolean; 15 | } 16 | 17 | export class CommunityLink extends Component { 18 | constructor(props: any, context: any) { 19 | super(props, context); 20 | } 21 | 22 | render() { 23 | const community = this.props.community; 24 | let name_: string, title: string, link: string; 25 | const local = community.local == null ? true : community.local; 26 | if (local) { 27 | name_ = community.name; 28 | title = community.title; 29 | link = `/c/${community.name}`; 30 | } else { 31 | const domain = hostname(community.actor_id); 32 | name_ = `${community.name}@${domain}`; 33 | title = `${community.title}@${domain}`; 34 | link = !this.props.realLink ? `/c/${name_}` : community.actor_id; 35 | } 36 | 37 | const apubName = `!${name_}`; 38 | const displayName = this.props.useApubName ? apubName : title; 39 | return !this.props.realLink ? ( 40 | 45 | {this.avatarAndName(displayName)} 46 | 47 | ) : ( 48 | 54 | {this.avatarAndName(displayName)} 55 | 56 | ); 57 | } 58 | 59 | avatarAndName(displayName: string) { 60 | const icon = this.props.community.icon; 61 | return ( 62 | <> 63 | {!this.props.hideAvatar && 64 | !this.props.community.removed && 65 | showAvatars() && 66 | icon && } 67 | {displayName} 68 | 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/shared/components/app/theme.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { Helmet } from "inferno-helmet"; 3 | import { UserService } from "../../services"; 4 | 5 | interface Props { 6 | defaultTheme: string; 7 | } 8 | 9 | export class Theme extends Component { 10 | render() { 11 | const user = UserService.Instance.myUserInfo; 12 | const hasTheme = user?.local_user_view.local_user.theme !== "browser"; 13 | 14 | if (user && hasTheme) { 15 | return ( 16 | 17 | 22 | 23 | ); 24 | } else if ( 25 | this.props.defaultTheme != "browser" && 26 | this.props.defaultTheme != "browser-compact" 27 | ) { 28 | return ( 29 | 30 | 35 | 36 | ); 37 | } else if (this.props.defaultTheme == "browser-compact") { 38 | return ( 39 | 40 | 47 | 54 | 55 | ); 56 | } else { 57 | return ( 58 | 59 | 66 | 73 | 74 | ); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/shared/components/common/data-type-select.tsx: -------------------------------------------------------------------------------- 1 | import { randomStr } from "@utils/helpers"; 2 | import classNames from "classnames"; 3 | import { Component, linkEvent } from "inferno"; 4 | import { DataType } from "../../interfaces"; 5 | import { I18NextService } from "../../services"; 6 | 7 | interface DataTypeSelectProps { 8 | type_: DataType; 9 | onChange?(val: DataType): any; 10 | } 11 | 12 | interface DataTypeSelectState { 13 | type_: DataType; 14 | } 15 | 16 | export class DataTypeSelect extends Component< 17 | DataTypeSelectProps, 18 | DataTypeSelectState 19 | > { 20 | private id = `listing-type-input-${randomStr()}`; 21 | 22 | state: DataTypeSelectState = { 23 | type_: this.props.type_, 24 | }; 25 | 26 | constructor(props: any, context: any) { 27 | super(props, context); 28 | } 29 | 30 | static getDerivedStateFromProps(props: any): DataTypeSelectProps { 31 | return { 32 | type_: props.type_, 33 | }; 34 | } 35 | 36 | render() { 37 | return ( 38 |
42 | 50 | 58 | 59 | 67 | 75 |
76 | ); 77 | } 78 | 79 | handleTypeChange(i: DataTypeSelect, event: any) { 80 | i.props.onChange?.(Number(event.target.value)); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/shared/components/community/create-community.tsx: -------------------------------------------------------------------------------- 1 | import { enableNsfw, setIsoData } from "@utils/app"; 2 | import { Component } from "inferno"; 3 | import { 4 | CreateCommunity as CreateCommunityI, 5 | GetSiteResponse, 6 | } from "lemmy-js-client"; 7 | import { HttpService, I18NextService } from "../../services"; 8 | import { HtmlTags } from "../common/html-tags"; 9 | import { CommunityForm } from "./community-form"; 10 | 11 | interface CreateCommunityState { 12 | siteRes: GetSiteResponse; 13 | loading: boolean; 14 | } 15 | 16 | export class CreateCommunity extends Component { 17 | private isoData = setIsoData(this.context); 18 | state: CreateCommunityState = { 19 | siteRes: this.isoData.site_res, 20 | loading: false, 21 | }; 22 | constructor(props: any, context: any) { 23 | super(props, context); 24 | this.handleCommunityCreate = this.handleCommunityCreate.bind(this); 25 | } 26 | 27 | get documentTitle(): string { 28 | return `${I18NextService.i18n.t("create_community")} - ${ 29 | this.state.siteRes.site_view.site.name 30 | }`; 31 | } 32 | 33 | render() { 34 | return ( 35 |
36 | 40 |
41 |
42 |

43 | {I18NextService.i18n.t("create_community")} 44 |

45 | 53 |
54 |
55 |
56 | ); 57 | } 58 | 59 | async handleCommunityCreate(form: CreateCommunityI) { 60 | this.setState({ loading: true }); 61 | 62 | const res = await HttpService.client.createCommunity(form); 63 | 64 | if (res.state === "success") { 65 | const name = res.data.community_view.community.name; 66 | this.props.history.replace(`/c/${name}`); 67 | } else { 68 | this.setState({ loading: false }); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41E Bug report" 2 | description: Report a bug to help us improve Lemmy-UI. 3 | labels: ["bug", "triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to help improve Lemmy-UI by reporting a bug! 9 | - type: checkboxes 10 | attributes: 11 | label: Requirements 12 | description: Before you create a bug report, please carefully check the following – 13 | options: 14 | - label: This is a bug report, and if not, please post to https://lemmy.ml/c/lemmy_support instead. 15 | required: true 16 | - label: Please [check](https://github.com/LemmyNet/lemmy-ui/issues) to see if this issue already exists. 17 | required: true 18 | - label: It's a single bug. Do not report multiple bugs in one issue. 19 | required: true 20 | - label: It's a frontend issue, not a backend issue; Otherwise please create an issue on the [backend repo](https://github.com/LemmyNet/lemmy) instead. 21 | required: true 22 | - type: textarea 23 | id: summary 24 | attributes: 25 | label: Summary 26 | description: Explain the bug and upload images, screenshots or videos if possible. 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: reproduce 31 | attributes: 32 | label: Steps to Reproduce 33 | description: | 34 | In a numbered list, walk us through the steps needed to reproduce the bug. 35 | The better your description is _(go 'here', click 'there'...)_, the quicker we can fix it. 36 | value: | 37 | 1. 38 | 2. 39 | 3. 40 | 4. 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: technical 45 | attributes: 46 | label: Technical Details 47 | description: | 48 | Describe your environment (OS, browser, model of smartphone, etc) 49 | If relevant, also share any console errors and/or screenshots here. 50 | validations: 51 | required: true 52 | - type: input 53 | id: lemmy-ui-version 54 | attributes: 55 | label: Lemmy Instance Version 56 | description: What's the version of the Lemmy instance where the bug can be reproduced? 57 | placeholder: ex. 0.18-rc.6 58 | validations: 59 | required: true 60 | - type: input 61 | id: lemmy-instance 62 | attributes: 63 | label: Lemmy Instance URL 64 | description: What's the URL of the Lemmy instance where the bug can be reproduced? 65 | placeholder: https://lemmy.ml 66 | -------------------------------------------------------------------------------- /src/shared/components/app/error-page.tsx: -------------------------------------------------------------------------------- 1 | import { setIsoData } from "@utils/app"; 2 | import { removeAuthParam } from "@utils/helpers"; 3 | import { Component } from "inferno"; 4 | import { T } from "inferno-i18next-dess"; 5 | import { Link } from "inferno-router"; 6 | import { IsoDataOptionalSite } from "../../interfaces"; 7 | import { I18NextService } from "../../services"; 8 | 9 | export class ErrorPage extends Component { 10 | private isoData: IsoDataOptionalSite = setIsoData(this.context); 11 | 12 | constructor(props: any, context: any) { 13 | super(props, context); 14 | } 15 | 16 | render() { 17 | const { errorPageData } = this.isoData; 18 | 19 | return ( 20 |
21 |

22 | {errorPageData 23 | ? I18NextService.i18n.t("error_page_title") 24 | : I18NextService.i18n.t("not_found_page_title")} 25 |

26 | {errorPageData ? ( 27 | 28 | ### 29 | ## 30 | 31 | ) : ( 32 |

{I18NextService.i18n.t("not_found_page_message")}

33 | )} 34 | {!errorPageData && ( 35 | 36 | {I18NextService.i18n.t("not_found_return_home_button")} 37 | 38 | )} 39 | {errorPageData?.adminMatrixIds && 40 | errorPageData.adminMatrixIds.length > 0 && ( 41 | <> 42 |
43 | {I18NextService.i18n.t("error_page_admin_matrix", { 44 | instance: 45 | this.isoData.site_res?.site_view.site.name ?? 46 | "this instance", 47 | })} 48 |
49 |
    50 | {errorPageData.adminMatrixIds.map(matrixId => ( 51 |
  • 52 | {matrixId} 53 |
  • 54 | ))} 55 |
56 | 57 | )} 58 | {errorPageData?.error && ( 59 | 64 | ### 65 | 66 | )} 67 |
68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/shared/components/app/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | import { NavLink } from "inferno-router"; 3 | import { GetSiteResponse } from "lemmy-js-client"; 4 | import { docsUrl, joinLemmyUrl, repoUrl } from "../../config"; 5 | import { I18NextService } from "../../services"; 6 | import { VERSION } from "../../version"; 7 | 8 | interface FooterProps { 9 | site?: GetSiteResponse; 10 | } 11 | 12 | export class Footer extends Component { 13 | constructor(props: any, context: any) { 14 | super(props, context); 15 | } 16 | 17 | render() { 18 | return ( 19 |
20 |
21 |
    22 | {this.props.site?.version !== VERSION && ( 23 |
  • 24 | UI: {VERSION} 25 |
  • 26 | )} 27 |
  • 28 | BE: {this.props.site?.version} 29 |
  • 30 |
  • 31 | 32 | {I18NextService.i18n.t("modlog")} 33 | 34 |
  • 35 | {this.props.site?.site_view.local_site.legal_information && ( 36 |
  • 37 | 38 | {I18NextService.i18n.t("legal_information")} 39 | 40 |
  • 41 | )} 42 | {this.props.site?.site_view.local_site.federation_enabled && ( 43 |
  • 44 | 45 | {I18NextService.i18n.t("instances")} 46 | 47 |
  • 48 | )} 49 |
  • 50 | 51 | {I18NextService.i18n.t("docs")} 52 | 53 |
  • 54 |
  • 55 | 56 | {I18NextService.i18n.t("code")} 57 | 58 |
  • 59 |
  • 60 | 61 | {new URL(joinLemmyUrl).hostname} 62 | 63 |
  • 64 |
65 |
66 |
67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/shared/components/common/pictrs-image.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { Component } from "inferno"; 3 | 4 | const iconThumbnailSize = 96; 5 | const thumbnailSize = 256; 6 | 7 | interface PictrsImageProps { 8 | src: string; 9 | alt?: string; 10 | icon?: boolean; 11 | banner?: boolean; 12 | thumbnail?: boolean; 13 | nsfw?: boolean; 14 | iconOverlay?: boolean; 15 | pushup?: boolean; 16 | } 17 | 18 | export class PictrsImage extends Component { 19 | constructor(props: any, context: any) { 20 | super(props, context); 21 | } 22 | 23 | render() { 24 | return ( 25 | 26 | 27 | 28 | 29 | {this.alt()} 48 | 49 | ); 50 | } 51 | 52 | src(format: string): string { 53 | // sample url: 54 | // http://localhost:8535/pictrs/image/file.png?thumbnail=256&format=jpg 55 | 56 | const split = this.props.src.split("/pictrs/image/"); 57 | 58 | // If theres not multiple, then its not a pictrs image 59 | if (split.length == 1) { 60 | return this.props.src; 61 | } 62 | 63 | const host = split[0]; 64 | const path = split[1]; 65 | 66 | const params = { format }; 67 | 68 | if (this.props.thumbnail) { 69 | params["thumbnail"] = thumbnailSize; 70 | } else if (this.props.icon) { 71 | params["thumbnail"] = iconThumbnailSize; 72 | } 73 | 74 | const paramsStr = new URLSearchParams(params).toString(); 75 | const out = `${host}/pictrs/image/${path}?${paramsStr}`; 76 | 77 | return out; 78 | } 79 | 80 | alt(): string { 81 | if (this.props.icon) { 82 | return ""; 83 | } 84 | return this.props.alt || ""; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/shared/components/common/emoji-picker.tsx: -------------------------------------------------------------------------------- 1 | import { Component, linkEvent } from "inferno"; 2 | import { I18NextService } from "../../services"; 3 | import { EmojiMart } from "./emoji-mart"; 4 | import { Icon } from "./icon"; 5 | 6 | interface EmojiPickerProps { 7 | onEmojiClick?(val: any): any; 8 | disabled?: boolean; 9 | } 10 | 11 | interface EmojiPickerState { 12 | showPicker: boolean; 13 | } 14 | 15 | function closeEmojiMartOnEsc(i, event): void { 16 | event.key === "Escape" && i.setState({ showPicker: false }); 17 | } 18 | 19 | export class EmojiPicker extends Component { 20 | private emptyState: EmojiPickerState = { 21 | showPicker: false, 22 | }; 23 | 24 | state: EmojiPickerState; 25 | constructor(props: EmojiPickerProps, context: any) { 26 | super(props, context); 27 | this.state = this.emptyState; 28 | this.handleEmojiClick = this.handleEmojiClick.bind(this); 29 | } 30 | 31 | render() { 32 | return ( 33 | 34 | 43 | 44 | {this.state.showPicker && ( 45 | <> 46 |
47 |
48 | 52 |
53 | {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} 54 |
58 |
59 | 60 | )} 61 | 62 | ); 63 | } 64 | 65 | componentWillUnmount() { 66 | document.removeEventListener("keyup", e => closeEmojiMartOnEsc(this, e)); 67 | } 68 | 69 | togglePicker(i: EmojiPicker, e: any) { 70 | e.preventDefault(); 71 | i.setState({ showPicker: !i.state.showPicker }); 72 | 73 | i.state.showPicker 74 | ? document.addEventListener("keyup", e => closeEmojiMartOnEsc(i, e)) 75 | : document.removeEventListener("keyup", e => closeEmojiMartOnEsc(i, e)); 76 | } 77 | 78 | handleEmojiClick(e: any) { 79 | this.props.onEmojiClick?.(e); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/server/utils/generate-manifest-json.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import { GetSiteResponse } from "lemmy-js-client"; 3 | import path from "path"; 4 | import sharp from "sharp"; 5 | import { fetchIconPng } from "./fetch-icon-png"; 6 | 7 | const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512]; 8 | 9 | const defaultLogoPathDirectory = path.join( 10 | process.cwd(), 11 | "dist", 12 | "assets", 13 | "icons" 14 | ); 15 | 16 | export default async function ({ 17 | my_user, 18 | site_view: { 19 | site, 20 | local_site: { community_creation_admin_only }, 21 | }, 22 | }: GetSiteResponse) { 23 | const icon = site.icon ? await fetchIconPng(site.icon) : null; 24 | 25 | return { 26 | name: site.name, 27 | description: site.description ?? "A link aggregator for the fediverse", 28 | start_url: "/", 29 | scope: "/", 30 | display: "standalone", 31 | id: "/", 32 | background_color: "#222222", 33 | theme_color: "#222222", 34 | icons: await Promise.all( 35 | iconSizes.map(async size => { 36 | let src = await readFile( 37 | path.join(defaultLogoPathDirectory, `icon-${size}x${size}.png`) 38 | ).then(buf => buf.toString("base64")); 39 | 40 | if (icon) { 41 | src = await sharp(icon) 42 | .resize(size, size) 43 | .png() 44 | .toBuffer() 45 | .then(buf => buf.toString("base64")); 46 | } 47 | 48 | return { 49 | sizes: `${size}x${size}`, 50 | type: "image/png", 51 | src: `data:image/png;base64,${src}`, 52 | purpose: "any maskable", 53 | }; 54 | }) 55 | ), 56 | shortcuts: [ 57 | { 58 | name: "Search", 59 | short_name: "Search", 60 | description: "Perform a search.", 61 | url: "/search", 62 | }, 63 | { 64 | name: "Communities", 65 | url: "/communities", 66 | short_name: "Communities", 67 | description: "Browse communities", 68 | }, 69 | { 70 | name: "Create Post", 71 | url: "/create_post", 72 | short_name: "Create Post", 73 | description: "Create a post.", 74 | }, 75 | ].concat( 76 | my_user?.local_user_view.person.admin || !community_creation_admin_only 77 | ? [ 78 | { 79 | name: "Create Community", 80 | url: "/create_community", 81 | short_name: "Create Community", 82 | description: "Create a community", 83 | }, 84 | ] 85 | : [] 86 | ), 87 | related_applications: [ 88 | { 89 | platform: "f-droid", 90 | url: "https://f-droid.org/packages/com.jerboa/", 91 | id: "com.jerboa", 92 | }, 93 | ], 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /src/assets/privacy.txt: -------------------------------------------------------------------------------- 1 | Wepi.social is a social network that aims to provide a secure and enjoyable experience for its users. In order to ensure that our platform is safe and secure, we require all users to go through a Know Your Customer (KYC) process using their Pi Network account. This Private Policy explains how we collect, use, disclose and protect your personal information when you use our platform. 2 | What information do we collect? 3 | - Personal information: We collect your name, Pi Username, Pi Wallet Address and Email Address when you sign up for a Wepi.social account, definitely not include a 24 characters paspharase. 4 | - KYC information: As part of the KYC process, just include your Pi Username and Pi Wallet Address, definitely not include a 24 characters paspharase. 5 | - Usage information: We collect information about how you use our platform, such as the pages you visit, the content you interact with, and the time you spend on the platform. 6 | How do we use your information? 7 | - To provide and improve our services: We use your personal information to provide you with access to our platform, as well as to improve and customize your experience on Wepi.social. 8 | - For KYC purposes: We use your Pi KYC information to verify your identity and ensure the security of our platform. 9 | - For security purposes: We use your information to detect and prevent fraud and abuse, and to ensure the security of our platform. 10 | Do we share your information with third parties? 11 | - Law enforcement: We may disclose your information to law enforcement agencies if we believe it is necessary to comply with the law, or to protect the safety and security of our platform. 12 | How do we protect your information? 13 | - Encryption: We use encryption to protect your information when it is transmitted over the internet. 14 | - Access controls: We have implemented access controls to prevent unauthorized access to your information. 15 | - Security audits: We regularly conduct security audits to identify and address potential vulnerabilities. 16 | Your rights: 17 | - Access to your information: You have the right to request access to your personal information that you provided for us. 18 | - Correction of your information: You have the right to request correction of any incorrect information  that you provided for us, except Pi Username and Pi Wallet Address information. 19 | - Deletion of your information: You have the right to request that we delete your personal information from our platform, except Pi Username and Pi Wallet Address information. 20 | - Objection to processing: You have the right to object to our processing of your personal information for certain purposes. 21 | Changes to this Policy: We may update this Privacy Policy from time to time to reflect changes to our practices or for other operational, legal, or regulatory reasons. If we make any material changes to this policy, we will notify you through our platform or by email. 22 | Contact Us: If you have any questions or concerns about this Privacy Policy, please contact us at support@wepi.social. 23 | 24 | -------------------------------------------------------------------------------- /src/shared/services/HttpService.ts: -------------------------------------------------------------------------------- 1 | import { getHttpBase } from "@utils/env"; 2 | import { LemmyHttp } from "lemmy-js-client"; 3 | import { toast } from "../toast"; 4 | import { I18NextService } from "./I18NextService"; 5 | 6 | export type EmptyRequestState = { 7 | state: "empty"; 8 | }; 9 | 10 | type LoadingRequestState = { 11 | state: "loading"; 12 | }; 13 | 14 | export type FailedRequestState = { 15 | state: "failed"; 16 | msg: string; 17 | }; 18 | 19 | type SuccessRequestState = { 20 | state: "success"; 21 | data: T; 22 | }; 23 | 24 | /** 25 | * Shows the state of an API request. 26 | * 27 | * Can be empty, loading, failed, or success 28 | */ 29 | export type RequestState = 30 | | EmptyRequestState 31 | | LoadingRequestState 32 | | FailedRequestState 33 | | SuccessRequestState; 34 | 35 | export type WrappedLemmyHttp = { 36 | [K in keyof LemmyHttp]: LemmyHttp[K] extends (...args: any[]) => any 37 | ? ReturnType extends Promise 38 | ? (...args: Parameters) => Promise> 39 | : ( 40 | ...args: Parameters 41 | ) => Promise> 42 | : LemmyHttp[K]; 43 | }; 44 | 45 | class WrappedLemmyHttpClient { 46 | #client: LemmyHttp; 47 | 48 | constructor(client: LemmyHttp, silent = false) { 49 | this.#client = client; 50 | 51 | for (const key of Object.getOwnPropertyNames( 52 | Object.getPrototypeOf(this.#client) 53 | )) { 54 | if (key !== "constructor") { 55 | WrappedLemmyHttpClient.prototype[key] = async (...args) => { 56 | try { 57 | const res = await this.#client[key](...args); 58 | 59 | return { 60 | data: res, 61 | state: !(res === undefined || res === null) ? "success" : "empty", 62 | }; 63 | } catch (error) { 64 | if (!silent) { 65 | console.error(`API error: ${error}`); 66 | toast(I18NextService.i18n.t(error), "danger"); 67 | } 68 | return { 69 | state: "failed", 70 | msg: error, 71 | }; 72 | } 73 | }; 74 | } 75 | } 76 | } 77 | } 78 | 79 | export function wrapClient(client: LemmyHttp, silent = false) { 80 | // unfortunately, this verbose cast is necessary 81 | return new WrappedLemmyHttpClient( 82 | client, 83 | silent 84 | ) as unknown as WrappedLemmyHttp; 85 | } 86 | 87 | export class HttpService { 88 | static #_instance: HttpService; 89 | #silent_client: WrappedLemmyHttp; 90 | #client: WrappedLemmyHttp; 91 | 92 | private constructor() { 93 | const lemmyHttp = new LemmyHttp(getHttpBase()); 94 | this.#client = wrapClient(lemmyHttp); 95 | this.#silent_client = wrapClient(lemmyHttp, true); 96 | } 97 | 98 | static get #Instance() { 99 | return this.#_instance ?? (this.#_instance = new this()); 100 | } 101 | 102 | public static get client() { 103 | return this.#Instance.#client; 104 | } 105 | 106 | public static get silent_client() { 107 | return this.#Instance.#silent_client; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /generate_translations.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | const translationDir = "lemmy-translations/translations/"; 4 | const outDir = "src/shared/translations/"; 5 | fs.mkdirSync(outDir, { recursive: true }); 6 | fs.readdir(translationDir, (_err, files) => { 7 | files.forEach(filename => { 8 | const lang = filename.split(".")[0]; 9 | try { 10 | const json = JSON.parse( 11 | fs.readFileSync(translationDir + filename, "utf8") 12 | ); 13 | let data = `export const ${lang} = {\n translation: {`; 14 | for (const key in json) { 15 | if (key in json) { 16 | const value = json[key].replace(/"/g, '\\"'); 17 | data += `\n ${key}: "${value}",`; 18 | } 19 | } 20 | data += "\n },\n};"; 21 | const target = outDir + lang + ".ts"; 22 | fs.writeFileSync(target, data); 23 | } catch (err) { 24 | console.error(err); 25 | } 26 | }); 27 | }); 28 | 29 | // generate types for i18n keys 30 | const baseLanguage = "en"; 31 | 32 | fs.readFile(`${translationDir}${baseLanguage}.json`, "utf8", (_, fileStr) => { 33 | const noOptionKeys = []; 34 | const optionKeys = []; 35 | const optionRegex = /\{\{(.+?)\}\}/g; 36 | const optionMap = new Map(); 37 | 38 | for (const [key, val] of Object.entries(JSON.parse(fileStr))) { 39 | const options = []; 40 | for ( 41 | let match = optionRegex.exec(val); 42 | match; 43 | match = optionRegex.exec(val) 44 | ) { 45 | options.push(match[1]); 46 | } 47 | 48 | if (options.length > 0) { 49 | optionMap.set(key, options); 50 | optionKeys.push(key); 51 | } else { 52 | noOptionKeys.push(key); 53 | } 54 | } 55 | 56 | const indent = " "; 57 | 58 | const data = `import { i18n } from "i18next"; 59 | 60 | declare module "i18next" { 61 | export type NoOptionI18nKeys = 62 | ${noOptionKeys.map(key => `${indent}| "${key}"`).join("\n")}; 63 | 64 | export type OptionI18nKeys = 65 | ${optionKeys.map(key => `${indent}| "${key}"`).join("\n")}; 66 | 67 | export type I18nKeys = NoOptionI18nKeys | OptionI18nKeys; 68 | 69 | export type TTypedOptions =${Array.from( 70 | optionMap.entries() 71 | ).reduce( 72 | (acc, [key, options]) => 73 | `${acc} TKey extends \"${key}\" ? ${ 74 | options.reduce((acc, cur) => acc + `${cur}: string | number; `, "{ ") + 75 | "}" 76 | } :\n${indent}`, 77 | "" 78 | )} (Record | string); 79 | 80 | export interface TFunctionTyped { 81 | // Translation requires options 82 | < 83 | TKey extends OptionI18nKeys | OptionI18nKeys[], 84 | TResult extends TFunctionResult = string, 85 | TInterpolationMap extends TTypedOptions = StringMap 86 | > ( 87 | key: TKey, 88 | options: TOptions | string 89 | ): TResult; 90 | 91 | // Translation does not require options 92 | < 93 | TResult extends TFunctionResult = string, 94 | TInterpolationMap extends Record = StringMap 95 | > ( 96 | key: NoOptionI18nKeys | NoOptionI18nKeys[], 97 | options?: TOptions | string 98 | ): TResult; 99 | } 100 | 101 | export interface i18nTyped extends i18n { 102 | t: TFunctionTyped; 103 | } 104 | } 105 | `; 106 | 107 | fs.writeFileSync(`${outDir}i18next.d.ts`, data); 108 | }); 109 | -------------------------------------------------------------------------------- /src/shared/components/common/image-upload-form.tsx: -------------------------------------------------------------------------------- 1 | import { randomStr } from "@utils/helpers"; 2 | import classNames from "classnames"; 3 | import { Component, linkEvent } from "inferno"; 4 | import { HttpService, I18NextService, UserService } from "../../services"; 5 | import { toast } from "../../toast"; 6 | import { Icon } from "./icon"; 7 | 8 | interface ImageUploadFormProps { 9 | uploadTitle: string; 10 | imageSrc?: string; 11 | onUpload(url: string): any; 12 | onRemove(): any; 13 | rounded?: boolean; 14 | } 15 | 16 | interface ImageUploadFormState { 17 | loading: boolean; 18 | } 19 | 20 | export class ImageUploadForm extends Component< 21 | ImageUploadFormProps, 22 | ImageUploadFormState 23 | > { 24 | private id = `image-upload-form-${randomStr()}`; 25 | private emptyState: ImageUploadFormState = { 26 | loading: false, 27 | }; 28 | 29 | constructor(props: any, context: any) { 30 | super(props, context); 31 | this.state = this.emptyState; 32 | } 33 | 34 | render() { 35 | return ( 36 |
37 | {this.props.imageSrc && ( 38 | 39 | {/* TODO: Create "Current Iamge" translation for alt text */} 40 | 50 | 58 | 59 | )} 60 | 69 |
70 | ); 71 | } 72 | 73 | handleImageUpload(i: ImageUploadForm, event: any) { 74 | event.preventDefault(); 75 | const image = event.target.files[0] as File; 76 | 77 | i.setState({ loading: true }); 78 | 79 | HttpService.client.uploadImage({ image }).then(res => { 80 | console.log("pictrs upload:"); 81 | console.log(res); 82 | if (res.state === "success") { 83 | if (res.data.msg === "ok") { 84 | i.props.onUpload(res.data.url as string); 85 | } else if (res.data.msg === "too_large") { 86 | toast(I18NextService.i18n.t("upload_too_large"), "danger"); 87 | } else { 88 | toast(JSON.stringify(res), "danger"); 89 | } 90 | } else if (res.state === "failed") { 91 | console.error(res.msg); 92 | toast(res.msg, "danger"); 93 | } 94 | 95 | i.setState({ loading: false }); 96 | }); 97 | } 98 | 99 | handleRemoveImage(i: ImageUploadForm, event: any) { 100 | if (event) event.preventDefault(); 101 | i.setState({ loading: true }); 102 | i.props.onRemove(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/shared/components/private_message/private-message-report.tsx: -------------------------------------------------------------------------------- 1 | import { myAuthRequired } from "@utils/app"; 2 | import { Component, InfernoNode, linkEvent } from "inferno"; 3 | import { T } from "inferno-i18next-dess"; 4 | import { 5 | PrivateMessageReportView, 6 | ResolvePrivateMessageReport, 7 | } from "lemmy-js-client"; 8 | import { mdToHtml } from "../../markdown"; 9 | import { I18NextService } from "../../services"; 10 | import { Icon, Spinner } from "../common/icon"; 11 | import { PersonListing } from "../person/person-listing"; 12 | 13 | interface Props { 14 | report: PrivateMessageReportView; 15 | onResolveReport(form: ResolvePrivateMessageReport): void; 16 | } 17 | 18 | interface State { 19 | loading: boolean; 20 | } 21 | 22 | export class PrivateMessageReport extends Component { 23 | state: State = { 24 | loading: false, 25 | }; 26 | 27 | constructor(props: any, context: any) { 28 | super(props, context); 29 | } 30 | 31 | componentWillReceiveProps( 32 | nextProps: Readonly<{ children?: InfernoNode } & Props> 33 | ): void { 34 | if (this.props != nextProps) { 35 | this.setState({ loading: false }); 36 | } 37 | } 38 | 39 | render() { 40 | const r = this.props.report; 41 | const pmr = r.private_message_report; 42 | const tippyContent = I18NextService.i18n.t( 43 | r.private_message_report.resolved ? "unresolve_report" : "resolve_report" 44 | ); 45 | 46 | return ( 47 |
48 |
49 | {I18NextService.i18n.t("creator")}:{" "} 50 | 51 |
52 |
53 | {I18NextService.i18n.t("message")}: 54 |
58 |
59 |
60 | {I18NextService.i18n.t("reporter")}:{" "} 61 | 62 |
63 |
64 | {I18NextService.i18n.t("reason")}: {pmr.reason} 65 |
66 | {r.resolver && ( 67 |
68 | {pmr.resolved ? ( 69 | 70 | # 71 | 72 | 73 | ) : ( 74 | 75 | # 76 | 77 | 78 | )} 79 |
80 | )} 81 | 98 |
99 | ); 100 | } 101 | 102 | handleResolveReport(i: PrivateMessageReport) { 103 | i.setState({ loading: true }); 104 | const pmr = i.props.report.private_message_report; 105 | i.props.onResolveReport({ 106 | report_id: pmr.id, 107 | resolved: !pmr.resolved, 108 | auth: myAuthRequired(), 109 | }); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.darkly.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | // Colors 4 | $white: #fff; 5 | $gray-200: #ebebeb; 6 | $gray-300: #dee2e6; 7 | $gray-500: #adb5bd; 8 | $gray-600: #888; 9 | $gray-700: #444; 10 | $gray-800: #303030; 11 | $gray-900: #222; 12 | 13 | $blue: #375a7f; 14 | $red: #e74c3c; 15 | $yellow: #f39c12; 16 | $green: #00bc8c; 17 | $cyan: #3498db; 18 | 19 | $primary: $green; 20 | $secondary: $gray-500; 21 | $success: $green; 22 | $dark: $gray-300; 23 | 24 | $body-color: $gray-300; 25 | $body-bg: $gray-900; 26 | $link-color: $success; 27 | $border-color: rgba($body-color, 0.25); 28 | $mark-bg: #333; 29 | $text-muted: $gray-600; 30 | $yiq-contrasted-threshold: 175; 31 | 32 | $font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", 33 | Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", 34 | "Segoe UI Emoji", "Segoe UI Symbol"; 35 | $h1-font-size: 3rem; 36 | $h2-font-size: 2.5rem; 37 | $h3-font-size: 2rem; 38 | 39 | $card-cap-bg: $gray-700; 40 | $card-bg: $gray-800; 41 | 42 | $navbar-padding-y: 1rem; 43 | $navbar-dark-color: rgba($white, 0.6); 44 | $navbar-dark-hover-color: $white; 45 | $navbar-light-color: rgba($white, 0.6); 46 | $navbar-light-hover-color: $white; 47 | $navbar-light-active-color: $white; 48 | $navbar-light-toggler-border-color: rgba($gray-900, 0.1); 49 | $navbar-light-brand-color: $white; 50 | $navbar-light-brand-hover-color: $navbar-light-brand-color; 51 | 52 | $nav-link-padding-x: 2rem; 53 | $nav-link-disabled-color: $gray-500; 54 | 55 | $nav-tabs-border-color: $gray-700; 56 | $nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color 57 | transparent; 58 | $nav-tabs-link-active-color: $white; 59 | $nav-tabs-link-active-border-color: $nav-tabs-border-color 60 | $nav-tabs-border-color transparent; 61 | 62 | $input-bg: $gray-700; 63 | $input-color: $white; 64 | $input-disabled-bg: darken($gray-700, 10%); 65 | $input-border-color: $body-bg; 66 | $input-group-addon-color: $gray-500; 67 | $input-group-addon-bg: $gray-700; 68 | 69 | $hr-border-color: rgba($body-color, 0.25); 70 | 71 | $table-border-color: $gray-700; 72 | 73 | $custom-file-color: $gray-500; 74 | $custom-file-border-color: $body-bg; 75 | 76 | $dropdown-bg: $gray-900; 77 | $dropdown-border-color: $gray-700; 78 | $dropdown-divider-bg: $gray-700; 79 | $dropdown-link-color: $white; 80 | $dropdown-link-hover-color: $white; 81 | $dropdown-link-hover-bg: $primary; 82 | 83 | $pagination-color: $white; 84 | $pagination-bg: $success; 85 | $pagination-border-width: 0; 86 | $pagination-border-color: transparent; 87 | $pagination-hover-color: $white; 88 | $pagination-hover-bg: lighten($success, 10%); 89 | $pagination-hover-border-color: transparent; 90 | $pagination-active-bg: $pagination-hover-bg; 91 | $pagination-active-border-color: transparent; 92 | $pagination-disabled-color: $white; 93 | $pagination-disabled-bg: darken($success, 15%); 94 | $pagination-disabled-border-color: transparent; 95 | 96 | $jumbotron-bg: $gray-800; 97 | $popover-bg: $gray-800; 98 | $popover-header-bg: $gray-700; 99 | $toast-background-color: $gray-700; 100 | $toast-header-background-color: $gray-800; 101 | $modal-content-bg: $gray-800; 102 | $modal-content-border-color: $gray-700; 103 | $modal-header-border-color: $gray-700; 104 | $progress-bg: $gray-700; 105 | $list-group-bg: $gray-800; 106 | $list-group-border-color: $gray-700; 107 | $list-group-hover-bg: $gray-700; 108 | $breadcrumb-bg: $gray-700; 109 | $close-color: $white; 110 | $close-text-shadow: none; 111 | $pre-color: inherit; 112 | $custom-select-bg: $gray-700; 113 | $custom-select-color: $white; 114 | $light: $gray-800; 115 | -------------------------------------------------------------------------------- /src/assets/tos.txt: -------------------------------------------------------------------------------- 1 | 2 | Introduction 3 | Welcome to WePi, a social media platform that connects people from all over the world. These Terms of Service (the "Terms") govern your access to and use of the platform, including our websites, mobile applications, and other services (collectively, the "Services"). By accessing or using the Services, you agree to be bound by these Terms, our Privacy Policy, and any other policies or guidelines we may provide from time to time. If you do not agree to these Terms, you may not access or use the Services. 4 | 5 | Account Requirements 6 | To access and use the Services, you must be at least [age] years old and have the capacity to enter into a binding agreement. By accessing or using the Services, you represent and warrant that you have the right, authority, and capacity to enter into these Terms and to abide by all of the terms and conditions set forth herein. 7 | 8 | User Conduct 9 | When accessing and using the Services, you agree to comply with all applicable laws, rules, and regulations, and to not engage in any prohibited conduct, including: 10 | 11 | Infringing the intellectual property rights of others, such as copyright and trademark infringement 12 | 13 | Posting false or misleading information, or impersonating another person or entity 14 | 15 | Engaging in harassment, hate speech, bullying, or any other conduct that is harmful or offensive to others, including but not limited to racist, sexist, homophobic, transphobic, or xenophobic language 16 | 17 | Collecting or using the personal information of others without their consent, including but not limited to scraping, harvesting, or otherwise collecting information from the Services 18 | 19 | Engaging in spamming, phishing, or any other conduct that is fraudulent or deceptive, including but not limited to sending unsolicited messages, promotions, or advertisements 20 | 21 | Engaging in any conduct that is harmful to the stability, security, or performance of the Services, or to any third-party systems or networks, including but not limited to hacking, cracking, distributing viruses or malware, or engaging in denial of service attacks 22 | 23 | Engaging in any conduct that could cause us to violate any applicable law or regulation, including but not limited to illegal or unauthorized gambling, sales of controlled substances, or sale of counterfeit goods 24 | 25 | Posting or distributing any content that is illegal, including but not limited to child pornography, illegal drugs, or incitement to violence 26 | 27 | Content Ownership and License 28 | The content you upload, post, or otherwise make available through the Services, including text, photos, videos, and other materials (the "User Content"), is your sole responsibility and remains your property. By making any User Content available through the Services, you grant to us a non-exclusive, transferable, sub-licensable, royalty-free, worldwide license to use, copy, modify, create derivative works based on, distribute, publicly display, and otherwise exploit the User Content in connection with the Services and our business, including for promotional and marketing purposes. 29 | 30 | We reserve the right to remove or delete any User Content that violates these Terms, or that we determine is otherwise harmful or offensive. We also reserve the right to limit or restrict access to the Services, or to terminate your account, in our sole discretion, if you engage in any prohibited conduct or violate these Terms. 31 | 32 | Modification and Termination 33 | We reserve the right to modify or discontinue the Services, or to change these Terms, at any time, without notice. If we make material changes to these Terms, we will provide you with notice, such as through a notification on the Services. 34 | 35 | -------------------------------------------------------------------------------- /src/shared/components/common/user-badges.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { Component } from "inferno"; 3 | import { I18NextService } from "../../services"; 4 | 5 | interface UserBadgesProps { 6 | isBanned?: boolean; 7 | isDeleted?: boolean; 8 | isPostCreator?: boolean; 9 | isMod?: boolean; 10 | isAdmin?: boolean; 11 | isBot?: boolean; 12 | classNames?: string; 13 | } 14 | 15 | export function getRoleLabelPill({ 16 | label, 17 | tooltip, 18 | classes, 19 | shrink = true, 20 | }: { 21 | label: string; 22 | tooltip: string; 23 | classes?: string; 24 | shrink?: boolean; 25 | }) { 26 | return ( 27 | 32 | {shrink ? label[0].toUpperCase() : label} 33 | 34 | ); 35 | } 36 | 37 | export class UserBadges extends Component { 38 | render() { 39 | return ( 40 | (this.props.isBanned || 41 | this.props.isPostCreator || 42 | this.props.isMod || 43 | this.props.isAdmin || 44 | this.props.isBot) && ( 45 | 51 | {this.props.isBanned && ( 52 | 53 | {getRoleLabelPill({ 54 | label: I18NextService.i18n.t("banned"), 55 | tooltip: I18NextService.i18n.t("banned"), 56 | classes: "text-danger border border-danger", 57 | shrink: false, 58 | })} 59 | 60 | )} 61 | {this.props.isDeleted && ( 62 | 63 | {getRoleLabelPill({ 64 | label: I18NextService.i18n.t("deleted"), 65 | tooltip: I18NextService.i18n.t("deleted"), 66 | classes: "text-danger border border-danger", 67 | shrink: false, 68 | })} 69 | 70 | )} 71 | 72 | {this.props.isPostCreator && ( 73 | 74 | {getRoleLabelPill({ 75 | label: I18NextService.i18n.t("op").toUpperCase(), 76 | tooltip: I18NextService.i18n.t("creator"), 77 | classes: "text-info border border-info", 78 | shrink: false, 79 | })} 80 | 81 | )} 82 | {this.props.isMod && ( 83 | 84 | {getRoleLabelPill({ 85 | label: I18NextService.i18n.t("mod"), 86 | tooltip: I18NextService.i18n.t("mod"), 87 | classes: "text-primary border border-primary", 88 | })} 89 | 90 | )} 91 | {this.props.isAdmin && ( 92 | 93 | {getRoleLabelPill({ 94 | label: I18NextService.i18n.t("admin"), 95 | tooltip: I18NextService.i18n.t("admin"), 96 | classes: "text-danger border border-danger", 97 | })} 98 | 99 | )} 100 | {this.props.isBot && ( 101 | 102 | {getRoleLabelPill({ 103 | label: I18NextService.i18n.t("bot_account").toLowerCase(), 104 | tooltip: I18NextService.i18n.t("bot_account"), 105 | })} 106 | 107 | )} 108 | 109 | ) 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/shared/components/person/person-listing.tsx: -------------------------------------------------------------------------------- 1 | import { showAvatars } from "@utils/app"; 2 | import { getStaticDir } from "@utils/env"; 3 | import { hostname, isCakeDay } from "@utils/helpers"; 4 | import classNames from "classnames"; 5 | import { Component } from "inferno"; 6 | import { Link } from "inferno-router"; 7 | import { Person } from "lemmy-js-client"; 8 | import { relTags } from "../../config"; 9 | import { Icon } from "../common/icon"; 10 | import { PictrsImage } from "../common/pictrs-image"; 11 | import { CakeDay } from "./cake-day"; 12 | 13 | interface PersonListingProps { 14 | person: Person; 15 | realLink?: boolean; 16 | useApubName?: boolean; 17 | muted?: boolean; 18 | hideAvatar?: boolean; 19 | showApubName?: boolean; 20 | } 21 | 22 | export class PersonListing extends Component { 23 | constructor(props: any, context: any) { 24 | super(props, context); 25 | } 26 | 27 | render() { 28 | const person = this.props.person; 29 | const local = person.local; 30 | let apubName: string, link: string, linkHome: string; 31 | linkHome = `/c/${person.name}`; 32 | if (local) { 33 | apubName = `@${person.name}`; 34 | link = `/u/${person.name}`; 35 | } else { 36 | const domain = hostname(person.actor_id); 37 | apubName = `@${person.name}@${domain}`; 38 | link = !this.props.realLink 39 | ? `/u/${person.name}@${domain}` 40 | : person.actor_id; 41 | } 42 | 43 | let displayName = this.props.useApubName 44 | ? apubName 45 | : person.display_name ?? apubName; 46 | 47 | if (this.props.showApubName && !local && person.display_name) { 48 | displayName = `${displayName} (${apubName})`; 49 | } 50 | 51 | return ( 52 | <> 53 | {!this.props.realLink ? ( 54 | 65 | {this.avatarAndName(displayName)} 66 | 67 | ) : ( 68 | 76 | {this.avatarAndName(displayName)} 77 | 78 | )} 79 | 85 | {person.verified && } 86 | {!person.verified && ( 87 | 88 | )} 89 | 90 | {isCakeDay(person.published) && } 91 | 92 | ); 93 | } 94 | 95 | avatarAndName(displayName: string) { 96 | const avatar = this.props.person.avatar; 97 | return ( 98 | <> 99 | {!this.props.hideAvatar && 100 | !this.props.person.banned && 101 | showAvatars() && ( 102 | 106 | )} 107 | {displayName} 108 | 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/assets/css/themes/_variables.darkly-pureblack.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | // Colors 4 | $white: #f3f3f3; 5 | $gray-200: #ebebeb; 6 | $gray-300: #dee2e6; 7 | $gray-500: #adb5bd; 8 | $gray-600: #666; 9 | $gray-700: #333; 10 | $gray-800: #202020; 11 | $gray-900: #111; 12 | $black: #000; 13 | 14 | $blue: #375a7f; 15 | $red: #e74c3c; 16 | $yellow: #f39c12; 17 | $green: #00bc8c; 18 | $cyan: #3498db; 19 | 20 | $primary: $green; 21 | $secondary: $gray-600; 22 | $success: $green; 23 | $dark: $gray-300; 24 | 25 | $body-color: $gray-200; 26 | $body-bg: $black; 27 | $link-color: $success; 28 | $border-color: rgba($body-color, 0.25); 29 | $mark-bg: $gray-900; 30 | $text-muted: $gray-600; 31 | $yiq-contrasted-threshold: 175; 32 | 33 | $font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", 34 | Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", 35 | "Segoe UI Emoji", "Segoe UI Symbol"; 36 | $font-size-base: 0.9375rem; 37 | $h1-font-size: 3rem; 38 | $h2-font-size: 2.5rem; 39 | $h3-font-size: 2rem; 40 | 41 | $card-cap-bg: $gray-900; 42 | $card-bg: $gray-900; 43 | $card-color: $gray-300; 44 | 45 | $navbar-padding-y: 1rem; 46 | $navbar-dark-color: rgba($white, 0.6); 47 | $navbar-dark-hover-color: $white; 48 | $navbar-light-color: rgba($white, 0.6); 49 | $navbar-light-hover-color: $white; 50 | $navbar-light-active-color: $white; 51 | $navbar-light-toggler-border-color: rgba($gray-900, 0.1); 52 | $navbar-light-brand-color: $white; 53 | $navbar-light-brand-hover-color: $navbar-light-brand-color; 54 | 55 | $nav-link-padding-x: 2rem; 56 | $nav-link-disabled-color: $gray-500; 57 | 58 | $nav-tabs-border-color: $gray-700; 59 | $nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color 60 | transparent; 61 | $nav-tabs-link-active-color: $white; 62 | $nav-tabs-link-active-border-color: $nav-tabs-border-color 63 | $nav-tabs-border-color transparent; 64 | 65 | $input-bg: $gray-900; 66 | $input-color: $white; 67 | $input-disabled-bg: darken($gray-900, 20%); 68 | $input-border-color: $gray-800; 69 | $input-group-addon-color: $gray-800; 70 | $input-group-addon-bg: $gray-800; 71 | 72 | $hr-border-color: rgba($body-color, 0.25); 73 | 74 | $table-border-color: $gray-700; 75 | 76 | $custom-file-color: $gray-500; 77 | $custom-file-border-color: $body-bg; 78 | 79 | $dropdown-bg: $gray-900; 80 | $dropdown-border-color: $gray-800; 81 | $dropdown-divider-bg: $gray-700; 82 | $dropdown-link-color: $white; 83 | $dropdown-link-hover-color: $white; 84 | $dropdown-link-hover-bg: $primary; 85 | 86 | $pagination-color: $white; 87 | $pagination-bg: $success; 88 | $pagination-border-width: 0; 89 | $pagination-border-color: transparent; 90 | $pagination-hover-color: $white; 91 | $pagination-hover-bg: lighten($success, 10%); 92 | $pagination-hover-border-color: transparent; 93 | $pagination-active-bg: $pagination-hover-bg; 94 | $pagination-active-border-color: transparent; 95 | $pagination-disabled-color: $white; 96 | $pagination-disabled-bg: darken($success, 15%); 97 | $pagination-disabled-border-color: transparent; 98 | 99 | $jumbotron-bg: $gray-900; 100 | $popover-bg: $gray-900; 101 | $popover-header-bg: $gray-900; 102 | $toast-background-color: $gray-800; 103 | $toast-header-background-color: $gray-900; 104 | $modal-content-bg: $gray-800; 105 | $modal-content-border-color: $gray-700; 106 | $modal-header-border-color: $gray-700; 107 | $progress-bg: $gray-700; 108 | $list-group-bg: $gray-800; 109 | $list-group-border-color: $gray-700; 110 | $list-group-hover-bg: $gray-700; 111 | $breadcrumb-bg: $gray-700; 112 | $close-color: $white; 113 | $close-text-shadow: none; 114 | $pre-color: inherit; 115 | $custom-select-bg: $gray-700; 116 | $custom-select-color: $white; 117 | $light: $gray-900; 118 | -------------------------------------------------------------------------------- /src/shared/components/app/app.tsx: -------------------------------------------------------------------------------- 1 | import { isAuthPath, setIsoData } from "@utils/app"; 2 | import { dataBsTheme } from "@utils/browser"; 3 | import { Component, RefObject, createRef, linkEvent } from "inferno"; 4 | import { Provider } from "inferno-i18next-dess"; 5 | import { Route, Switch } from "inferno-router"; 6 | import { MyUserInfo } from "lemmy-js-client"; 7 | import { IsoDataOptionalSite } from "../../interfaces"; 8 | import { routes } from "../../routes"; 9 | import { FirstLoadService, I18NextService, UserService } from "../../services"; 10 | import AuthGuard from "../common/auth-guard"; 11 | import ErrorGuard from "../common/error-guard"; 12 | import { ErrorPage } from "./error-page"; 13 | import { Footer } from "./footer"; 14 | import { Navbar } from "./navbar"; 15 | import "./styles.scss"; 16 | import { Theme } from "./theme"; 17 | 18 | interface AppProps { 19 | user?: MyUserInfo; 20 | } 21 | 22 | export class App extends Component { 23 | private isoData: IsoDataOptionalSite = setIsoData(this.context); 24 | private readonly mainContentRef: RefObject; 25 | constructor(props: AppProps, context: any) { 26 | super(props, context); 27 | this.mainContentRef = createRef(); 28 | } 29 | 30 | handleJumpToContent(event) { 31 | event.preventDefault(); 32 | this.mainContentRef.current?.focus(); 33 | } 34 | 35 | user = UserService.Instance.myUserInfo; 36 | 37 | render() { 38 | const siteRes = this.isoData.site_res; 39 | const siteView = siteRes?.site_view; 40 | 41 | return ( 42 | <> 43 | 44 |
49 | 56 | {siteView && ( 57 | 58 | )} 59 | 60 |
61 | 62 | {routes.map( 63 | ({ path, component: RouteComponent, fetchInitialData }) => ( 64 | { 69 | if (!fetchInitialData) { 70 | FirstLoadService.falsify(); 71 | } 72 | 73 | return ( 74 | 75 |
76 | {RouteComponent && 77 | (isAuthPath(path ?? "") ? ( 78 | 79 | 80 | 81 | ) : ( 82 | 83 | ))} 84 |
85 |
86 | ); 87 | }} 88 | /> 89 | ) 90 | )} 91 | 92 |
93 |
94 |
95 |
96 |
97 | 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/server/utils/create-ssr-html.tsx: -------------------------------------------------------------------------------- 1 | import { getStaticDir } from "@utils/env"; 2 | import { Helmet } from "inferno-helmet"; 3 | import { renderToString } from "inferno-server"; 4 | import serialize from "serialize-javascript"; 5 | import sharp from "sharp"; 6 | import { favIconPngUrl, favIconUrl } from "../../shared/config"; 7 | import { IsoDataOptionalSite } from "../../shared/interfaces"; 8 | import { buildThemeList } from "./build-themes-list"; 9 | import { fetchIconPng } from "./fetch-icon-png"; 10 | 11 | const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || ""; 12 | 13 | let appleTouchIcon: string | undefined = undefined; 14 | 15 | export async function createSsrHtml( 16 | root: string, 17 | isoData: IsoDataOptionalSite, 18 | cspNonce: string 19 | ) { 20 | const site = isoData.site_res; 21 | 22 | const fallbackTheme = ``; 25 | 26 | const customHtmlHeaderScriptTag = new RegExp(" buf.toString("base64"))}` 48 | : favIconPngUrl; 49 | } 50 | 51 | const erudaStr = 52 | process.env["LEMMY_UI_DEBUG"] === "true" 53 | ? renderToString( 54 | <> 55 | 59 | 60 | 61 | ) 62 | : ""; 63 | 64 | const helmet = Helmet.renderStatic(); 65 | 66 | return ` 67 | 68 | 69 | 70 | 71 | 72 | 73 | ${erudaStr} 74 | 75 | 76 | ${customHtmlHeaderWithNonce} 77 | 78 | ${helmet.title.toString()} 79 | ${helmet.meta.toString()} 80 | 81 | 82 | 83 | 84 | 85 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | ${helmet.link.toString() || fallbackTheme} 102 | 103 | 104 | 105 | 106 | 111 | 112 |
${root}
113 | 114 | 115 | 116 | `; 117 | } 118 | -------------------------------------------------------------------------------- /src/shared/components/common/listing-type-select.tsx: -------------------------------------------------------------------------------- 1 | import { randomStr } from "@utils/helpers"; 2 | import classNames from "classnames"; 3 | import { Component, linkEvent } from "inferno"; 4 | import { ListingType } from "lemmy-js-client"; 5 | import { I18NextService, UserService } from "../../services"; 6 | 7 | interface ListingTypeSelectProps { 8 | type_: ListingType; 9 | showLocal: boolean; 10 | showSubscribed: boolean; 11 | onChange(val: ListingType): void; 12 | } 13 | 14 | interface ListingTypeSelectState { 15 | type_: ListingType; 16 | } 17 | 18 | export class ListingTypeSelect extends Component< 19 | ListingTypeSelectProps, 20 | ListingTypeSelectState 21 | > { 22 | private id = `listing-type-input-${randomStr()}`; 23 | 24 | state: ListingTypeSelectState = { 25 | type_: this.props.type_, 26 | }; 27 | 28 | constructor(props: any, context: any) { 29 | super(props, context); 30 | } 31 | 32 | static getDerivedStateFromProps( 33 | props: ListingTypeSelectProps 34 | ): ListingTypeSelectState { 35 | return { 36 | type_: props.type_, 37 | }; 38 | } 39 | 40 | render() { 41 | return ( 42 |
46 | {this.props.showSubscribed && ( 47 | <> 48 | 57 | 68 | 69 | )} 70 | {this.props.showLocal && ( 71 | <> 72 | 80 | 89 | 90 | )} 91 | 99 | 110 |
111 | ); 112 | } 113 | 114 | handleTypeChange(i: ListingTypeSelect, event: any) { 115 | i.props.onChange(event.target.value); 116 | } 117 | } 118 | --------------------------------------------------------------------------------