,
8 | ): InfernoNode => ;
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/assets/css/themes/i386-dark.scss:
--------------------------------------------------------------------------------
1 | @import "variables.i386-dark";
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/server/utils/has-jwt-cookie.ts:
--------------------------------------------------------------------------------
1 | import * as cookie from "cookie";
2 | import { authCookieName } from "@utils/config";
3 | import { IncomingHttpHeaders } from "http";
4 |
5 | export function getJwtCookie(headers: IncomingHttpHeaders): string | undefined {
6 | return headers.cookie
7 | ? cookie.parse(headers.cookie)[authCookieName] // This can actually be undefined
8 | : undefined;
9 | }
10 |
--------------------------------------------------------------------------------
/.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 | .yalc
25 | yalc.lock
26 |
27 | package-lock.json
28 | *.orig
29 |
30 | src/shared/translations
31 |
32 | stats.json
33 |
34 |
--------------------------------------------------------------------------------
/scripts/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
5 | cd "$CWD/../"
6 |
7 | # Use this to develop on voyager.lemmy.ml
8 | export LEMMY_UI_BACKEND_REMOTE=voyager.lemmy.ml
9 |
10 | # Use this to develop locally. Change TEST.TLD to your test server.
11 | # export LEMMY_UI_BACKEND_INTERNAL=0.0.0.0:8536
12 | # export LEMMY_UI_BACKEND_EXTERNAL=TEST.TLD:8536
13 |
14 | pnpm i
15 | pnpm dev
16 |
--------------------------------------------------------------------------------
/scripts/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/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 |
--------------------------------------------------------------------------------
/scripts/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
5 | cd "$CWD/../"
6 |
7 | new_tag="$1"
8 |
9 | # Old deploy
10 | # sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64 --push
11 | # sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64
12 | # sudo docker push dessalines/lemmy-ui:$new_tag
13 |
14 | # Upgrade version
15 | pnpm version $new_tag
16 | git push
17 |
18 | git tag $new_tag
19 | git push origin $new_tag
20 |
--------------------------------------------------------------------------------
/src/assets/css/themes/_variables.scss:
--------------------------------------------------------------------------------
1 | $link-decoration: none;
2 | $min-contrast-ratio: 3;
3 | $font-size-root: 100%;
4 | :root {
5 | --comment-border-width: 2px;
6 | --comment-node-1-color: hsla(0, 35%, 50%, 0.5);
7 | --comment-node-2-color: hsla(50, 35%, 50%, 0.5);
8 | --comment-node-3-color: hsla(100, 35%, 50%, 0.5);
9 | --comment-node-4-color: hsla(150, 35%, 50%, 0.5);
10 | --comment-node-5-color: hsla(200, 35%, 50%, 0.5);
11 | --comment-node-6-color: hsla(250, 35%, 50%, 0.5);
12 | --comment-node-7-color: hsla(300, 35%, 50%, 0.5);
13 | }
14 |
--------------------------------------------------------------------------------
/src/assets/css/themes/RBlind-Dark.scss:
--------------------------------------------------------------------------------
1 | @import "variables.darkly-compact";
2 | @import "variables.RBlind-Dark";
3 |
4 | @import "../../../../node_modules/bootstrap/scss/bootstrap";
5 | @import "./rblind/RBlind";
6 |
7 | // make the down arrow on the dropdown select buttons white instead of gray
8 | .form-select {
9 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
10 | }
11 |
--------------------------------------------------------------------------------
/src/assets/css/themes/RBlind-Light.scss:
--------------------------------------------------------------------------------
1 | @import "variables.litely-compact";
2 | @import "variables.RBlind-Light";
3 |
4 | @import "../../../../node_modules/bootstrap/scss/bootstrap";
5 | @import "./rblind/RBlind";
6 |
7 | // make the down arrow on the dropdown select buttons white instead of gray
8 | .form-select {
9 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
10 | }
11 |
--------------------------------------------------------------------------------
/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: /signup
10 | Disallow: /settings
11 | Disallow: /create_community
12 | Disallow: /create_post
13 | Disallow: /create_private_message
14 | Disallow: /notifications
15 | Disallow: /setup
16 | Disallow: /admin
17 | Disallow: /password_change
18 | Disallow: /search
19 | Disallow: /modlog
20 | Disallow: /api
21 | Crawl-delay: 60`);
22 | };
23 |
--------------------------------------------------------------------------------
/scripts/update_translations.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
5 | cd "$CWD/../"
6 |
7 | pushd ../lemmy-translations
8 | git fetch weblate
9 | git merge weblate/main
10 | git push
11 | popd
12 |
13 | # look for unused translations
14 | for langfile in lemmy-translations/translations/*.json; do
15 | lang=$(basename $langfile .json)
16 | if ! grep -q "\"./translations/$lang\"" src/shared/services/I18NextService.ts; then
17 | echo "Unused language $lang"
18 | fi
19 | done
20 |
21 | git submodule update --remote
22 | git add lemmy-translations
23 | git commit -m"Updating translations."
24 | git push
25 |
--------------------------------------------------------------------------------
/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.name;
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/server/handlers/security-handler.ts:
--------------------------------------------------------------------------------
1 | import type { Response } from "express";
2 | import { BUILD_DATE_ISO8601 } from "../../shared/build-date";
3 | import { parseISO } from "date-fns";
4 |
5 | export default async ({ res }: { res: Response }) => {
6 | const buildDatePlusYear = parseISO(BUILD_DATE_ISO8601);
7 |
8 | // Add a year to the build date
9 | buildDatePlusYear.setFullYear(new Date().getFullYear() + 1);
10 |
11 | const yearFromNow = buildDatePlusYear.toISOString();
12 |
13 | res.setHeader("content-type", "text/plain; charset=utf-8");
14 |
15 | res.send(`Contact: https://github.com/LemmyNet/lemmy-ui/security/advisories/new
16 | Expires: ${yearFromNow}`);
17 | };
18 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "compact": false,
3 | "presets": [
4 | [
5 | "@babel/preset-env",
6 | {
7 | "loose": true,
8 | "browserslistEnv": "package.json"
9 | }
10 | ],
11 | ["@babel/typescript", { "isTSX": true, "allExtensions": true }]
12 | ],
13 | "plugins": [
14 | ["@babel/plugin-proposal-decorators", { "version": "legacy" }],
15 | [
16 | "@babel/plugin-transform-runtime",
17 | // version defaults to 7.0.0 for which non-legacy decorators produce duplicate code
18 | { "version": "^7.24.3" }
19 | ],
20 | ["babel-plugin-inferno", { "imports": true }],
21 | ["@babel/plugin-transform-class-properties", { "loose": true }]
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/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:
27 | 0.5px 0.5px 0 $secondary,
28 | 0.5px -0.5px 0 $secondary,
29 | -0.5px 0.5px 0 $secondary,
30 | -0.5px -0.5px 0 $secondary;
31 | }
32 |
33 | .input-group-text {
34 | background: $gray-500;
35 | }
36 |
--------------------------------------------------------------------------------
/src/shared/components/home/banned-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { I18NextService } from "@services/index";
2 | import { formatRelativeDate } from "@utils/date";
3 |
4 | interface BannedDialogProps {
5 | expires: string | undefined;
6 | }
7 |
8 | export function BannedDialog({ expires }: BannedDialogProps) {
9 | const title = expires
10 | ? I18NextService.i18n.t("banned_dialog_title_temporary", {
11 | expires: formatRelativeDate(expires),
12 | })
13 | : I18NextService.i18n.t("banned_dialog_title_permanent");
14 | return (
15 |
16 |
{title}
17 |
{I18NextService.i18n.t("banned_dialog_body")}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/shared/components/common/registration-state-radios.tsx:
--------------------------------------------------------------------------------
1 | import { RadioOption, RadioButtonGroup } from "./radio-button-group";
2 |
3 | export type RegistrationState = "unread" | "all" | "denied";
4 |
5 | interface RegistrationStateRadiosProps {
6 | state: RegistrationState;
7 | onClick(val: RegistrationState): void;
8 | }
9 |
10 | export function RegistrationStateRadios(props: RegistrationStateRadiosProps) {
11 | const allStates: RadioOption[] = [
12 | { value: "unread", i18n: "unread" },
13 | { value: "all", i18n: "all" },
14 | { value: "denied", i18n: "denied" },
15 | ];
16 | return (
17 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/shared/components/mixins/tippy-mixin.ts:
--------------------------------------------------------------------------------
1 | import { Component, InfernoNode } from "inferno";
2 | import { cleanupTippy } from "@utils/tippy";
3 |
4 | export function tippyMixin<
5 | P,
6 | S,
7 | Base extends new (...args: any) => Component,
8 | >(base: Base, _context?: ClassDecoratorContext) {
9 | return class extends base {
10 | componentDidUpdate(
11 | prevProps: P & { children?: InfernoNode },
12 | prevState: S,
13 | snapshot: any,
14 | ) {
15 | // For conditional rendering, old tippy instances aren't reused
16 | cleanupTippy();
17 | return super.componentDidUpdate?.(prevProps, prevState, snapshot);
18 | }
19 |
20 | componentWillUnmount() {
21 | cleanupTippy();
22 | return super.componentWillUnmount?.();
23 | }
24 | };
25 | }
26 |
--------------------------------------------------------------------------------
/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 | $mark-bg-dark: $gray-600;
29 | $pre-color: $gray-200;
30 |
--------------------------------------------------------------------------------
/src/server/utils/set-forwarded-headers.ts:
--------------------------------------------------------------------------------
1 | import { IncomingHttpHeaders } from "http";
2 | import { getJwtCookie } from "./has-jwt-cookie";
3 |
4 | export function setForwardedHeaders(headers: IncomingHttpHeaders): {
5 | [key: string]: string;
6 | } {
7 | const out: { [key: string]: string } = {};
8 |
9 | if (headers.host) {
10 | out.host = headers.host;
11 | }
12 |
13 | const realIp = headers["x-real-ip"];
14 |
15 | if (realIp) {
16 | out["x-real-ip"] = realIp as string;
17 | }
18 |
19 | const forwardedFor = headers["x-forwarded-for"];
20 |
21 | if (forwardedFor) {
22 | out["x-forwarded-for"] = forwardedFor as string;
23 | }
24 |
25 | const auth = getJwtCookie(headers);
26 |
27 | if (auth) {
28 | out["Authorization"] = `Bearer ${auth}`;
29 | }
30 |
31 | return out;
32 | }
33 |
--------------------------------------------------------------------------------
/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/components/person/cake-day.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from "inferno";
2 | import { I18NextService } from "../../services";
3 | import { Icon } from "../common/icon";
4 | import { tippyMixin } from "../mixins/tippy-mixin";
5 |
6 | interface CakeDayProps {
7 | creatorName: string;
8 | }
9 |
10 | @tippyMixin
11 | export class CakeDay extends Component {
12 | render() {
13 | return (
14 |
18 |
19 |
20 | );
21 | }
22 |
23 | cakeDayTippy(): string {
24 | return I18NextService.i18n.t("cake_day_info", {
25 | creator_name: this.props.creatorName,
26 | });
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/shared/components/common/anonymous-guard.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from "inferno";
2 | import { Spinner } from "./icon";
3 | import { isBrowser } from "@utils/browser";
4 | import { MyUserInfo } from "lemmy-js-client";
5 |
6 | interface AnonymousGuardProps {
7 | myUserInfo: MyUserInfo | undefined;
8 | }
9 |
10 | class AnonymousGuard extends Component {
11 | constructor(props: any, context: any) {
12 | super(props, context);
13 | }
14 |
15 | hasAuth() {
16 | return this.props.myUserInfo;
17 | }
18 |
19 | componentWillMount() {
20 | if (this.hasAuth() && isBrowser()) {
21 | this.context.router.history.replace(`/`);
22 | }
23 | }
24 |
25 | render() {
26 | return !this.hasAuth() ? this.props.children : ;
27 | }
28 | }
29 |
30 | export default AnonymousGuard;
31 |
--------------------------------------------------------------------------------
/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/assets/css/code-themes/atom-one-dark.css:
--------------------------------------------------------------------------------
1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
--------------------------------------------------------------------------------
/src/assets/css/code-themes/atom-one-light.css:
--------------------------------------------------------------------------------
1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
--------------------------------------------------------------------------------
/src/shared/components/common/loading-ellipses.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from "inferno";
2 |
3 | interface LoadingEllipsesState {
4 | ellipses: string;
5 | }
6 |
7 | export class LoadingEllipses extends Component {
8 | state: LoadingEllipsesState = {
9 | ellipses: "...",
10 | };
11 | #interval?: NodeJS.Timeout;
12 |
13 | constructor(props: any, context: any) {
14 | super(props, context);
15 | }
16 |
17 | render() {
18 | return this.state.ellipses;
19 | }
20 |
21 | componentDidMount() {
22 | this.#interval = setInterval(this.#updateEllipses, 1000);
23 | }
24 |
25 | componentWillUnmount() {
26 | clearInterval(this.#interval);
27 | }
28 |
29 | #updateEllipses = () => {
30 | this.setState(({ ellipses }) => ({
31 | ellipses: ellipses.length === 3 ? "" : ellipses + ".",
32 | }));
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/shared/components/common/paginator.tsx:
--------------------------------------------------------------------------------
1 | import { I18NextService } from "../../services";
2 |
3 | interface PaginatorProps {
4 | onNext(): void;
5 | onPrev(): void;
6 | nextDisabled: boolean;
7 | disabled?: boolean;
8 | }
9 |
10 | const Paginator = ({
11 | nextDisabled,
12 | onNext,
13 | onPrev,
14 | disabled,
15 | }: PaginatorProps) => (
16 |
17 |
24 | {!nextDisabled && (
25 |
32 | )}
33 |
34 | );
35 |
36 | export default Paginator;
37 |
--------------------------------------------------------------------------------
/src/shared/components/common/emoji-mart.tsx:
--------------------------------------------------------------------------------
1 | import { Component, RefObject, createRef } from "inferno";
2 | import { getEmojiMart } from "@utils/markdown";
3 |
4 | interface EmojiMartProps {
5 | onEmojiClick?(val: any): any;
6 | pickerOptions: any;
7 | }
8 |
9 | export class EmojiMart extends Component {
10 | div: RefObject;
11 |
12 | constructor(props: any, context: any) {
13 | super(props, context);
14 |
15 | this.div = createRef();
16 |
17 | this.handleEmojiClick = this.handleEmojiClick.bind(this);
18 | }
19 |
20 | componentDidMount() {
21 | this.div.current?.appendChild(
22 | getEmojiMart(this.handleEmojiClick, this.props.pickerOptions) as any,
23 | );
24 | }
25 |
26 | render() {
27 | return ;
28 | }
29 |
30 | handleEmojiClick(e: any) {
31 | this.props.onEmojiClick?.(e);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/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-dark",
18 | "RBlind-Dark",
19 | "RBlind-Light",
20 | ];
21 |
22 | export async function buildThemeList(): Promise> {
23 | if (existsSync(extraThemesFolder)) {
24 | const dirThemes = await readdir(extraThemesFolder);
25 | const cssThemes = dirThemes
26 | .filter(d => d.endsWith(".css"))
27 | .map(d => d.replace(".css", ""));
28 | return themes.concat(cssThemes);
29 | }
30 | return themes;
31 | }
32 |
--------------------------------------------------------------------------------
/src/embedded/index.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error has a weird import error
2 | import { lazyLoad } from "unlazy";
3 |
4 | // WARNING: This script is written as content into a script tag. A closing
5 | // tag, even in an import, can break things.
6 |
7 | // document.body doesn't exist yet
8 | window.requestAnimationFrame(() => {
9 | const cleanup = lazyLoad('img[loading="lazy"]');
10 | // The timeout gives enough time to display the blurred image and to start
11 | // loading the images, the Pictrs component creates a new lazyLoad instance
12 | // that will handle the actual display of loaded image.
13 | setTimeout(() => {
14 | cleanup();
15 | });
16 | });
17 |
18 | if (!document.documentElement.hasAttribute("data-bs-theme")) {
19 | const light = window.matchMedia("(prefers-color-scheme: light)").matches;
20 | document.documentElement.setAttribute(
21 | "data-bs-theme",
22 | light ? "light" : "dark",
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/server/handlers/manifest-handler.ts:
--------------------------------------------------------------------------------
1 | import { getHttpBaseInternal } from "@utils/env";
2 | import type { Request, Response } from "express";
3 | import { LemmyHttp } from "lemmy-js-client";
4 | import { wrapClient } from "../../shared/services/HttpService";
5 | import generateManifestJson from "../utils/generate-manifest-json";
6 |
7 | let manifest: Awaited> | undefined =
8 | undefined;
9 |
10 | export default async (_req: Request, res: Response) => {
11 | if (!manifest) {
12 | const client = wrapClient(new LemmyHttp(getHttpBaseInternal()));
13 | const site = await client.getSite();
14 |
15 | if (site.state === "success") {
16 | manifest = await generateManifestJson(site.data.site_view.site);
17 | } else {
18 | res.sendStatus(500);
19 | return;
20 | }
21 | }
22 |
23 | res.setHeader("content-type", "application/manifest+json");
24 |
25 | res.send(manifest);
26 | };
27 |
--------------------------------------------------------------------------------
/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 | componentWillUnmount(): void {
13 | const { errorPageData, siteRes } = this.isoData;
14 | if (errorPageData || !siteRes) {
15 | // Without reload the error data is still present at the new route
16 | window.location.reload();
17 | }
18 | }
19 |
20 | render() {
21 | const errorPageData = this.isoData.errorPageData;
22 | const siteRes = this.isoData.siteRes;
23 |
24 | if (errorPageData || !siteRes) {
25 | return ;
26 | } else {
27 | return this.props.children;
28 | }
29 | }
30 | }
31 |
32 | export default ErrorGuard;
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/server/utils/dev-env.ts:
--------------------------------------------------------------------------------
1 | /** Returns the default when the environment variable is either undefined or
2 | * the empty string, considers every value other than "true" as `false`. */
3 | function envBoolean(
4 | envVar: string,
5 | default_: boolean = process.env.NODE_ENV === "development",
6 | ) {
7 | const envVal = process.env[envVar];
8 | if (envVal) {
9 | return envVal === "true";
10 | }
11 | return default_;
12 | }
13 |
14 | /** Defaults to true in development, false in production. */
15 | export const enableEruda = envBoolean("LEMMY_UI_ERUDA");
16 |
17 | /** Disabled by default, as `.css.map` files are untracked and only exist
18 | * after running `pnpm themes:build`. They also become possibly outdated after
19 | * switching between commits.
20 | */
21 | export const serveCssMaps = envBoolean("LEMMY_UI_SERVE_CSS_MAPS", false);
22 |
23 | /** Defaults to true in development, false in production. */
24 | export const enableResponseBodyCompression = envBoolean("LEMMY_UI_COMPRESSION");
25 |
--------------------------------------------------------------------------------
/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": ["es2022", "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, // false for non-legacy decorators
20 | "strictNullChecks": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "paths": {
23 | "@/*": ["/*"],
24 | "@utils/*": ["shared/utils/*"],
25 | "@services/*": ["shared/services/*"],
26 | "@components/*": ["shared/components/*"]
27 | }
28 | },
29 | "include": [
30 | "src/shared/build-config.d.ts",
31 | "src/**/*.ts",
32 | "src/**/*.tsx",
33 | "node_modules/inferno/dist/index.d.ts"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/src/shared/components/common/language-list.tsx:
--------------------------------------------------------------------------------
1 | import { I18NextService } from "@services/index";
2 | import { Language } from "lemmy-js-client";
3 |
4 | interface LanguageListProps {
5 | allLanguages?: Language[];
6 | languageIds?: number[];
7 | }
8 |
9 | export function LanguageList({ allLanguages, languageIds }: LanguageListProps) {
10 | const langs = allLanguages?.filter(x => languageIds?.includes(x.id));
11 |
12 | const showLanguages =
13 | allLanguages && langs && langs.length < allLanguages.length;
14 |
15 | return (
16 | showLanguages && (
17 |
18 |
19 | {langs.map(l => (
20 | -
21 | {languageName(l)}
22 |
23 | ))}
24 |
25 |
26 | )
27 | );
28 | }
29 |
30 | export function languageName(l: Language): string {
31 | if (l.id === 0) {
32 | return I18NextService.i18n.t("unknown_language");
33 | } else {
34 | return l.name;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/server/handlers/code-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 | res.status(400).send("Theme must be a css file");
15 | return;
16 | }
17 |
18 | const customTheme = path.resolve(extraThemesFolder, theme);
19 |
20 | if (existsSync(customTheme)) {
21 | res.sendFile(customTheme);
22 | } else {
23 | const internalTheme = path.resolve(
24 | `./dist/assets/css/code-themes/${theme}`,
25 | );
26 |
27 | // If the theme doesn't exist, just send atom-one-light
28 | if (existsSync(internalTheme)) {
29 | res.sendFile(internalTheme);
30 | } else {
31 | res.sendFile(
32 | path.resolve("./dist/assets/css/code-themes/atom-one-light.css"),
33 | );
34 | }
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/src/shared/components/common/auth-guard.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from "inferno";
2 | import { RouteComponentProps } from "inferno-router/dist/Route";
3 | import { Spinner } from "./icon";
4 | import { getQueryString } from "@utils/helpers";
5 | import { isBrowser } from "@utils/browser";
6 | import { MyUserInfo } from "lemmy-js-client";
7 |
8 | interface AuthGuardProps extends RouteComponentProps> {
9 | myUserInfo: MyUserInfo | undefined;
10 | }
11 |
12 | export default class AuthGuard extends Component {
13 | constructor(props: AuthGuardProps, context: any) {
14 | super(props, context);
15 | }
16 |
17 | hasAuth() {
18 | return this.props.myUserInfo;
19 | }
20 |
21 | componentWillMount() {
22 | if (!this.hasAuth() && isBrowser()) {
23 | const { pathname, search } = this.props.location;
24 | this.context.router.history.replace(
25 | `/login${getQueryString({ prev: pathname + search })}`,
26 | );
27 | }
28 | }
29 |
30 | render() {
31 | return this.hasAuth() ? this.props.children : ;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/shared/build-config.js:
--------------------------------------------------------------------------------
1 | // Don't import/require things here. This file is also imported in
2 | // webpack.config.js. Needs dev server restart to apply changes.
3 |
4 | /** Bundled highlighters can be autodetected in markdown.
5 | * @type ["plaintext", ...string[]] **/
6 | // prettier-ignore
7 | const bundledSyntaxHighlighters = [
8 | "plaintext",
9 | // The 'Common' set of highlight.js languages.
10 | "bash", "c", "cpp", "csharp", "css", "diff", "go", "graphql", "ini", "java",
11 | "javascript", "json", "kotlin", "less", "lua", "makefile", "markdown",
12 | "objectivec", "perl", "php-template", "php", "python-repl", "python", "r",
13 | "ruby", "rust", "scss", "shell", "sql", "swift", "typescript", "vbnet",
14 | "wasm", "xml", "yaml",
15 | ];
16 |
17 | /** Lazy highlighters can't be autodetected, they have to be explicitly specified
18 | * as the language. (e.g. ```dockerfile ...)
19 | * "*" enables all non-bundled languages
20 | * @type string[] | "*" **/
21 | const lazySyntaxHighlighters = "*";
22 |
23 | module.exports = {
24 | bundledSyntaxHighlighters,
25 | lazySyntaxHighlighters,
26 | };
27 |
--------------------------------------------------------------------------------
/src/shared/components/home/legal.tsx:
--------------------------------------------------------------------------------
1 | import { setIsoData } from "@utils/app";
2 | import { Component } from "inferno";
3 | import { mdToHtml } from "@utils/markdown";
4 | import { I18NextService } from "../../services";
5 | import { HtmlTags } from "../common/html-tags";
6 |
7 | export class Legal extends Component {
8 | private isoData = setIsoData(this.context);
9 |
10 | constructor(props: any, context: any) {
11 | super(props, context);
12 | }
13 |
14 | get documentTitle(): string {
15 | return I18NextService.i18n.t("legal_information");
16 | }
17 |
18 | render() {
19 | const legal = this.isoData.siteRes?.site_view.local_site.legal_information;
20 | return (
21 |
22 |
26 | {legal && (
27 |
this.forceUpdate())}
30 | />
31 | )}
32 |
33 | );
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/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.25;
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.25;
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/shared/utils/media.ts:
--------------------------------------------------------------------------------
1 | const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/;
2 |
3 | export function isImage(url: string) {
4 | return imageRegex.test(url);
5 | }
6 |
7 | const animatedImageRegex = /(http)?s?:?(\/\/[^"']*\.(?:gif))/;
8 |
9 | export function isAnimatedImage(url: string) {
10 | return animatedImageRegex.test(url);
11 | }
12 |
13 | const videoRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp4|webm|ogv))/;
14 |
15 | const audioRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp3|wav|opus|ogg|m4a|flac|spx))/;
16 |
17 | export function isVideo(url: string) {
18 | return videoRegex.test(url);
19 | }
20 |
21 | export function isAudio(url: string) {
22 | return audioRegex.test(url);
23 | }
24 |
25 | /**
26 | * Is true if its an image, audio, or video
27 | **/
28 | export function isMedia(url: string) {
29 | return isImage(url) || isVideo(url) || isAudio(url);
30 | }
31 |
32 | const magnetLinkRegex = /^magnet:\?xt=urn:btih:[0-9a-fA-F]{40,}.*$/;
33 |
34 | export function isMagnetLink(url: string) {
35 | return magnetLinkRegex.test(url);
36 | }
37 |
38 | export function extractMagnetLinkDownloadName(url: string) {
39 | return new URLSearchParams(url).get("dn");
40 | }
41 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/dev.dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:24-alpine as builder
2 | RUN apk update && apk add curl python3 build-base gcc wget git --no-cache
3 |
4 | # Install corepack
5 | RUN npm install -g corepack
6 |
7 | RUN corepack enable pnpm
8 |
9 | WORKDIR /usr/src/app
10 |
11 | ENV npm_config_target_arch=x64
12 | ENV npm_config_target_platform=linux
13 | ENV npm_config_target_libc=musl
14 |
15 | # Cache deps
16 | COPY package.json pnpm-lock.yaml ./
17 | RUN pnpm i --prefer-offline
18 |
19 | # Build
20 | COPY generate_translations.js \
21 | tsconfig.json \
22 | webpack.config.js \
23 | .babelrc \
24 | ./
25 |
26 | COPY lemmy-translations lemmy-translations
27 | COPY src src
28 | COPY .git .git
29 |
30 | # Set UI version
31 | RUN echo "export const VERSION = 'dev';" > "src/shared/version.ts"
32 | RUN echo "export const BUILD_DATE_ISO8601 = '$(date -u +"%Y-%m-%dT%H:%M:%SZ")';" > "src/shared/build-date.ts"
33 |
34 | RUN pnpm i --prefer-offline
35 | RUN pnpm build:dev
36 |
37 | FROM node:24-alpine as runner
38 | COPY --from=builder /usr/src/app/dist /app/dist
39 | COPY --from=builder /usr/src/app/node_modules /app/node_modules
40 |
41 | EXPOSE 1234
42 | WORKDIR /app
43 | CMD node --enable-source-maps dist/js/server.js
44 |
--------------------------------------------------------------------------------
/src/shared/components/home/federation-mode-select.tsx:
--------------------------------------------------------------------------------
1 | import { LinkedEvent, FormEvent, Component } from "inferno";
2 | import { FederationMode } from "lemmy-js-client";
3 | import { I18NextService } from "../../services";
4 | import { NoOptionI18nKeys } from "i18next";
5 |
6 | interface FederationModeSelectProps
{
7 | id: string;
8 | current: FederationMode;
9 | onChange: LinkedEvent> | null;
10 | }
11 |
12 | const modes: { value: FederationMode; i18nKey: NoOptionI18nKeys }[] = [
13 | { value: "all", i18nKey: "all" },
14 | { value: "local", i18nKey: "local" },
15 | { value: "disable", i18nKey: "disable" },
16 | ];
17 |
18 | export class FederationModeSelect extends Component<
19 | FederationModeSelectProps
20 | > {
21 | render() {
22 | return (
23 | <>
24 |
36 | >
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/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: $blue;
19 | $secondary: $gray-600;
20 | $success: $green;
21 | $info: $orange;
22 | $danger: $red;
23 |
24 | $body-color: $gray-700;
25 | $body-bg: #fff;
26 | $border-color: rgba($body-color, 0.25);
27 | $mark-bg: rgb(255, 252, 239);
28 | $headings-color: $gray-700;
29 |
30 | $font-family-sans-serif:
31 | -apple-system, BlinkMacSystemFont, "Droid Sans", "Segoe UI", Verdana, "Arimo",
32 | "Helvetica", Arial, sans-serif;
33 | $font-weight-bold: 600;
34 |
35 | $navbar-dark-toggler-border-color: rgba($black, 0.1);
36 | $navbar-light-color: $gray-600;
37 | $navbar-light-hover-color: $gray-900;
38 | $navbar-light-active-color: $gray-900;
39 |
40 | $form-feedback-valid-color: $info;
41 | $input-btn-focus-color: rgba($primary, 0.75);
42 |
43 | $border-radius: 0.5rem;
44 | $border-radius-lg: 0.5rem;
45 | $border-radius-sm: 1rem;
46 | $rounded-pill: 0.25rem;
47 |
48 | $hr-border-color: rgba($body-color, 0.25);
49 |
--------------------------------------------------------------------------------
/src/server/handlers/theme-handler.ts:
--------------------------------------------------------------------------------
1 | import type { Request, Response } from "express";
2 | import { existsSync } from "fs";
3 | import path from "path";
4 | import { serveCssMaps } from "../utils/dev-env";
5 |
6 | const extraThemesFolder =
7 | process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes";
8 |
9 | export default async (req: Request, res: Response) => {
10 | const theme = req.params.name;
11 |
12 | if (theme.endsWith(".css")) {
13 | res.contentType("text/css");
14 | } else if (theme.endsWith(".css.map")) {
15 | res.contentType("application/json");
16 | }
17 |
18 | if (
19 | !theme.endsWith(".css") &&
20 | !(serveCssMaps && theme.endsWith(".css.map"))
21 | ) {
22 | res.status(400).send("Theme must be a css file");
23 | return;
24 | }
25 |
26 | const customTheme = path.resolve(extraThemesFolder, theme);
27 |
28 | if (existsSync(customTheme)) {
29 | res.sendFile(customTheme);
30 | } else {
31 | const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`);
32 |
33 | // If the theme doesn't exist, just send litely
34 | if (existsSync(internalTheme)) {
35 | res.sendFile(internalTheme);
36 | } else {
37 | res.sendFile(path.resolve("./dist/assets/css/themes/litely.css"));
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/shared/components/app/code-theme.tsx:
--------------------------------------------------------------------------------
1 | import { dataBsTheme } from "@utils/browser";
2 | import { Component } from "inferno";
3 | import { Helmet } from "inferno-helmet";
4 |
5 | interface CodeThemeProps {
6 | theme: string;
7 | }
8 |
9 | export class CodeTheme extends Component {
10 | render() {
11 | const { theme } = this.props;
12 | const hasTheme = theme !== "browser" && theme !== "browser-compact";
13 |
14 | if (!hasTheme) {
15 | return (
16 |
17 |
23 |
29 |
30 | );
31 | }
32 |
33 | return (
34 |
35 | {dataBsTheme(theme) === "dark" ? (
36 |
41 | ) : (
42 |
47 | )}
48 |
49 | );
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/assets/css/themes/_variables.i386-dark.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 | $mark-bg-dark: #463b00;
60 |
--------------------------------------------------------------------------------
/src/shared/components/common/radio-button-group.tsx:
--------------------------------------------------------------------------------
1 | import { I18NextService } from "@services/index";
2 | import { randomStr } from "@utils/helpers";
3 | import classNames from "classnames";
4 | import { NoOptionI18nKeys } from "i18next";
5 |
6 | export interface RadioOption {
7 | value: string;
8 | i18n?: NoOptionI18nKeys;
9 | }
10 |
11 | export interface RadioButtonGroupProps {
12 | className?: string;
13 | allOptions: RadioOption[];
14 | currentOption: string;
15 | onClick(val: string): void;
16 | }
17 |
18 | export function RadioButtonGroup(props: RadioButtonGroupProps) {
19 | const radioId = randomStr();
20 | return (
21 |
28 | {props.allOptions.map(state => (
29 | <>
30 | props.onClick(state.value)}
37 | />
38 |
48 | >
49 | ))}
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/client/index.tsx:
--------------------------------------------------------------------------------
1 | import { hydrate } from "inferno-hydrate";
2 | import { BrowserRouter } from "inferno-router";
3 | import App from "../shared/components/app/app";
4 | import { lazyHighlightjs } from "@utils/lazy-highlightjs";
5 | import { loadLanguageInstances } from "@services/I18NextService";
6 | import { verifyDynamicImports } from "@utils/dynamic-imports";
7 | import { setupMarkdown } from "@utils/markdown";
8 |
9 | import "bootstrap/js/dist/collapse";
10 | import "bootstrap/js/dist/dropdown";
11 | import "bootstrap/js/dist/modal";
12 |
13 | async function startClient() {
14 | // Allows to test imports from the browser console.
15 | window.checkLazyScripts = () => {
16 | verifyDynamicImports(true).then(x => console.debug(x));
17 | };
18 |
19 | window.history.scrollRestoration = "manual";
20 |
21 | setupMarkdown();
22 | lazyHighlightjs.enableLazyLoading();
23 |
24 | const fallbackLanguages = window.navigator.languages;
25 | const interfaceLanguage =
26 | window.isoData?.myUserInfo?.local_user_view.local_user.interface_language;
27 |
28 | // Make sure the language is loaded before hydration.
29 | const [[dateFnsLocale, i18n]] = await Promise.all([
30 | loadLanguageInstances(fallbackLanguages, interfaceLanguage),
31 | ]);
32 |
33 | const wrapper = (
34 |
35 |
36 |
37 | );
38 |
39 | const root = document.getElementById("root");
40 |
41 | if (root) {
42 | hydrate(wrapper, root);
43 |
44 | root.dispatchEvent(new CustomEvent("lemmy-hydrated", { bubbles: true }));
45 | }
46 | }
47 |
48 | startClient();
49 |
--------------------------------------------------------------------------------
/src/shared/components/common/post-listing-mode-select.tsx:
--------------------------------------------------------------------------------
1 | import { randomStr } from "@utils/helpers";
2 | import { Component, linkEvent } from "inferno";
3 | import { PostListingMode } from "lemmy-js-client";
4 | import { I18NextService } from "../../services";
5 | import { NoOptionI18nKeys } from "i18next";
6 |
7 | interface Props {
8 | current: PostListingMode;
9 | onChange(val: PostListingMode): void;
10 | }
11 |
12 | type Choice = {
13 | key: NoOptionI18nKeys;
14 | value: PostListingMode;
15 | };
16 |
17 | const choices: Choice[] = [
18 | { key: "list", value: "list" },
19 | { key: "card", value: "card" },
20 | { key: "small_card", value: "small_card" },
21 | ];
22 |
23 | export class PostListingModeSelect extends Component {
24 | private id = `post-listing-mode-select-${randomStr()}`;
25 |
26 | constructor(props: any, context: any) {
27 | super(props, context);
28 | }
29 |
30 | render() {
31 | return (
32 |
51 | );
52 | }
53 |
54 | handleChange(i: PostListingModeSelect, event: any) {
55 | i.props.onChange(event.target.value);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/shared/utils/env.ts:
--------------------------------------------------------------------------------
1 | import { isBrowser } from "@utils/browser";
2 | import { testHost } from "@utils/config";
3 | import { IsoDataOptionalSite } from "@utils/types";
4 |
5 | // The browser uses the hosts in IsoData.
6 | const hosts = !isBrowser() && {
7 | backendInternal: getBaseUrl(
8 | process.env.LEMMY_UI_BACKEND_INTERNAL ??
9 | process.env.LEMMY_UI_BACKEND ??
10 | testHost,
11 | ),
12 | backendExternal: getBaseUrl(process.env.LEMMY_UI_BACKEND ?? testHost),
13 | };
14 |
15 | export function getHttpBaseInternal() {
16 | if (!hosts) {
17 | throw new Error("Can't access internal backend host");
18 | }
19 | return hosts.backendInternal;
20 | }
21 |
22 | export function getBackendHostExternal() {
23 | return !hosts ? window.isoData.lemmyBackend : hosts.backendExternal;
24 | }
25 |
26 | export function getBaseUrl(host: string) {
27 | const hasProtocol = /^https?:\/\//.test(host);
28 | const defaultProtocol =
29 | process.env.LEMMY_UI_HTTPS === "true" ? "https:" : "http:";
30 | const fullHost = hasProtocol ? host : defaultProtocol + "//" + host;
31 | const url = new URL(fullHost);
32 | const { protocol, hostname, port } = url;
33 | return `${protocol}//${hostname}${port ? ":" + port : ""}`;
34 | }
35 |
36 | /**
37 | * Returns path to static directory, intended
38 | * for cache-busting based on latest commit hash.
39 | */
40 | export function getStaticDir() {
41 | return `/static/${process.env.COMMIT_HASH}`;
42 | }
43 |
44 | export function httpFrontendUrl(path: string, isoData: IsoDataOptionalSite) {
45 | return `${isoData.lemmyFrontend}${path}`;
46 | }
47 |
48 | export function httpBackendUrl(path: string) {
49 | return `${getBackendHostExternal()}${path}`;
50 | }
51 |
52 | export function isHttps() {
53 | return window.location.protocol === "https:";
54 | }
55 |
--------------------------------------------------------------------------------
/src/shared/components/common/blocking-keywords-textarea.tsx:
--------------------------------------------------------------------------------
1 | import { linkEvent, Component } from "inferno";
2 | import { I18NextService } from "../../services/I18NextService";
3 |
4 | interface Props {
5 | keywords: string[];
6 | onUpdate(keywords: string[]): void;
7 | }
8 |
9 | interface State {
10 | text: string;
11 | }
12 |
13 | function handleTextChange(i: BlockingKeywordsTextArea, event: any) {
14 | i.setState({ text: event.target.value });
15 | }
16 |
17 | function handleTextBlur(i: BlockingKeywordsTextArea, _event: any) {
18 | const keywords = fromText(i.state.text);
19 | i.props.onUpdate(keywords);
20 | }
21 |
22 | function toText(keywords: string[]): string {
23 | return keywords.join("\n");
24 | }
25 |
26 | function fromText(text: string): string[] {
27 | // Split lines
28 | const intermediateText = text.replace(/\s+/g, "\n");
29 |
30 | // Split by newlines, and filter out empty strings
31 | // Note: an empty array is a Some(None) / erase
32 | const keywords = intermediateText.split("\n").filter(k => k.trim());
33 | return keywords;
34 | }
35 |
36 | export default class BlockingKeywordsTextArea extends Component {
37 | state: State = {
38 | text: toText(this.props.keywords),
39 | };
40 |
41 | render() {
42 | return (
43 |
44 |
47 |
48 |
57 |
58 |
59 | );
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/shared/components/community/community-header.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from "inferno";
2 | import { Community, MyUserInfo } from "lemmy-js-client";
3 | import { I18NextService } from "../../services";
4 | import { BannerIconHeader } from "../common/banner-icon-header";
5 | import { Icon } from "../common/icon";
6 | import { CommunityLink } from "./community-link";
7 | import { LoadingEllipses } from "../common/loading-ellipses";
8 |
9 | type CommunityHeaderProps = {
10 | community?: Community;
11 | urlCommunityName?: string;
12 | myUserInfo: MyUserInfo | undefined;
13 | };
14 |
15 | export class CommunityHeader extends Component {
16 | render() {
17 | const { community, urlCommunityName } = this.props;
18 | return (
19 |
20 | {community && (
21 |
22 | )}
23 |
24 |
32 | {community?.title ?? (
33 | <>
34 | {urlCommunityName}
35 |
36 | >
37 | )}
38 |
39 | {community?.posting_restricted_to_mods && (
40 |
41 | )}
42 |
43 | {(community && (
44 |
52 | )) ??
53 | urlCommunityName}
54 |
55 | );
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import pluginJs from "@eslint/js";
2 | import tseslint from "typescript-eslint";
3 | import prettier from "eslint-plugin-prettier/recommended";
4 | import jsxa11y from "eslint-plugin-jsx-a11y";
5 | import inferno from "eslint-plugin-inferno";
6 |
7 | export default [
8 | pluginJs.configs.recommended,
9 | ...tseslint.configs.recommended,
10 | prettier,
11 | {
12 | plugins: {
13 | inferno: inferno,
14 | rules: inferno.configs.recommended,
15 | },
16 | },
17 | {
18 | plugins: {
19 | "jsx-a11y": jsxa11y,
20 | },
21 | rules: jsxa11y.configs.recommended.rules,
22 | },
23 | {
24 | languageOptions: {
25 | parser: tseslint.parser,
26 | },
27 | },
28 | // For some reason this has to be in its own block
29 | {
30 | ignores: [
31 | "generate_translations.js",
32 | "webpack.config.js",
33 | "src/shared/build-config.js",
34 | "src/api_tests",
35 | "**/*.png",
36 | "**/*.css",
37 | "**/*.scss",
38 | "**/*.svg",
39 | "src/shared/translations/**",
40 | "dist/*",
41 | ".yalc/*",
42 | ],
43 | },
44 | {
45 | files: ["src/**/*.js", "src/**/*.mjs", "src/**/*.ts", "src/**/*.tsx"],
46 | rules: {
47 | "@typescript-eslint/no-explicit-any": 0,
48 | "no-console": ["error", { allow: ["warn", "error", "debug", "assert"] }],
49 | "inferno/jsx-boolean-value": "error",
50 | "inferno/jsx-props-class-name": "error",
51 | "@typescript-eslint/no-unused-vars": [
52 | "error",
53 | { argsIgnorePattern: "^_" },
54 | ],
55 | eqeqeq: "error",
56 | "no-restricted-imports": [
57 | "error",
58 | {
59 | patterns: [
60 | {
61 | group: ["assets/*", "client/*", "server/*", "shared/*"],
62 | message: "Use relative import instead.",
63 | },
64 | ],
65 | },
66 | ],
67 | },
68 | },
69 | ];
70 |
--------------------------------------------------------------------------------
/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: any) {
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/shared/utils/dynamic-imports.ts:
--------------------------------------------------------------------------------
1 | import {
2 | verifyDateFnsImports,
3 | verifyTranslationImports,
4 | } from "@services/I18NextService";
5 | import { verifyHighlighjsImports } from "./lazy-highlightjs";
6 |
7 | export class ImportReport {
8 | error: Array<{ id: string; error: Error | string | undefined }> = [];
9 | success: string[] = [];
10 | message?: string;
11 | }
12 |
13 | export type ImportReportCollection = {
14 | translation?: ImportReport;
15 | "date-fns"?: ImportReport;
16 | "highlight.js"?: ImportReport;
17 | };
18 |
19 | function collect(
20 | verbose: boolean,
21 | kind: keyof ImportReportCollection,
22 | collection: ImportReportCollection,
23 | report: ImportReport,
24 | ) {
25 | collection[kind] = report;
26 | if (verbose) {
27 | for (const { id, error } of report.error) {
28 | console.warn(`${kind} "${id}" failed: ${error}`);
29 | }
30 | const message = report.message ? ` (${report.message})` : "";
31 | const good = report.success.length;
32 | const bad = report.error.length;
33 | if (bad) {
34 | console.error(
35 | `${bad} out of ${bad + good} ${kind} imports failed.` + message,
36 | );
37 | } else {
38 | console.debug(`${good} ${kind} imports verified.` + message);
39 | }
40 | }
41 | }
42 |
43 | // This verifies that the parameters used for parameterized imports are
44 | // correct, that the respective chunks are reachable or bundled, and that the
45 | // returned objects match expectations.
46 | export async function verifyDynamicImports(
47 | verbose: boolean,
48 | ): Promise {
49 | const collection: ImportReportCollection = {};
50 | await verifyTranslationImports().then(report =>
51 | collect(verbose, "translation", collection, report),
52 | );
53 | await verifyDateFnsImports().then(report =>
54 | collect(verbose, "date-fns", collection, report),
55 | );
56 | await verifyHighlighjsImports().then(report =>
57 | collect(verbose, "highlight.js", collection, report),
58 | );
59 | return collection;
60 | }
61 |
--------------------------------------------------------------------------------
/src/shared/components/common/html-tags.tsx:
--------------------------------------------------------------------------------
1 | import { httpFrontendUrl } 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 "@utils/markdown";
6 | import { I18NextService } from "../../services";
7 | import { setIsoData } from "@utils/app";
8 |
9 | interface HtmlTagsProps {
10 | title: string;
11 | path: string;
12 | canonicalPath?: string;
13 | description?: string;
14 | image?: string;
15 | }
16 |
17 | /// Taken from https://metatags.io/
18 | export class HtmlTags extends Component {
19 | render() {
20 | const url = httpFrontendUrl(this.props.path, setIsoData(this.context));
21 | const canonicalUrl = this.props.canonicalPath ?? url;
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/server/middleware.ts:
--------------------------------------------------------------------------------
1 | import * as crypto from "crypto";
2 | import type { NextFunction, Request, Response } from "express";
3 | import { getJwtCookie } 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: blob:;
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 | let caching: string;
43 |
44 | // Only allow caching for success responses
45 | if (res.statusCode >= 200 && res.statusCode < 400) {
46 | if (
47 | req.path.match(/\.(js|css|txt|manifest\.webmanifest)\/?$/) ||
48 | req.path.includes("/css/themelist")
49 | ) {
50 | // Static content gets cached publicly for a day
51 | caching = "public, max-age=86400";
52 | } else {
53 | res.setHeader("Vary", "Cookie, Accept, Accept-Language");
54 | if (getJwtCookie(req.headers)) {
55 | caching = "private";
56 | } else {
57 | caching = "public, max-age=60";
58 | }
59 | }
60 |
61 | res.setHeader("Cache-Control", caching);
62 | }
63 |
64 | next();
65 | }
66 |
--------------------------------------------------------------------------------
/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 "@utils/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 |
56 | );
57 | } else {
58 | return <>>;
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/shared/services/UnreadCounterService.ts:
--------------------------------------------------------------------------------
1 | import { HttpService } from "@services/index";
2 | import { updateUnreadCountsInterval } from "@utils/config";
3 | import { poll } from "@utils/helpers";
4 | import { myAuth } from "@utils/app";
5 | import { BehaviorSubject } from "rxjs";
6 | import { MyUserInfo } from "lemmy-js-client";
7 |
8 | /**
9 | * Service to poll and keep track of unread notifications count which is shown in the navbar.
10 | */
11 | export class UnreadCounterService {
12 | public notificationCount: BehaviorSubject =
13 | new BehaviorSubject(0);
14 |
15 | public unreadReportCount: BehaviorSubject =
16 | new BehaviorSubject(0);
17 |
18 | public unreadApplicationCount: BehaviorSubject =
19 | new BehaviorSubject(0);
20 |
21 | public pendingFollowCount: BehaviorSubject =
22 | new BehaviorSubject(0);
23 |
24 | private enableUnreadCounts: boolean = false;
25 |
26 | static #instance: UnreadCounterService;
27 |
28 | private get shouldUpdate() {
29 | if (window.document.visibilityState === "hidden") {
30 | return false;
31 | }
32 | if (!myAuth()) {
33 | return false;
34 | }
35 | return true;
36 | }
37 |
38 | public configure(myUserInfo: MyUserInfo | undefined) {
39 | this.enableUnreadCounts = !!myUserInfo;
40 | poll(async () => this.updateUnreadCounts(), updateUnreadCountsInterval);
41 | }
42 |
43 | public async updateUnreadCounts() {
44 | if (this.shouldUpdate && this.enableUnreadCounts) {
45 | const unreadCountRes = await HttpService.client.getUnreadCounts();
46 | if (unreadCountRes.state === "success") {
47 | const data = unreadCountRes.data;
48 | this.notificationCount.next(data.notification_count);
49 | if (data.report_count) {
50 | this.unreadReportCount.next(data.report_count);
51 | }
52 | if (data.pending_follow_count) {
53 | this.pendingFollowCount.next(data.pending_follow_count);
54 | }
55 | if (data.registration_application_count) {
56 | this.unreadApplicationCount.next(data.registration_application_count);
57 | }
58 | }
59 | }
60 | }
61 |
62 | static get Instance() {
63 | return this.#instance ?? (this.#instance = new this());
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/shared/components/common/url-list-textarea.tsx:
--------------------------------------------------------------------------------
1 | import { linkEvent, Component } from "inferno";
2 | import { I18NextService } from "../../services/I18NextService";
3 |
4 | interface UrlListTextareaProps {
5 | urls: string[];
6 | onUpdate(urls: string[]): void;
7 | }
8 |
9 | interface UrlListTextareaState {
10 | text: string;
11 | }
12 |
13 | function handleTextChange(i: UrlListTextarea, event: any) {
14 | i.setState({ text: event.target.value });
15 | }
16 |
17 | const URL_SCHEME = "https://";
18 |
19 | function processUrl(str: string) {
20 | return new URL(str).toString().replace(URL_SCHEME, "");
21 | }
22 |
23 | function handleTextBlur(i: UrlListTextarea, event: any) {
24 | const inputValue: string = event.currentTarget?.value ?? "";
25 |
26 | const intermediateText = inputValue.replace(/\s+/g, "\n");
27 | const newUrls: string[] = [];
28 |
29 | for (const str of intermediateText.split("\n")) {
30 | let url: string;
31 |
32 | try {
33 | url = processUrl(str);
34 | } catch {
35 | try {
36 | url = processUrl(URL_SCHEME + str);
37 | } catch {
38 | continue;
39 | }
40 | }
41 |
42 | if (newUrls.every(u => u !== url)) {
43 | newUrls.push(url);
44 | }
45 | }
46 |
47 | i.setState({ text: newUrls.join("\n") });
48 | i.props.onUpdate(newUrls);
49 | }
50 |
51 | export default class UrlListTextarea extends Component<
52 | UrlListTextareaProps,
53 | UrlListTextareaState
54 | > {
55 | state: UrlListTextareaState = {
56 | text: this.props.urls.join("\n"),
57 | };
58 |
59 | render() {
60 | return (
61 |
62 |
68 |
69 |
70 |
79 |
80 |
81 | );
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/shared/components/common/icon.tsx:
--------------------------------------------------------------------------------
1 | import { getStaticDir } from "@utils/env";
2 | import classNames from "classnames";
3 | import { Component } from "inferno";
4 | import { I18NextService } from "../../services";
5 |
6 | interface IconProps {
7 | icon: string;
8 | classes?: string;
9 | inline?: boolean;
10 | small?: boolean;
11 | }
12 |
13 | export class Icon extends Component {
14 | constructor(props: any, context: any) {
15 | super(props, context);
16 | }
17 |
18 | render() {
19 | let iconAltText: string | undefined;
20 | if (
21 | this.props.icon === "plus-square" ||
22 | this.props.icon === "minus-square"
23 | ) {
24 | iconAltText = `${I18NextService.i18n.t("show_content")}`;
25 | }
26 |
27 | return (
28 |
46 | );
47 | }
48 | }
49 |
50 | interface SpinnerProps {
51 | large?: boolean;
52 | className?: string;
53 | }
54 |
55 | export class Spinner extends Component {
56 | constructor(props: any, context: any) {
57 | super(props, context);
58 | }
59 |
60 | render() {
61 | return (
62 |
68 | );
69 | }
70 | }
71 |
72 | export class PurgeWarning extends Component {
73 | constructor(props: any, context: any) {
74 | super(props, context);
75 | }
76 |
77 | render() {
78 | return (
79 |
80 |
81 | {I18NextService.i18n.t("purge_warning")}
82 |
83 | );
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/shared/utils/tippy.ts:
--------------------------------------------------------------------------------
1 | import { RefObject } from "inferno";
2 | import {
3 | DelegateInstance as TippyDelegateInstance,
4 | Props as TippyProps,
5 | Instance as TippyInstance,
6 | delegate as tippyDelegate,
7 | } from "tippy.js";
8 |
9 | let instance: TippyDelegateInstance | undefined;
10 | const tippySelector = "[data-tippy-content]";
11 | const shownInstances: Set> = new Set();
12 | let instanceCounter = 0;
13 |
14 | const tippyDelegateOptions: Partial & { target: string } = {
15 | delay: [500, 0],
16 | // Display on "long press"
17 | touch: ["hold", 500],
18 | target: tippySelector,
19 | onShow(i: TippyInstance) {
20 | shownInstances.add(i);
21 | },
22 | onHidden(i: TippyInstance) {
23 | shownInstances.delete(i);
24 | },
25 | onCreate() {
26 | instanceCounter++;
27 | },
28 | onDestroy(i: TippyInstance) {
29 | // Tippy doesn't remove its onDocumentPress listener when destroyed.
30 | // Instead the listener removes itself after calling hide for hideOnClick.
31 | const origHide = i.hide;
32 | // This silences the first warning when hiding a destroyed tippy instance.
33 | // hide() is otherwise a noop for destroyed instances.
34 | i.hide = () => {
35 | i.hide = origHide;
36 | };
37 | },
38 | };
39 |
40 | export function setupTippy(root: RefObject) {
41 | if (!instance && root.current) {
42 | instance = tippyDelegate(root.current, tippyDelegateOptions);
43 | }
44 | }
45 |
46 | export function cleanupTippy() {
47 | // Hide tooltips for elements that are no longer connected to the document.
48 | shownInstances.forEach(i => {
49 | if (!i.reference.isConnected) {
50 | console.assert(!i.state.isDestroyed, "hide called on destroyed tippy");
51 | i.hide();
52 | }
53 | });
54 |
55 | if (shownInstances.size || instanceCounter < 10) {
56 | // Avoid randomly closing tooltips.
57 | return;
58 | }
59 | instanceCounter = 0;
60 | const current = instance?.reference ?? null;
61 | // delegate from tippy.js creates tippy instances when needed, but only
62 | // destroys them when the delegate instance is destroyed.
63 | destroyTippy();
64 | setupTippy({ current });
65 | }
66 |
67 | export function destroyTippy() {
68 | instance?.destroy();
69 | instance = undefined;
70 | }
71 |
--------------------------------------------------------------------------------
/src/shared/components/common/modal/display-modal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | InfernoNode,
4 | MouseEventHandler,
5 | RefObject,
6 | createRef,
7 | } from "inferno";
8 | import type { Modal } from "bootstrap";
9 | import { Spinner } from "../icon";
10 | import { LoadingEllipses } from "../loading-ellipses";
11 | import { modalMixin } from "../../mixins/modal-mixin";
12 |
13 | interface DisplayModalProps {
14 | children: InfernoNode;
15 | loadingMessage?: string;
16 | title: string;
17 | onClose: MouseEventHandler;
18 | show: boolean;
19 | loading?: boolean;
20 | }
21 |
22 | @modalMixin
23 | export default class DisplayModal extends Component {
24 | readonly modalDivRef: RefObject;
25 | modal?: Modal;
26 |
27 | constructor(props: DisplayModalProps, context: any) {
28 | super(props, context);
29 |
30 | this.modalDivRef = createRef();
31 | }
32 |
33 | render() {
34 | const { children, loadingMessage, title, onClose, loading } = this.props;
35 |
36 | return (
37 |
46 |
47 |
48 |
49 |
50 | {title}
51 |
52 |
58 |
59 |
60 | {loading ? (
61 |
62 |
63 |
64 | {loadingMessage}
65 |
66 |
67 |
68 | ) : (
69 | children
70 | )}
71 |
72 |
73 |
74 |
75 | );
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/shared/components/common/post-hidden-select.tsx:
--------------------------------------------------------------------------------
1 | import { StringBoolean } from "@utils/types";
2 | import classNames from "classnames";
3 | import { Icon } from "./icon";
4 | import { tippyMixin } from "../mixins/tippy-mixin";
5 | import { Component, linkEvent } from "inferno";
6 | import { I18NextService } from "../../services/I18NextService";
7 |
8 | interface PostHiddenSelectProps {
9 | showHidden?: StringBoolean;
10 | onShowHiddenChange: (hidden?: StringBoolean) => void;
11 | }
12 |
13 | function handleShowHiddenChange(i: PostHiddenSelect, event: any) {
14 | i.props.onShowHiddenChange(event.target.value);
15 | }
16 |
17 | @tippyMixin
18 | export default class PostHiddenSelect extends Component<
19 | PostHiddenSelectProps,
20 | never
21 | > {
22 | render() {
23 | const { showHidden } = this.props;
24 |
25 | return (
26 |
30 |
38 |
48 |
56 |
66 |
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/shared/services/UserService.ts:
--------------------------------------------------------------------------------
1 | import { isAuthPath } from "@utils/app";
2 | import { clearAuthCookie, isBrowser, setAuthCookie } from "@utils/browser";
3 | import * as cookie from "cookie";
4 | import { jwtDecode } from "jwt-decode";
5 | import { LoginResponse } from "lemmy-js-client";
6 | import { toast } from "@utils/app";
7 | import { I18NextService } from "./I18NextService";
8 | import { HttpService } from ".";
9 | import { authCookieName } from "@utils/config";
10 |
11 | interface Claims {
12 | sub: number;
13 | iss: string;
14 | iat: number;
15 | }
16 |
17 | interface AuthInfo {
18 | claims: Claims;
19 | auth: string;
20 | }
21 |
22 | export class UserService {
23 | static #instance: UserService;
24 | public authInfo?: AuthInfo;
25 |
26 | private constructor() {
27 | this.#setAuthInfo();
28 | }
29 |
30 | public login({
31 | res,
32 | showToast = true,
33 | }: {
34 | res: LoginResponse;
35 | showToast?: boolean;
36 | }) {
37 | if (isBrowser() && res.jwt) {
38 | if (showToast) {
39 | toast(I18NextService.i18n.t("logged_in"));
40 | }
41 | setAuthCookie(res.jwt);
42 | this.#setAuthInfo();
43 | }
44 | }
45 |
46 | public logout() {
47 | this.authInfo = undefined;
48 |
49 | if (isBrowser()) {
50 | clearAuthCookie();
51 | }
52 |
53 | HttpService.client.logout();
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 auth = this.authInfo?.auth;
64 |
65 | if (auth) {
66 | return auth;
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 | #setAuthInfo() {
81 | if (isBrowser()) {
82 | const auth = cookie.parse(document.cookie)[authCookieName];
83 |
84 | if (auth) {
85 | HttpService.client.setHeaders({ Authorization: `Bearer ${auth}` });
86 | this.authInfo = { auth, claims: jwtDecode(auth) };
87 | }
88 | }
89 | }
90 |
91 | public static get Instance() {
92 | return this.#instance || (this.#instance = new this());
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/shared/components/multi-community/multi-community-link.tsx:
--------------------------------------------------------------------------------
1 | // TODO this should probably be combined with community-link and person-link
2 | import { hostname } from "@utils/helpers";
3 | import { Component } from "inferno";
4 | import { Link } from "inferno-router";
5 | import { MultiCommunity, MyUserInfo } from "lemmy-js-client";
6 | import { relTags } from "@utils/config";
7 |
8 | interface Props {
9 | multiCommunity: MultiCommunity;
10 | realLink?: boolean;
11 | useApubName?: boolean;
12 | muted?: boolean;
13 | myUserInfo: MyUserInfo | undefined;
14 | }
15 |
16 | export class MultiCommunityLink extends Component {
17 | constructor(props: any, context: any) {
18 | super(props, context);
19 | }
20 |
21 | render() {
22 | const { multiCommunity, useApubName } = this.props;
23 |
24 | const title = useApubName
25 | ? multiCommunity.name
26 | : (multiCommunity.title ?? multiCommunity.name);
27 |
28 | const { link, serverStr } = multiCommunityLink(
29 | multiCommunity,
30 | this.props.realLink,
31 | );
32 |
33 | const classes = `community-link ${this.props.muted ? "text-muted" : ""}`;
34 |
35 | return !this.props.realLink ? (
36 |
37 | {this.name(title, serverStr)}
38 |
39 | ) : (
40 |
41 | {this.name(title, serverStr)}
42 |
43 | );
44 | }
45 |
46 | name(title: string, serverStr?: string) {
47 | return (
48 |
49 | {title}
50 | {serverStr && {serverStr}}
51 |
52 | );
53 | }
54 | }
55 |
56 | export type MultiCommunityLinkAndServerStr = {
57 | link: string;
58 | serverStr?: string;
59 | };
60 |
61 | export function multiCommunityLink(
62 | multiCommunity: MultiCommunity,
63 | realLink: boolean = false,
64 | ): MultiCommunityLinkAndServerStr {
65 | const local = multiCommunity.local === null ? true : multiCommunity.local;
66 |
67 | if (local) {
68 | return { link: `/m/${multiCommunity.name}` };
69 | } else {
70 | const serverStr = `@${hostname(multiCommunity.ap_id)}`;
71 | const link = realLink
72 | ? multiCommunity.ap_id
73 | : `/c/${multiCommunity.name}${serverStr}`;
74 |
75 | return { link, serverStr };
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/shared/components/common/paginator-cursor.tsx:
--------------------------------------------------------------------------------
1 | import { Component, linkEvent } from "inferno";
2 | import { I18NextService } from "../../services";
3 | import { PaginationCursor } from "lemmy-js-client";
4 | import { RequestState } from "@services/HttpService";
5 |
6 | interface PaginatedResource {
7 | next_page?: PaginationCursor;
8 | prev_page?: PaginationCursor;
9 | }
10 |
11 | interface PaginatorCursorProps {
12 | current: PaginationCursor | undefined;
13 | resource: RequestState;
14 | onPageChange(cursor?: PaginationCursor): void;
15 | }
16 |
17 | function handleNext(i: PaginatorCursor) {
18 | if (i.nextPage) {
19 | i.props.onPageChange(i.nextPage);
20 | }
21 | }
22 |
23 | function handlePrev(i: PaginatorCursor) {
24 | if (i.prevPage) {
25 | i.props.onPageChange(i.prevPage);
26 | }
27 | }
28 |
29 | function handleFirstPage(i: PaginatorCursor) {
30 | i.props.onPageChange(undefined);
31 | }
32 |
33 | export class PaginatorCursor extends Component {
34 | constructor(props: any, context: any) {
35 | super(props, context);
36 | }
37 |
38 | get nextPage(): PaginationCursor | undefined {
39 | return this.props.resource.state === "success"
40 | ? this.props.resource.data.next_page
41 | : undefined;
42 | }
43 |
44 | get prevPage(): PaginationCursor | undefined {
45 | return this.props.resource.state === "success"
46 | ? this.props.current
47 | ? this.props.resource.data.prev_page
48 | : undefined
49 | : undefined;
50 | }
51 |
52 | render() {
53 | return (
54 |
55 |
62 |
69 | {!this.prevPage && !this.nextPage && this.props.current && (
70 |
76 | )}
77 |
78 | );
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/shared/components/common/content-actions/action-button.tsx:
--------------------------------------------------------------------------------
1 | import { Component, linkEvent } from "inferno";
2 | import { Icon, Spinner } from "../icon";
3 | import classNames from "classnames";
4 | import { tippyMixin } from "../../mixins/tippy-mixin";
5 |
6 | interface ActionButtonPropsBase {
7 | label: string;
8 | icon: string;
9 | iconClass?: string;
10 | inline?: boolean;
11 | inlineWithText?: boolean;
12 | noLoading?: boolean;
13 | disabled?: boolean;
14 | }
15 |
16 | interface ActionButtonPropsLoading extends ActionButtonPropsBase {
17 | onClick: () => void;
18 | noLoading?: false;
19 | }
20 |
21 | interface ActionButtonPropsNoLoading extends ActionButtonPropsBase {
22 | onClick: () => void;
23 | noLoading: true;
24 | }
25 |
26 | type ActionButtonProps = ActionButtonPropsLoading | ActionButtonPropsNoLoading;
27 |
28 | interface ActionButtonState {
29 | loading: boolean;
30 | }
31 |
32 | function handleClick(i: ActionButton) {
33 | if (!i.props.noLoading) {
34 | i.setState({ loading: true });
35 | }
36 | i.props.onClick();
37 | i.setState({ loading: false });
38 | }
39 |
40 | @tippyMixin
41 | export default class ActionButton extends Component<
42 | ActionButtonProps,
43 | ActionButtonState
44 | > {
45 | state: ActionButtonState = {
46 | loading: false,
47 | };
48 |
49 | constructor(props: ActionButtonProps, context: any) {
50 | super(props, context);
51 | }
52 |
53 | render() {
54 | const { label, icon, iconClass, inline, inlineWithText } = this.props;
55 |
56 | return (
57 |
82 | );
83 | }
84 | }
85 |
86 | ActionButton.defaultProps = {
87 | inline: false,
88 | noLoading: false,
89 | };
90 |
--------------------------------------------------------------------------------
/src/shared/utils/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 torrentHelpUrl = `${markdownHelpUrl}#torrents`;
13 | export const sortingHelpUrl = `${joinLemmyUrl}/docs/en/users/03-votes-and-ranking.html`;
14 | export const archiveTodayUrl = "https://archive.today";
15 | export const ghostArchiveUrl = "https://ghostarchive.org";
16 | export const webArchiveUrl = "https://web.archive.org";
17 | export const matrixUrl = "https://matrix.org/try-matrix/";
18 |
19 | export const postRefetchSeconds: number = 60 * 1000;
20 | export const mentionDropdownFetchLimit = 10;
21 | export const commentTreeMaxDepth = 8;
22 | export const postMarkdownFieldCharacterLimit = 50000;
23 | export const markdownFieldCharacterLimit = 10000;
24 | export const maxUploadImages = 20;
25 | export const concurrentImageUpload = 4;
26 | export const updateUnreadCountsInterval = 30000;
27 | export const fetchLimit = 20;
28 | export const similarPostFetchLimit = 6;
29 | export const relTags = "noopener nofollow";
30 | export const emDash = "\u2014";
31 | export const authCookieName = "jwt";
32 | export const adultConsentCookieKey = "adultConsent";
33 |
34 | // No. of max displayed communities per
35 | // page on route "/communities"
36 | export const communityLimit = 50;
37 | export const multiCommunityLimit = 50;
38 |
39 | const queryPairRegex = "[a-zA-Zd_-]+=[a-zA-Zd+-_]+";
40 |
41 | /**
42 | * Accepted formats:
43 | * !community@server.com
44 | * /c/community@server.com
45 | * /m/community@server.com
46 | * /u/username@server.com
47 | * @username@server.com
48 | */
49 | export const instanceLinkRegex = new RegExp(
50 | `(/[cmu]/|!|@)[a-zA-Z\\d._%+-]+@[a-zA-Z\\d.-]+\\.[a-zA-Z]{2,}(?:/?\\?${queryPairRegex}(?:&${queryPairRegex})*)?`,
51 | "g",
52 | );
53 |
54 | export const testHost = "localhost:8536";
55 |
56 | export const validActorRegexPattern =
57 | "^\\w+|[\\p{Script=Arabic}\\d_]+|[\\p{Script=Cyrillic}\\d_]+$";
58 |
--------------------------------------------------------------------------------
/.woodpecker.yml:
--------------------------------------------------------------------------------
1 | variables:
2 | - &install_pnpm "npm i -g corepack && corepack enable pnpm"
3 |
4 | steps:
5 | fetch_git_submodules:
6 | image: node:24-alpine
7 | commands:
8 | - apk add git
9 | - git submodule init
10 | - git submodule update
11 | when:
12 | - event: [pull_request, tag]
13 |
14 | prettier_check:
15 | image: jauderho/prettier:3.7.4-alpine
16 | commands:
17 | - prettier --no-config --arrow-parens avoid -c .
18 | when:
19 | - event: pull_request
20 |
21 | bash_fmt:
22 | image: alpine:3
23 | commands:
24 | - apk add shfmt
25 | - shfmt -i 2 -d */**.bash
26 | - shfmt -i 2 -d */**.sh
27 | when:
28 | - event: pull_request
29 |
30 | install:
31 | image: node:24-alpine
32 | commands:
33 | - *install_pnpm
34 | - pnpm i
35 | when:
36 | - event: pull_request
37 |
38 | lint:
39 | image: node:24-alpine
40 | commands:
41 | - *install_pnpm
42 | - pnpm lint
43 | when:
44 | - event: pull_request
45 |
46 | build_dev:
47 | image: node:24-alpine
48 | commands:
49 | - *install_pnpm
50 | - pnpm prebuild:dev
51 | - pnpm build:dev
52 | when:
53 | - event: pull_request
54 |
55 | publish_release_docker:
56 | image: woodpeckerci/plugin-docker-buildx
57 | settings:
58 | repo: dessalines/lemmy-ui
59 | dockerfile: Dockerfile
60 | username:
61 | from_secret: docker_username
62 | password:
63 | from_secret: docker_password
64 | platforms: linux/amd64,linux/arm64
65 | tag: ${CI_COMMIT_TAG=nightly}
66 | when:
67 | - event: tag
68 | - event: cron
69 |
70 | notify_success:
71 | image: alpine:3
72 | commands:
73 | - apk add curl
74 | - "curl -H'Title: ✔️ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci"
75 | when:
76 | - event: pull_request
77 | status: [success]
78 |
79 | notify_failure:
80 | image: alpine:3
81 | commands:
82 | - apk add curl
83 | - "curl -H'Title: ❌ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci"
84 | when:
85 | - event: pull_request
86 | status: [failure]
87 |
88 | notify_on_tag_deploy:
89 | image: alpine:3
90 | commands:
91 | - apk add curl
92 | - "curl -H'Title: ${CI_REPO_NAME}:${CI_COMMIT_TAG} deployed' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci"
93 | when:
94 | event: tag
95 |
--------------------------------------------------------------------------------
/src/shared/components/person/verify-email.tsx:
--------------------------------------------------------------------------------
1 | import { setIsoData } from "@utils/app";
2 | import { Component } from "inferno";
3 | import { SuccessResponse } from "lemmy-js-client";
4 | import { I18NextService } from "../../services";
5 | import {
6 | EMPTY_REQUEST,
7 | HttpService,
8 | LOADING_REQUEST,
9 | RequestState,
10 | } from "../../services/HttpService";
11 | import { toast } from "@utils/app";
12 | import { HtmlTags } from "../common/html-tags";
13 | import { Spinner } from "../common/icon";
14 | import { simpleScrollMixin } from "../mixins/scroll-mixin";
15 | import { RouteComponentProps } from "inferno-router/dist/Route";
16 | import { isBrowser } from "@utils/browser";
17 |
18 | interface State {
19 | verifyRes: RequestState;
20 | }
21 |
22 | @simpleScrollMixin
23 | export class VerifyEmail extends Component<
24 | RouteComponentProps>,
25 | State
26 | > {
27 | private isoData = setIsoData(this.context);
28 |
29 | state: State = {
30 | verifyRes: EMPTY_REQUEST,
31 | };
32 |
33 | constructor(props: any, context: any) {
34 | super(props, context);
35 | }
36 |
37 | async verify() {
38 | this.setState({
39 | verifyRes: LOADING_REQUEST,
40 | });
41 |
42 | this.setState({
43 | verifyRes: await HttpService.client.verifyEmail({
44 | token: this.props.match.params.token,
45 | }),
46 | });
47 |
48 | if (this.state.verifyRes.state === "success") {
49 | toast(I18NextService.i18n.t("email_verified"));
50 | this.props.history.push("/login");
51 | }
52 | }
53 |
54 | async componentWillMount() {
55 | if (isBrowser()) {
56 | await this.verify();
57 | }
58 | }
59 |
60 | get documentTitle(): string {
61 | return `${I18NextService.i18n.t("verify_email")} - ${
62 | this.isoData.siteRes?.site_view.site.name
63 | }`;
64 | }
65 |
66 | render() {
67 | return (
68 |
69 |
73 |
74 |
75 |
{I18NextService.i18n.t("verify_email")}
76 | {this.state.verifyRes.state === "loading" && (
77 |
78 |
79 |
80 | )}
81 |
82 |
83 |
84 | );
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/shared/components/app/error-page.tsx:
--------------------------------------------------------------------------------
1 | import { setIsoData } from "@utils/app";
2 | import { Component } from "inferno";
3 | import { T } from "inferno-i18next-dess";
4 | import { Link } from "inferno-router";
5 | import { IsoData } from "@utils/types";
6 | import { I18NextService } from "../../services";
7 |
8 | export class ErrorPage extends Component {
9 | private isoData: IsoData = setIsoData(this.context);
10 |
11 | constructor(props: any, context: any) {
12 | super(props, context);
13 | }
14 |
15 | render() {
16 | const { errorPageData } = this.isoData;
17 |
18 | return (
19 |
20 |
21 | {errorPageData
22 | ? I18NextService.i18n.t("error_page_title")
23 | : I18NextService.i18n.t("not_found_page_title")}
24 |
25 | {errorPageData ? (
26 |
27 | ###
28 | ##
29 |
30 | ) : (
31 |
{I18NextService.i18n.t("not_found_page_message")}
32 | )}
33 | {!errorPageData && (
34 |
35 | {I18NextService.i18n.t("not_found_return_home_button")}
36 |
37 | )}
38 | {errorPageData?.adminMatrixIds &&
39 | errorPageData.adminMatrixIds.length > 0 && (
40 | <>
41 |
42 | {I18NextService.i18n.t("error_page_admin_matrix", {
43 | instance:
44 | this.isoData.siteRes?.site_view.site.name ??
45 | "this instance",
46 | })}
47 |
48 |
49 | {errorPageData.adminMatrixIds.map(matrixId => (
50 | -
51 | {matrixId}
52 |
53 | ))}
54 |
55 | >
56 | )}
57 | {errorPageData?.error && (
58 |
65 | ###
66 |
67 | )}
68 |
69 | );
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/shared/components/multi-community/create-multi-community.tsx:
--------------------------------------------------------------------------------
1 | import { setIsoData } from "@utils/app";
2 | import { Component } from "inferno";
3 | import { CreateMultiCommunity as CreateMultiCommunityI } from "lemmy-js-client";
4 | import { HttpService, I18NextService } from "../../services";
5 | import { HtmlTags } from "../common/html-tags";
6 | import { MultiCommunityForm } from "./multi-community-form";
7 | import { simpleScrollMixin } from "../mixins/scroll-mixin";
8 | import { RouteComponentProps } from "inferno-router/dist/Route";
9 | import { toast } from "@utils/app";
10 | import { NoOptionI18nKeys } from "i18next";
11 |
12 | interface State {
13 | loading: boolean;
14 | }
15 |
16 | @simpleScrollMixin
17 | export class CreateMultiCommunity extends Component<
18 | RouteComponentProps>,
19 | State
20 | > {
21 | private isoData = setIsoData(this.context);
22 | state: State = {
23 | loading: false,
24 | };
25 |
26 | constructor(props: any, context: any) {
27 | super(props, context);
28 | this.handleCreate = this.handleCreate.bind(this);
29 | }
30 |
31 | get documentTitle(): string {
32 | return `${I18NextService.i18n.t("create_multi_community")} - ${
33 | this.isoData.siteRes?.site_view.site.name
34 | }`;
35 | }
36 |
37 | render() {
38 | return (
39 |
40 |
44 |
45 |
46 |
47 | {I18NextService.i18n.t("create_multi_community")}
48 |
49 |
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | async handleCreate(form: CreateMultiCommunityI) {
61 | this.setState({ loading: true });
62 |
63 | const res = await HttpService.client.createMultiCommunity(form);
64 |
65 | if (res.state === "success" && this.isoData.myUserInfo) {
66 | const name = res.data.multi_community_view.multi.name;
67 | this.props.history.replace(`/m/${name}`);
68 | } else if (res.state === "failed") {
69 | toast(I18NextService.i18n.t(res.err.name as NoOptionI18nKeys), "danger");
70 | }
71 | this.setState({ loading: false });
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/.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/common/post-or-comment-type-select.tsx:
--------------------------------------------------------------------------------
1 | import { randomStr } from "@utils/helpers";
2 | import classNames from "classnames";
3 | import { Component, linkEvent } from "inferno";
4 | import { PostOrCommentType } from "@utils/types";
5 | import { I18NextService } from "../../services";
6 |
7 | interface PostOrCommentTypeSelectProps {
8 | type_: PostOrCommentType;
9 | onChange?(val: PostOrCommentType): any;
10 | }
11 |
12 | interface PostOrCommentTypeSelectState {
13 | type_: PostOrCommentType;
14 | }
15 |
16 | export class PostOrCommentTypeSelect extends Component<
17 | PostOrCommentTypeSelectProps,
18 | PostOrCommentTypeSelectState
19 | > {
20 | private id = `data-type-input-${randomStr()}`;
21 |
22 | state: PostOrCommentTypeSelectState = {
23 | type_: this.props.type_,
24 | };
25 |
26 | constructor(props: any, context: any) {
27 | super(props, context);
28 | }
29 |
30 | // Necessary in case the props change
31 | static getDerivedStateFromProps(props: any): PostOrCommentTypeSelectProps {
32 | return {
33 | type_: props.type_,
34 | };
35 | }
36 |
37 | render() {
38 | return (
39 |
43 |
51 |
59 |
60 |
68 |
76 |
77 | );
78 | }
79 |
80 | handleTypeChange(i: PostOrCommentTypeSelect, event: any) {
81 | i.props.onChange?.(event.target.value);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/shared/components/common/moment-time.tsx:
--------------------------------------------------------------------------------
1 | import { capitalizeFirstLetter } from "@utils/helpers";
2 | import { formatRelativeDate } from "@utils/date";
3 | import { addMinutes, format, isBefore, parseISO } from "date-fns";
4 | import { Component } from "inferno";
5 | import { I18NextService } from "../../services";
6 | import { Icon } from "./icon";
7 | import { tippyMixin } from "../mixins/tippy-mixin";
8 |
9 | interface MomentTimeProps {
10 | published: string;
11 | updated?: string;
12 | showAgo?: boolean;
13 | ignoreUpdated?: boolean;
14 | }
15 |
16 | function formatDate(input: string) {
17 | const parsed = parseISO(input + "Z");
18 | return format(parsed, "PPPPpppp");
19 | }
20 |
21 | @tippyMixin
22 | export class MomentTime extends Component {
23 | constructor(props: any, context: any) {
24 | super(props, context);
25 | }
26 |
27 | createdAndModifiedTimes() {
28 | const updated = this.updatedTime;
29 | let line = `${capitalizeFirstLetter(
30 | I18NextService.i18n.t("created"),
31 | )}: ${formatDate(this.props.published)}`;
32 | if (updated) {
33 | line += `\n\n\n${capitalizeFirstLetter(
34 | I18NextService.i18n.t("modified"),
35 | )} ${formatDate(updated)}`;
36 | }
37 | return line;
38 | }
39 |
40 | isRecentlyUpdated(): boolean {
41 | if (this.props.updated) {
42 | const published = new Date(this.props.published);
43 | const updated = new Date(this.props.updated);
44 | const updateLimit = addMinutes(published, 5);
45 | return isBefore(updated, updateLimit);
46 | } else {
47 | return false;
48 | }
49 | }
50 |
51 | get updatedTime(): string | undefined {
52 | if (!this.isRecentlyUpdated()) {
53 | return this.props.updated;
54 | } else {
55 | return;
56 | }
57 | }
58 |
59 | render() {
60 | if (!this.props.ignoreUpdated && this.updatedTime) {
61 | return (
62 |
66 |
67 | {formatRelativeDate(this.updatedTime, this.props.showAgo)}
68 |
69 | );
70 | } else {
71 | const published = this.props.published;
72 | return (
73 |
77 | {formatRelativeDate(published, this.props.showAgo)}
78 |
79 | );
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/shared/components/common/loading-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from "inferno";
2 |
3 | interface LoadingSkeletonProps {
4 | itemCount?: number;
5 | }
6 |
7 | interface LoadingSkeletonLineProps {
8 | size: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
9 | }
10 |
11 | class LoadingSkeletonLine extends Component {
12 | render() {
13 | const className = "placeholder placeholder-lg col-" + this.props.size;
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 | }
21 |
22 | export class PostsLoadingSkeleton extends Component {
23 | render() {
24 | return Array.from({ length: this.props.itemCount ?? 10 }, (_, index) => (
25 |
26 | ));
27 | }
28 | }
29 |
30 | class PostThumbnailLoadingSkeleton extends Component {
31 | render() {
32 | return (
33 |
34 |
35 |
36 | );
37 | }
38 | }
39 |
40 | class PostsLoadingSkeletonItem extends Component {
41 | render() {
42 | return (
43 |
44 |
45 |
46 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | }
59 | }
60 |
61 | export class CommentsLoadingSkeleton extends Component {
62 | render() {
63 | return Array.from({ length: this.props.itemCount ?? 10 }, (_, index) => (
64 |
65 | ));
66 | }
67 | }
68 |
69 | class CommentsLoadingSkeletonItem extends Component {
70 | render() {
71 | return (
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | );
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/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 "@utils/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 |
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/server/utils/generate-manifest-json.ts:
--------------------------------------------------------------------------------
1 | import { Site } from "lemmy-js-client";
2 | import { fetchIconPng } from "./fetch-icon-png";
3 | import { getStaticDir } from "@utils/env";
4 |
5 | type Icon = { sizes: string; src: string; type: string; purpose: string };
6 | const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512];
7 | let icons: Icon[] | null = null;
8 |
9 | function mapIcon(src: string, size: number): Icon {
10 | return {
11 | sizes: `${size}x${size}`,
12 | type: "image/png",
13 | src,
14 | purpose: "any maskable",
15 | };
16 | }
17 |
18 | function generateDefaultIcons() {
19 | return iconSizes.map(size =>
20 | mapIcon(`${getStaticDir()}/assets/icons/icon-${size}x${size}.png`, size),
21 | );
22 | }
23 |
24 | export default async function (site: Site) {
25 | if (!icons) {
26 | try {
27 | const icon = site.icon ? await fetchIconPng(site.icon) : null;
28 |
29 | if (icon) {
30 | icons = await Promise.all(
31 | iconSizes.map(async size => {
32 | const sharp = (await import("sharp")).default;
33 | const src = `data:image/png;base64,${await sharp(icon)
34 | .resize(size, size)
35 | .png()
36 | .toBuffer()
37 | .then(buf => buf.toString("base64"))}`;
38 |
39 | return mapIcon(src, size);
40 | }),
41 | );
42 | } else {
43 | icons = generateDefaultIcons();
44 | }
45 | } catch {
46 | console.warn(
47 | `Failed to fetch site logo for manifest icon. Using default icon`,
48 | );
49 | icons = generateDefaultIcons();
50 | }
51 | }
52 |
53 | return {
54 | name: site.name,
55 | description: site.description ?? "A link aggregator for the fediverse",
56 | start_url: "/",
57 | scope: "/",
58 | display: "standalone",
59 | id: "/",
60 | background_color: "#222222",
61 | theme_color: "#222222",
62 | icons,
63 | shortcuts: [
64 | {
65 | name: "Search",
66 | short_name: "Search",
67 | description: "Perform a search.",
68 | url: "/search",
69 | },
70 | {
71 | name: "communities",
72 | url: "/communities",
73 | short_name: "communities",
74 | description: "Browse communities",
75 | },
76 | {
77 | name: "Create Post",
78 | url: "/create_post",
79 | short_name: "Create Post",
80 | description: "Create a post.",
81 | },
82 | ],
83 | related_applications: [
84 | {
85 | platform: "f-droid",
86 | url: "https://f-droid.org/packages/com.jerboa/",
87 | id: "com.jerboa",
88 | },
89 | ],
90 | };
91 | }
92 |
--------------------------------------------------------------------------------
/src/shared/components/mixins/modal-mixin.ts:
--------------------------------------------------------------------------------
1 | import { Modal } from "bootstrap";
2 | import { Component, InfernoNode, RefObject } from "inferno";
3 |
4 | export function modalMixin<
5 | P extends { show?: boolean },
6 | S,
7 | Base extends new (...args: any[]) => Component & {
8 | readonly modalDivRef: RefObject;
9 | handleShow?(): void;
10 | handleHide?(): void;
11 | },
12 | >(base: Base, _context?: ClassDecoratorContext) {
13 | return class extends base {
14 | modal?: Modal;
15 | constructor(...args: any[]) {
16 | super(...args);
17 | this.handleHide = this.handleHide?.bind(this);
18 | this.handleShow = this.handleShow?.bind(this);
19 | }
20 |
21 | private addModalListener(type: string, listener?: () => void) {
22 | if (listener) {
23 | this.modalDivRef.current?.addEventListener(type, listener);
24 | }
25 | }
26 |
27 | private removeModalListener(type: string, listener?: () => void) {
28 | if (listener) {
29 | this.modalDivRef.current?.addEventListener(type, listener);
30 | }
31 | }
32 |
33 | componentDidMount() {
34 | // Keeping this sync to allow the super implementation to be sync
35 | import("bootstrap/js/dist/modal").then(
36 | (res: { default: typeof Modal }) => {
37 | if (!this.modalDivRef.current) {
38 | return;
39 | }
40 |
41 | // bootstrap tries to touch `document` during import, which makes
42 | // the import fail on the server.
43 |
44 | const Modal = res.default;
45 |
46 | this.addModalListener("shown.bs.modal", this.handleShow);
47 | this.addModalListener("hidden.bs.modal", this.handleHide);
48 |
49 | this.modal = new Modal(this.modalDivRef.current!);
50 |
51 | if (this.props.show) {
52 | this.modal.show();
53 | }
54 | },
55 | );
56 | return super.componentDidMount?.();
57 | }
58 |
59 | componentWillUnmount() {
60 | this.removeModalListener("shown.bs.modal", this.handleShow);
61 | this.removeModalListener("hidden.bs.modal", this.handleHide);
62 |
63 | this.modal?.dispose();
64 | return super.componentWillUnmount?.();
65 | }
66 |
67 | componentWillReceiveProps(
68 | nextProps: Readonly<{ children?: InfernoNode } & P>,
69 | nextContext: any,
70 | ) {
71 | if (nextProps.show !== this.props.show) {
72 | if (nextProps.show) {
73 | this.modal?.show();
74 | } else {
75 | this.modal?.hide();
76 | }
77 | }
78 | return super.componentWillReceiveProps?.(nextProps, nextContext);
79 | }
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/src/shared/components/home/donation-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from "inferno";
2 | import { MyUserInfo } from "lemmy-js-client";
3 | import { HttpService, I18NextService } from "../../services";
4 | import { T } from "inferno-i18next-dess";
5 | import { donateLemmyUrl } from "@utils/config";
6 |
7 | interface Props {
8 | myUserInfo?: MyUserInfo;
9 | }
10 |
11 | interface State {
12 | show: boolean;
13 | }
14 |
15 | export class DonationDialog extends Component {
16 | state: State = { show: this.initializeShow() };
17 |
18 | constructor(props: any, context: any) {
19 | super(props, context);
20 | this.clickDonate = this.clickDonate.bind(this);
21 | this.clickHide = this.clickHide.bind(this);
22 | this.hideDialog = this.hideDialog.bind(this);
23 | }
24 |
25 | initializeShow(): boolean {
26 | const lastNotifDate = new Date(
27 | this.props.myUserInfo?.local_user_view.local_user
28 | .last_donation_notification_at ?? Number.MAX_SAFE_INTEGER,
29 | );
30 |
31 | const oneYearAgo = new Date();
32 | oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
33 | return lastNotifDate < oneYearAgo;
34 | }
35 |
36 | render() {
37 | if (this.state.show) {
38 | return (
39 |
40 |
41 |
42 | {I18NextService.i18n.t("donation_dialog_title")}
43 |
44 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
60 |
61 |
62 | );
63 | }
64 | }
65 |
66 | async clickDonate() {
67 | window.open(donateLemmyUrl, "_blank")?.focus();
68 | await this.hideDialog();
69 | }
70 |
71 | async clickHide() {
72 | await this.hideDialog();
73 | }
74 |
75 | async hideDialog() {
76 | await HttpService.client.donationDialogShown();
77 | const my_user = this.props.myUserInfo;
78 | if (my_user !== undefined) {
79 | my_user!.local_user_view.local_user.last_donation_notification_at =
80 | new Date(0).toString();
81 | }
82 | this.setState({ show: false });
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/shared/services/HttpService.ts:
--------------------------------------------------------------------------------
1 | import { getBackendHostExternal } from "@utils/env";
2 | import { LemmyHttp } from "lemmy-js-client";
3 |
4 | export const EMPTY_REQUEST = {
5 | state: "empty",
6 | } as const;
7 |
8 | export type EmptyRequestState = typeof EMPTY_REQUEST;
9 |
10 | export const LOADING_REQUEST = {
11 | state: "loading",
12 | } as const;
13 |
14 | type LoadingRequestState = typeof LOADING_REQUEST;
15 |
16 | export type FailedRequestState = {
17 | state: "failed";
18 | err: Error;
19 | };
20 |
21 | type SuccessRequestState = {
22 | state: "success";
23 | data: T;
24 | };
25 |
26 | /**
27 | * Shows the state of an API request.
28 | *
29 | * Can be empty, loading, failed, or success
30 | */
31 | export type RequestState =
32 | | EmptyRequestState
33 | | LoadingRequestState
34 | | FailedRequestState
35 | | SuccessRequestState;
36 |
37 | export type WrappedLemmyHttp = WrappedLemmyHttpClient & {
38 | [K in keyof LemmyHttp]: LemmyHttp[K] extends (...args: any[]) => any
39 | ? ReturnType extends Promise
40 | ? (...args: Parameters) => Promise>
41 | : (
42 | ...args: Parameters
43 | ) => Promise>
44 | : LemmyHttp[K];
45 | };
46 |
47 | class WrappedLemmyHttpClient {
48 | rawClient: LemmyHttp;
49 |
50 | constructor(client: LemmyHttp) {
51 | this.rawClient = client;
52 |
53 | for (const key of Object.getOwnPropertyNames(
54 | Object.getPrototypeOf(this.rawClient),
55 | )) {
56 | if (key !== "constructor") {
57 | this[key] = async (...args: any) => {
58 | try {
59 | const res = await this.rawClient[key](...args);
60 |
61 | return {
62 | data: res,
63 | state: !(res === undefined || res === null) ? "success" : "empty",
64 | };
65 | } catch (error) {
66 | return {
67 | state: "failed",
68 | err: error,
69 | };
70 | }
71 | };
72 | }
73 | }
74 | }
75 | }
76 |
77 | export function wrapClient(client: LemmyHttp) {
78 | // unfortunately, this verbose cast is necessary
79 | return new WrappedLemmyHttpClient(client) as unknown as WrappedLemmyHttp;
80 | }
81 |
82 | export class HttpService {
83 | static #_instance: HttpService;
84 | #client: WrappedLemmyHttp;
85 |
86 | private constructor() {
87 | const lemmyHttp = new LemmyHttp(getBackendHostExternal());
88 | this.#client = wrapClient(lemmyHttp);
89 | }
90 |
91 | static get #Instance() {
92 | return this.#_instance ?? (this.#_instance = new this());
93 | }
94 |
95 | public static get client() {
96 | return this.#Instance.#client;
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/shared/utils/roles.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CommentSlimView,
3 | CommentView,
4 | CommunityModeratorView,
5 | CommunityView,
6 | LocalSite,
7 | MyUserInfo,
8 | PersonView,
9 | PostView,
10 | } from "lemmy-js-client";
11 | import { userNotLoggedInOrBanned } from "./app";
12 |
13 | export function amAdmin(myUserInfo: MyUserInfo | undefined): boolean {
14 | return myUserInfo?.local_user_view.local_user.admin ?? false;
15 | }
16 |
17 | export function amCommunityCreator(
18 | creator_id: number,
19 | mods: CommunityModeratorView[] | undefined,
20 | myUserInfo: MyUserInfo | undefined,
21 | ): boolean {
22 | const myId = myUserInfo?.local_user_view.person.id;
23 | // Don't allow mod actions on yourself
24 | return myId === mods?.at(0)?.moderator.id && myId !== creator_id;
25 | }
26 |
27 | export function amMod(
28 | thing: PostView | CommentSlimView | CommentView | CommunityView,
29 | ): boolean {
30 | return thing.can_mod;
31 | }
32 |
33 | export function amSiteCreator(
34 | creator_id: number,
35 | admins: PersonView[],
36 | myUserInfo: MyUserInfo | undefined,
37 | ): boolean {
38 | const myId = myUserInfo?.local_user_view.person.id;
39 | return myId === admins?.at(0)?.person.id && myId !== creator_id;
40 | }
41 |
42 | export function amTopMod(
43 | mods: CommunityModeratorView[],
44 | myUserInfo: MyUserInfo | undefined,
45 | ): boolean {
46 | return mods.at(0)?.moderator.id === myUserInfo?.local_user_view.person.id;
47 | }
48 |
49 | export function canAdmin(
50 | creatorId: number,
51 | admins: PersonView[],
52 | myUserInfo: MyUserInfo | undefined,
53 | onSelf = false,
54 | ): boolean {
55 | const myId = myUserInfo?.local_user_view.person.id;
56 | if (!myId) {
57 | return false;
58 | }
59 | if (onSelf && creatorId !== myId) {
60 | return false;
61 | }
62 | const first =
63 | admins &&
64 | admins.find(x => x.person.id === creatorId || x.person.id === myId);
65 | // You can do admin actions only on admins added after you.
66 | return first?.person.id === myId;
67 | }
68 |
69 | export function moderatesSomething(
70 | myUserInfo: MyUserInfo | undefined,
71 | ): boolean {
72 | return amAdmin(myUserInfo) || (myUserInfo?.moderates?.length ?? 0) > 0;
73 | }
74 |
75 | export function moderatesPrivateCommunity(
76 | myUserInfo: MyUserInfo | undefined,
77 | ): boolean {
78 | return (
79 | myUserInfo?.moderates?.some(c => c.community.visibility === "private") ??
80 | false
81 | );
82 | }
83 |
84 | export function canCreateCommunity(
85 | localSite: LocalSite,
86 | myUserInfo: MyUserInfo | undefined,
87 | ): boolean {
88 | const adminOnly = localSite.community_creation_admin_only;
89 | const disableInput_ = userNotLoggedInOrBanned(myUserInfo);
90 | return (!adminOnly && !disableInput_) || amAdmin(myUserInfo);
91 | }
92 |
--------------------------------------------------------------------------------
/src/shared/components/common/content-actions/create-item-buttons.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "inferno-router";
2 | import { I18NextService } from "../../../services";
3 | import { Icon } from "../icon";
4 | import { CrossPostParams } from "@utils/types";
5 | import { InfernoNode } from "inferno";
6 | import { getQueryString } from "@utils/helpers";
7 | import { CommunityView, LocalSite, MyUserInfo } from "lemmy-js-client";
8 | import { canCreateCommunity } from "@utils/roles";
9 | import classNames from "classnames";
10 | import { userNotLoggedInOrBanned } from "@utils/app";
11 |
12 | export function CrossPostButton(props: CrossPostParams): InfernoNode {
13 | const label = I18NextService.i18n.t("cross_post");
14 | return (
15 |
25 |
26 | {label}
27 |
28 | );
29 | }
30 |
31 | type CreatePostButtonProps = {
32 | communityView?: CommunityView;
33 | };
34 | export function CreatePostButton({ communityView }: CreatePostButtonProps) {
35 | const classes = classNames("btn btn-secondary d-block mb-2 w-100", {
36 | "no-click":
37 | communityView?.community.deleted || communityView?.community.removed,
38 | });
39 |
40 | const link = communityView
41 | ? "/create_post" +
42 | getQueryString({ communityId: communityView.community.id.toString() })
43 | : "/create_post";
44 |
45 | return (
46 |
47 | {I18NextService.i18n.t("create_post")}
48 |
49 | );
50 | }
51 |
52 | type CreateCommunityButtonProps = {
53 | localSite?: LocalSite;
54 | myUserInfo: MyUserInfo | undefined;
55 | };
56 | export function CreateCommunityButton({
57 | localSite,
58 | myUserInfo,
59 | }: CreateCommunityButtonProps) {
60 | const classes = classNames("btn btn-secondary d-block mb-2 w-100", {
61 | "no-click": !(localSite && canCreateCommunity(localSite, myUserInfo)),
62 | });
63 |
64 | return (
65 |
66 | {I18NextService.i18n.t("create_community")}
67 |
68 | );
69 | }
70 |
71 | type CreateMultiCommunityButtonProps = {
72 | myUserInfo: MyUserInfo | undefined;
73 | };
74 | export function CreateMultiCommunityButton({
75 | myUserInfo,
76 | }: CreateMultiCommunityButtonProps) {
77 | const classes = classNames("btn btn-secondary d-block mb-2 w-100", {
78 | "no-click": userNotLoggedInOrBanned(myUserInfo),
79 | });
80 |
81 | return (
82 |
83 | {I18NextService.i18n.t("create_multi_community")}
84 |
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 | import { tippyMixin } from "../mixins/tippy-mixin";
6 |
7 | interface EmojiPickerProps {
8 | onEmojiClick?(val: any): any;
9 | disabled?: boolean;
10 | }
11 |
12 | interface EmojiPickerState {
13 | showPicker: boolean;
14 | }
15 |
16 | function closeEmojiMartOnEsc(i: EmojiPicker, event: KeyboardEvent): void {
17 | if (event.key === "Escape") {
18 | i.setState({ showPicker: false });
19 | }
20 | }
21 |
22 | @tippyMixin
23 | export class EmojiPicker extends Component {
24 | private emptyState: EmojiPickerState = {
25 | showPicker: false,
26 | };
27 |
28 | state: EmojiPickerState;
29 | constructor(props: EmojiPickerProps, context: any) {
30 | super(props, context);
31 | this.state = this.emptyState;
32 | this.handleEmojiClick = this.handleEmojiClick.bind(this);
33 | }
34 |
35 | render() {
36 | return (
37 |
38 |
47 |
48 | {this.state.showPicker && (
49 | <>
50 |
51 |
52 |
56 |
57 | {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
58 |
62 |
63 | >
64 | )}
65 |
66 | );
67 | }
68 |
69 | componentWillUnmount() {
70 | document.removeEventListener("keyup", e => closeEmojiMartOnEsc(this, e));
71 | }
72 |
73 | togglePicker(i: EmojiPicker, e: any) {
74 | e.preventDefault();
75 | i.setState({ showPicker: !i.state.showPicker });
76 |
77 | if (i.state.showPicker) {
78 | document.addEventListener("keyup", e => closeEmojiMartOnEsc(i, e));
79 | } else {
80 | document.removeEventListener("keyup", e => closeEmojiMartOnEsc(i, e));
81 | }
82 | }
83 |
84 | handleEmojiClick(e: any) {
85 | this.props.onEmojiClick?.(e);
86 | this.setState({ showPicker: false });
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/shared/components/post/post-listing-list.tsx:
--------------------------------------------------------------------------------
1 | import { ShowCrossPostsType } from "@utils/types";
2 | import {
3 | CreatePostLike,
4 | Language,
5 | LocalSite,
6 | MyUserInfo,
7 | PostView,
8 | } from "lemmy-js-client";
9 | import { VoteButtons } from "@components/common/vote-buttons";
10 | import { postIsInteractable, userNotLoggedInOrBanned } from "@utils/app";
11 | import { PostThumbnail } from "./post-thumbnail";
12 | import { PostCreatedLine, PostName } from "./common";
13 | import { CrossPosts } from "./cross-posts";
14 | import { CommentsButton } from "./post-action-bar";
15 |
16 | type Props = {
17 | postView: PostView;
18 | crossPosts: PostView[];
19 | allLanguages: Language[];
20 | showCommunity: boolean;
21 | hideImage: boolean;
22 | viewOnly: boolean;
23 | myUserInfo: MyUserInfo | undefined;
24 | localSite: LocalSite;
25 | showCrossPosts: ShowCrossPostsType;
26 | onPostVote(form: CreatePostLike): void;
27 | onScrollIntoCommentsClick(e: MouseEvent): void;
28 | };
29 |
30 | export function PostListingList({
31 | postView,
32 | crossPosts,
33 | allLanguages,
34 | showCommunity,
35 | hideImage,
36 | viewOnly,
37 | myUserInfo,
38 | localSite,
39 | showCrossPosts,
40 | onPostVote,
41 | onScrollIntoCommentsClick,
42 | }: Props) {
43 | return (
44 |
45 |
46 | {postIsInteractable(postView, viewOnly) && (
47 |
48 |
58 |
59 | )}
60 |
77 |
84 |
85 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/cliff.toml:
--------------------------------------------------------------------------------
1 | # git-cliff ~ configuration file
2 | # https://git-cliff.org/docs/configuration
3 |
4 | [remote.github]
5 | owner = "LemmyNet"
6 | repo = "lemmy-ui"
7 | # token = ""
8 |
9 | [changelog]
10 | # template for the changelog body
11 | # https://keats.github.io/tera/docs/#introduction
12 | body = """
13 | ## What's Changed
14 |
15 | {%- if version %} in {{ version }}{%- endif -%}
16 | {% for commit in commits %}
17 | {% if commit.remote.pr_title -%}
18 | {%- set commit_message = commit.remote.pr_title -%}
19 | {%- else -%}
20 | {%- set commit_message = commit.message -%}
21 | {%- endif -%}
22 | * {{ commit_message | split(pat="\n") | first | trim }}\
23 | {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%}
24 | {% if commit.remote.pr_number %} in \
25 | [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \
26 | {%- endif %}
27 | {%- endfor -%}
28 |
29 | {%- if github -%}
30 | {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
31 | {% raw %}\n{% endraw -%}
32 | ## New Contributors
33 | {%- endif %}\
34 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
35 | * @{{ contributor.username }} made their first contribution
36 | {%- if contributor.pr_number %} in \
37 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
38 | {%- endif %}
39 | {%- endfor -%}
40 | {%- endif -%}
41 |
42 | {% if version %}
43 | {% if previous.version %}
44 | **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}
45 | {% endif %}
46 | {% else -%}
47 | {% raw %}\n{% endraw %}
48 | {% endif %}
49 |
50 | {%- macro remote_url() -%}
51 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
52 | {%- endmacro -%}
53 | """
54 | # remove the leading and trailing whitespace from the template
55 | trim = true
56 | # template for the changelog footer
57 | footer = """
58 |
59 | """
60 | # postprocessors
61 | postprocessors = []
62 |
63 | [git]
64 | # parse the commits based on https://www.conventionalcommits.org
65 | conventional_commits = false
66 | # filter out the commits that are not conventional
67 | filter_unconventional = true
68 | # process each line of a commit as an individual commit
69 | split_commits = false
70 | # regex for preprocessing the commit messages
71 | commit_preprocessors = [
72 | # remove issue numbers from commits
73 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" },
74 | ]
75 | commit_parsers = [
76 | { field = "author.name", pattern = "renovate", skip = true },
77 | { field = "message", pattern = "Upping version", skip = true },
78 | ]
79 | # filter out the commits that are not matched by commit parsers
80 | filter_commits = false
81 | # sort the tags topologically
82 | topo_order = false
83 | # sort the commits inside sections by oldest/newest order
84 | sort_commits = "newest"
85 |
--------------------------------------------------------------------------------
/src/shared/components/community/create-community.tsx:
--------------------------------------------------------------------------------
1 | import { enableNsfw, setIsoData } from "@utils/app";
2 | import { Component } from "inferno";
3 | import { CreateCommunity as CreateCommunityI } from "lemmy-js-client";
4 | import { HttpService, I18NextService } from "../../services";
5 | import { HtmlTags } from "../common/html-tags";
6 | import { CommunityForm } from "./community-form";
7 | import { simpleScrollMixin } from "../mixins/scroll-mixin";
8 | import { RouteComponentProps } from "inferno-router/dist/Route";
9 | import { toast } from "@utils/app";
10 | import { NoOptionI18nKeys } from "i18next";
11 |
12 | interface CreateCommunityState {
13 | loading: boolean;
14 | }
15 |
16 | @simpleScrollMixin
17 | export class CreateCommunity extends Component<
18 | RouteComponentProps>,
19 | CreateCommunityState
20 | > {
21 | private isoData = setIsoData(this.context);
22 | state: CreateCommunityState = {
23 | loading: false,
24 | };
25 | constructor(props: any, context: any) {
26 | super(props, context);
27 | this.handleCommunityCreate = this.handleCommunityCreate.bind(this);
28 | }
29 |
30 | get documentTitle(): string {
31 | return `${I18NextService.i18n.t("create_community")} - ${
32 | this.isoData.siteRes?.site_view.site.name
33 | }`;
34 | }
35 |
36 | render() {
37 | return (
38 |
39 |
43 |
44 |
45 |
46 | {I18NextService.i18n.t("create_community")}
47 |
48 |
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | async handleCommunityCreate(form: CreateCommunityI) {
64 | this.setState({ loading: true });
65 |
66 | const res = await HttpService.client.createCommunity(form);
67 |
68 | if (res.state === "success" && this.isoData.myUserInfo) {
69 | const myUserInfo = this.isoData.myUserInfo;
70 | myUserInfo.moderates.push({
71 | community: res.data.community_view.community,
72 | moderator: myUserInfo.local_user_view.person,
73 | });
74 | const name = res.data.community_view.community.name;
75 | this.props.history.replace(`/c/${name}`);
76 | } else if (res.state === "failed") {
77 | toast(I18NextService.i18n.t(res.err.name as NoOptionI18nKeys), "danger");
78 | }
79 | this.setState({ loading: false });
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/shared/components/common/modal/image-upload-confirm-modal.tsx:
--------------------------------------------------------------------------------
1 | import { Component, RefObject, createRef, linkEvent } from "inferno";
2 | import { Modal } from "bootstrap";
3 | import { modalMixin } from "@components/mixins/modal-mixin";
4 | import { I18NextService } from "@services/I18NextService";
5 |
6 | interface ImageUploadConfirmModalModalProps {
7 | onConfirm: () => void;
8 | onCancel: () => void;
9 | pendingImageURL: string;
10 | show?: boolean;
11 | }
12 |
13 | @modalMixin
14 | export default class ImageUploadConfirmModalModal extends Component<
15 | ImageUploadConfirmModalModalProps,
16 | Record
17 | > {
18 | readonly modalDivRef: RefObject;
19 | readonly okButtonRef: RefObject;
20 | modal?: Modal;
21 |
22 | constructor(props: ImageUploadConfirmModalModalProps, context: any) {
23 | super(props, context);
24 |
25 | this.modalDivRef = createRef();
26 | this.okButtonRef = createRef();
27 | }
28 |
29 | render() {
30 | return (
31 |
40 |
41 |
42 |
43 |
44 | {I18NextService.i18n.t("upload_and_publish_image_title")}
45 |
46 |
47 |
48 |
49 | {I18NextService.i18n.t("upload_and_publish_image_desc")}
50 |
51 |
52 |
53 |

59 |
60 |
61 |
62 |
79 |
80 |
81 |
82 | );
83 | }
84 |
85 | handleShow() {
86 | this.okButtonRef.current?.focus();
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/shared/components/community/community-link.tsx:
--------------------------------------------------------------------------------
1 | import { hideAnimatedImage, hideImages, showAvatars } from "@utils/app";
2 | import { hostname } from "@utils/helpers";
3 | import { Component } from "inferno";
4 | import { Link } from "inferno-router";
5 | import { Community, MyUserInfo } from "lemmy-js-client";
6 | import { relTags } from "@utils/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 | myUserInfo: MyUserInfo | undefined;
16 | }
17 |
18 | export class CommunityLink extends Component {
19 | constructor(props: any, context: any) {
20 | super(props, context);
21 | }
22 |
23 | render() {
24 | const { community, useApubName } = this.props;
25 |
26 | const title = useApubName
27 | ? community.name
28 | : (community.title ?? community.name);
29 |
30 | const { link, serverStr } = communityLink(community, this.props.realLink);
31 |
32 | const classes = `community-link ${this.props.muted ? "text-muted" : ""}`;
33 |
34 | return !this.props.realLink ? (
35 |
36 | {this.avatarAndName(this.props.myUserInfo, title, serverStr)}
37 |
38 | ) : (
39 |
40 | {this.avatarAndName(this.props.myUserInfo, title, serverStr)}
41 |
42 | );
43 | }
44 |
45 | avatarAndName(
46 | myUserInfo: MyUserInfo | undefined,
47 | title: string,
48 | serverStr?: string,
49 | ) {
50 | const icon = this.props.community.icon;
51 | const nsfw = this.props.community.nsfw;
52 |
53 | const hideAvatar =
54 | // Hide the avatar if you have hide images on
55 | hideImages(this.props.hideAvatar ?? false, myUserInfo) ||
56 | // Or its an animated image
57 | hideAnimatedImage(icon ?? "", myUserInfo) ||
58 | // Or you have hide avatars in your user settings
59 | !showAvatars(this.props.myUserInfo);
60 |
61 | return (
62 | <>
63 | {!hideAvatar && !this.props.community.removed && icon && (
64 |
65 | )}
66 |
67 | {title}
68 | {serverStr && {serverStr}}
69 |
70 | >
71 | );
72 | }
73 | }
74 |
75 | export type CommunityLinkAndServerStr = {
76 | link: string;
77 | serverStr?: string;
78 | };
79 |
80 | export function communityLink(
81 | community: Community,
82 | realLink: boolean = false,
83 | ): CommunityLinkAndServerStr {
84 | const local = community.local === null ? true : community.local;
85 |
86 | if (local) {
87 | return { link: `/c/${community.name}` };
88 | } else {
89 | const serverStr = `@${hostname(community.ap_id)}`;
90 | const link = realLink
91 | ? community.ap_id
92 | : `/c/${community.name}${serverStr}`;
93 |
94 | return { link, serverStr };
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1
2 |
3 | FROM node:current-slim AS builder
4 |
5 | ARG TARGETARCH
6 |
7 | # Added vips-dev and pkgconfig so that local vips is used instead of prebuilt
8 | # Done for two reasons:
9 | # - libvips binaries are not available for ARM32
10 | # - It can break depending on the CPU (https://github.com/LemmyNet/lemmy-ui/issues/1566)
11 | # Caching as per https://stackoverflow.com/a/72851168
12 | RUN --mount=target=/var/lib/apt/lists,type=cache,sharing=locked \
13 | --mount=target=/var/cache/apt,type=cache,sharing=locked \
14 | rm -f /etc/apt/apt.conf.d/docker-clean \
15 | && apt-get update \
16 | && apt-get -y --no-install-recommends install \
17 | curl python3 gcc wget git libvips-dev pkg-config python3-pip make g++
18 |
19 | # Install node-gyp and corepack
20 | RUN --mount=type=cache,target=/root/.npm \
21 | npm install -g -f node-gyp corepack
22 |
23 | # Enable corepack to use pnpm
24 | RUN corepack enable
25 |
26 | WORKDIR /usr/src/app
27 |
28 | ENV npm_config_target_platform=linux
29 | ENV npm_config_target_libc=musl
30 |
31 | # Cache deps
32 | COPY package.json pnpm-lock.yaml ./
33 | RUN \
34 | --mount=type=cache,target=/root/.local/share/pnpm/store \
35 | pnpm i
36 | # Build
37 | COPY generate_translations.js \
38 | tsconfig.json \
39 | webpack.config.js \
40 | .babelrc \
41 | ./
42 |
43 | COPY lemmy-translations lemmy-translations
44 | COPY src src
45 | COPY .git .git
46 |
47 | # Set UI version
48 | RUN echo "export const VERSION = '$(git describe --tag)';" > "src/shared/version.ts"
49 | RUN echo "export const BUILD_DATE_ISO8601 = '$(date -u +"%Y-%m-%dT%H:%M:%SZ")';" > "src/shared/build-date.ts"
50 |
51 | RUN \
52 | --mount=type=cache,target=/root/.local/share/pnpm/store \
53 | pnpm i
54 | RUN \
55 | --mount=type=cache,target=/root/.local/share/pnpm/store \
56 | pnpm prebuild:prod
57 | RUN \
58 | --mount=type=cache,target=/root/.local/share/pnpm/store \
59 | pnpm build:prod
60 |
61 | RUN rm -rf ./node_modules/import-sort-parser-typescript
62 | RUN rm -rf ./node_modules/typescript
63 | RUN rm -rf ./node_modules/npm
64 |
65 | FROM node:current-slim AS runner
66 |
67 | ARG TARGETARCH
68 |
69 | ENV NODE_ENV=production
70 |
71 | RUN --mount=target=/var/lib/apt/lists,type=cache,sharing=locked \
72 | --mount=target=/var/cache/apt,type=cache,sharing=locked \
73 | rm -f /etc/apt/apt.conf.d/docker-clean \
74 | && apt-get update \
75 | && apt-get -y --no-install-recommends install \
76 | curl
77 |
78 | COPY --from=builder --chown=node:node /usr/src/app/dist /app/dist
79 | COPY --from=builder --chown=node:node /usr/src/app/node_modules /app/node_modules
80 |
81 | RUN chown node:node /app
82 |
83 | LABEL org.opencontainers.image.authors="The Lemmy Authors"
84 | LABEL org.opencontainers.image.source="https://github.com/LemmyNet/lemmy-ui"
85 | LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
86 | LABEL org.opencontainers.image.description="The official web app for Lemmy."
87 |
88 | HEALTHCHECK --interval=60s --start-period=10s --retries=2 --timeout=10s CMD curl -ILfSs http://localhost:1234/ > /dev/null || exit 1
89 |
90 | USER node
91 | EXPOSE 1234
92 | WORKDIR /app
93 |
94 | CMD ["node", "--enable-source-maps", "dist/js/server.js"]
95 |
--------------------------------------------------------------------------------
/generate_translations.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const lemmyjsclient = require("lemmy-js-client");
3 |
4 | const translationDir = "lemmy-translations/translations/";
5 | const outDir = "src/shared/translations/";
6 | fs.mkdirSync(outDir, { recursive: true });
7 | fs.readdir(translationDir, (_err, files) => {
8 | files.forEach(filename => {
9 | const lang = filename.split(".")[0];
10 | try {
11 | const json = JSON.parse(
12 | fs.readFileSync(translationDir + filename, "utf8"),
13 | );
14 | let data = `export const ${lang} = {\n translation: {`;
15 | for (const key in json) {
16 | if (key in json) {
17 | const value = json[key].replace(/"/g, '\\"').replace("\n", "\\n");
18 | data += `\n ${key}: "${value}",`;
19 | }
20 | }
21 | data += "\n },\n} as const;";
22 | const target = outDir + lang + ".ts";
23 | if (
24 | !fs.existsSync(target) ||
25 | fs.readFileSync(target).toString() !== data
26 | ) {
27 | fs.writeFileSync(target, data);
28 | }
29 | } catch (err) {
30 | console.error(err);
31 | }
32 | });
33 | });
34 |
35 | // generate types for i18n keys
36 | const baseLanguage = "en";
37 |
38 | fs.readFile(`${translationDir}${baseLanguage}.json`, "utf8", (_, fileStr) => {
39 | const noOptionKeys = [];
40 | const optionKeys = [];
41 | const optionRegex = /\{\{(.+?)\}\}/g;
42 | const optionMap = new Map();
43 |
44 | const entries = Object.entries(JSON.parse(fileStr));
45 | for (const [key, val] of entries) {
46 | const options = [];
47 | for (
48 | let match = optionRegex.exec(val);
49 | match;
50 | match = optionRegex.exec(val)
51 | ) {
52 | options.push(match[1]);
53 | }
54 |
55 | if (options.length > 0) {
56 | optionMap.set(key, options);
57 | optionKeys.push(key);
58 | } else {
59 | noOptionKeys.push(key);
60 | }
61 | }
62 |
63 | const translationKeys = entries.map(e => e[0]);
64 | let missingErrorTranslations = false;
65 | lemmyjsclient.AllLemmyErrors.forEach(e => {
66 | if (!translationKeys.includes(e)) {
67 | missingErrorTranslations = true;
68 | console.error(`Missing translation for error ${e}`);
69 | }
70 | });
71 | if (missingErrorTranslations) {
72 | throw "Some errors are missing translations";
73 | }
74 |
75 | const indent = " ";
76 |
77 | const data = `import "i18next";
78 | import { en } from "./en";
79 |
80 | declare module "i18next" {
81 | interface CustomTypeOptions {
82 | jsonFormat: "v3"; // no longer supported in ^24.0, (can just be removed after converting v4)
83 | // strictKeyChecks: true; would also check that options are provided, needs ^24.2
84 | resources: {
85 | translation: typeof en.translation
86 | };
87 | }
88 | export type NoOptionI18nKeys =
89 | ${noOptionKeys.map(key => `${indent}| "${key}"`).join("\n")};
90 |
91 | export type OptionI18nKeys =
92 | ${optionKeys.map(key => `${indent}| "${key}"`).join("\n")};
93 |
94 | export type I18nKeys = NoOptionI18nKeys | OptionI18nKeys;
95 | }
96 | `;
97 |
98 | const target = `${outDir}i18next.d.ts`;
99 | if (!fs.existsSync(target) || fs.readFileSync(target).toString() !== data) {
100 | fs.writeFileSync(target, data);
101 | }
102 | });
103 |
--------------------------------------------------------------------------------
/src/assets/css/themes/_variables.RBlind-Dark.scss:
--------------------------------------------------------------------------------
1 | @import "./variables";
2 | @import "./variables.darkly";
3 |
4 | /*
5 | RBLIND THEME
6 | */
7 | $font-family-sans-serif: Verdana, sans-serif;
8 | $font-family-monospace: "Courier New", monospace;
9 | $h1-font-size: 3.5rem;
10 | $h2-font-size: 3rem;
11 | $h3-font-size: 2.5rem;
12 | $h4-font-size: 2rem;
13 | $h5-font-size: 1.5rem;
14 | $h6-font-size: 1.25rem;
15 |
16 | $font-size-root: 120%;
17 |
18 | :root {
19 | --rblind-focus-indicator: #7764d8;
20 | --gray-200-rgb: 221, 221, 221;
21 | --icon-outline-opacity: 0.5;
22 | --divider: var(--bs-gray-700);
23 | --comment-border-width: 2px;
24 | --comment-node-1-color: #8f358fff;
25 | --comment-node-2-color: #7263bcff;
26 | --comment-node-3-color: #5b96b5ff;
27 | --comment-node-4-color: #65bf93ff;
28 | --comment-node-5-color: #97d278ff;
29 | --comment-node-6-color: #e0d187ff;
30 | --comment-node-7-color: #ffdadaff;
31 | }
32 |
33 | // Breakpoints
34 | // Override default BT variables:
35 | $grid-breakpoints: (
36 | xs: 0,
37 | sm: 768px,
38 | md: 992px,
39 | lg: 1200px,
40 | xl: 1900px,
41 | xxl: 2200px,
42 | );
43 |
44 | // override default BT container sizes
45 | $container-max-widths: (
46 | sm: 960px,
47 | md: 1140px,
48 | lg: 1920px,
49 | xl: 2200,
50 | xxl: 2400,
51 | );
52 |
53 | // $navbar-brand-padding-y: 0.25rem;
54 |
55 | // rBlind Colours
56 | // Colors
57 | $white: #eeeeeeff; // bs-white, bs-emphasis, bs-table, border-white, text-light
58 | $gray-200: #ddddddff; // secondary button background
59 | $gray-300: #ccccccff; // bs-dark, bs-dark-emphasis and more
60 | $gray-500: #bbbbbbff; // bs-button-bg, and other button styles, input group text (defined in the class by hex code not variable, bs button disabled, etc
61 | $gray-600: #676767ff; // blockquote footer, disabled form, disabled button, dropdown header colour, bs-gray
62 | $gray-700: #242424ff; // card header background
63 | $gray-800: #131313ff; // card background, bs-light
64 | $gray-900: #030303ff; // Background colour
65 |
66 | $red: #ffaabbff; // bs-danger, danger red, auto converted to rgb values in custom theme
67 | $orange: #ee8866ff;
68 | $yellow: #eedd88ff; // bs-warning
69 | $teal: #bbcc33ff; // more green than teal
70 | $green: #44bb99ff; // form check input background and border
71 | $cyan: #6fcfffff; // bs-info
72 | $blue: #77aaddff; // bs-blue
73 |
74 | $primary: $green;
75 | $secondary: $gray-500;
76 | $success: $green;
77 | $dark: $gray-300;
78 |
79 | $body-color: $gray-300;
80 | $body-bg: $gray-900;
81 | $link-color: $cyan;
82 | $border-color: rgba($body-color, 0.25);
83 | $mark-bg: #333;
84 | $mark-bg-dark: #333;
85 | $text-muted: $gray-600;
86 | $yiq-contrasted-threshold: 175;
87 |
88 | // Custom RBlind Colours
89 | $box-shadow: 0 0 0 0.25rem var(--rblind-focus-indicator) !important; //--bs-btn-focus-box-shadow default
90 | $focus-ring-color: var(--rblind-focus-indicator);
91 | $form-check-input-checked-color: $gray-900; // change checkbox check from white to black
92 | $dropdown-link-active-color: $gray-900;
93 | $dropdown-link-active-bg: $gray-200;
94 | $dropdown-link-hover-bg: $gray-200;
95 | $dropdown-link-hover-color: $gray-900;
96 | $code-color: $yellow;
97 | $border-color: $gray-500 !important;
98 | $table-color: $gray-200;
99 | $btn-disabled-color: $gray-200;
100 | $btn-disabled-opacity: 0.65; // default
101 | $enable-dark-mode: true; // enable data-bs-theme="dark"
102 | $enable-light-mode: false; // disable data-bs-theme="light"
103 |
--------------------------------------------------------------------------------
/src/shared/components/common/modal/confirmation-modal.tsx:
--------------------------------------------------------------------------------
1 | import { Component, RefObject, createRef, linkEvent } from "inferno";
2 | import { I18NextService } from "../../../services";
3 | import type { Modal } from "bootstrap";
4 | import { Spinner } from "../icon";
5 | import { LoadingEllipses } from "../loading-ellipses";
6 | import { modalMixin } from "../../mixins/modal-mixin";
7 | import { MouseEventHandler } from "inferno";
8 |
9 | interface ConfirmationModalProps {
10 | onYes: () => void;
11 | onNo: MouseEventHandler;
12 | message: string;
13 | loadingMessage: string;
14 | show: boolean;
15 | }
16 |
17 | interface ConfirmationModalState {
18 | loading: boolean;
19 | }
20 |
21 | function handleYes(i: ConfirmationModal) {
22 | i.setState({ loading: true });
23 | i.props.onYes();
24 | i.setState({ loading: false });
25 | }
26 |
27 | @modalMixin
28 | export default class ConfirmationModal extends Component<
29 | ConfirmationModalProps,
30 | ConfirmationModalState
31 | > {
32 | readonly modalDivRef: RefObject;
33 | readonly yesButtonRef: RefObject;
34 | modal?: Modal;
35 | state: ConfirmationModalState = {
36 | loading: false,
37 | };
38 |
39 | constructor(props: ConfirmationModalProps, context: any) {
40 | super(props, context);
41 |
42 | this.modalDivRef = createRef();
43 | this.yesButtonRef = createRef();
44 | }
45 |
46 | render() {
47 | const { message, onNo, loadingMessage } = this.props;
48 | const { loading } = this.state;
49 |
50 | return (
51 |
60 |
61 |
62 |
63 |
64 | {I18NextService.i18n.t("confirmation_required")}
65 |
66 |
67 |
68 | {loading ? (
69 | <>
70 |
71 |
72 | {loadingMessage}
73 |
74 |
75 | >
76 | ) : (
77 | message
78 | )}
79 |
80 |
99 |
100 |
101 |
102 | );
103 | }
104 |
105 | handleShow() {
106 | this.yesButtonRef.current?.focus();
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/shared/components/common/notification-select.tsx:
--------------------------------------------------------------------------------
1 | import { I18NextService } from "@services/index";
2 | import { Icon } from "./icon";
3 | import { NoOptionI18nKeys } from "i18next";
4 | import {
5 | CommunityNotificationsMode,
6 | PostNotificationsMode,
7 | } from "lemmy-js-client";
8 | import { Component, linkEvent } from "inferno";
9 |
10 | interface CommonNotificationSelectProps {
11 | onChange(val: T): void;
12 | current: T;
13 | }
14 |
15 | interface NotificationSelectProps<
16 | T extends string,
17 | > extends CommonNotificationSelectProps {
18 | choices: Choice[];
19 | showIcon?: boolean;
20 | }
21 |
22 | type Choice = {
23 | key: NoOptionI18nKeys;
24 | value: T;
25 | };
26 |
27 | class NotificationSelect extends Component<
28 | NotificationSelectProps,
29 | any
30 | > {
31 | constructor(props: any, context: any) {
32 | super(props, context);
33 | }
34 |
35 | render() {
36 | return (
37 | <>
38 | {this.props.showIcon && }
39 |
53 | >
54 | );
55 | }
56 | }
57 |
58 | function handleNotificationChange(
59 | i: NotificationSelect,
60 | event: any,
61 | ) {
62 | i.props.onChange(event.target.value);
63 | }
64 |
65 | const postNotifChoices: Choice[] = [
66 | {
67 | key: "notification_mode_all_comments",
68 | value: "all_comments",
69 | },
70 | {
71 | key: "notification_mode_replies_and_mentions",
72 | value: "replies_and_mentions",
73 | },
74 | {
75 | key: "notification_mode_mute",
76 | value: "mute",
77 | },
78 | ];
79 |
80 | export class PostNotificationSelect extends Component<
81 | CommonNotificationSelectProps
82 | > {
83 | render() {
84 | return (
85 |
86 | onChange={this.props.onChange}
87 | choices={postNotifChoices}
88 | current={this.props.current}
89 | showIcon
90 | />
91 | );
92 | }
93 | }
94 |
95 | const communityNotifChoices: Choice[] = [
96 | {
97 | key: "notification_mode_all_posts_and_comments",
98 | value: "all_posts_and_comments",
99 | },
100 | {
101 | key: "notification_mode_all_posts",
102 | value: "all_posts",
103 | },
104 | {
105 | key: "notification_mode_replies_and_mentions",
106 | value: "replies_and_mentions",
107 | },
108 | {
109 | key: "notification_mode_mute",
110 | value: "mute",
111 | },
112 | ];
113 |
114 | export class CommunityNotificationSelect extends Component<
115 | CommonNotificationSelectProps
116 | > {
117 | render() {
118 | return (
119 |
120 | onChange={this.props.onChange}
121 | choices={communityNotifChoices}
122 | current={this.props.current}
123 | />
124 | );
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/shared/utils/date.ts:
--------------------------------------------------------------------------------
1 | import {
2 | addDays,
3 | constructNow,
4 | parseISO,
5 | parse,
6 | isSameDay,
7 | isSameYear,
8 | getYear,
9 | setYear,
10 | formatDistanceToNowStrict,
11 | subDays,
12 | formatDistance,
13 | } from "date-fns";
14 |
15 | export function futureDaysToUnixTime(days?: number): number | undefined {
16 | return days && days > 0
17 | ? Math.trunc(addDays(constructNow(undefined), days).getTime() / 1000)
18 | : undefined;
19 | }
20 |
21 | export function formatRelativeDate(date: string, addSuffix: boolean = true) {
22 | try {
23 | const then = parseISO(date);
24 | return formatDistanceToNowStrict(then, { addSuffix });
25 | } catch {
26 | return "indeterminate";
27 | }
28 | }
29 |
30 | // Returns a date in local time with the same year, month and day. Ignores the
31 | // source timezone. The goal is to show the same date in all timezones.
32 | export function cakeDate(published: string): Date {
33 | return parse(published.substring(0, 10), "yyyy-MM-dd", new Date(0));
34 | }
35 |
36 | export function isCakeDay(published: string): boolean {
37 | const createDate = cakeDate(published);
38 | const currentDate = new Date();
39 |
40 | // The day-overflow of Date makes leap days become 03-01 in non leap years.
41 | return (
42 | isSameDay(currentDate, setYear(createDate, getYear(currentDate))) &&
43 | !isSameYear(currentDate, createDate)
44 | );
45 | }
46 |
47 | /**
48 | * Converts timestamp string to unix timestamp in seconds, as used by Lemmy API
49 | */
50 | export function getUnixTimeLemmy(text?: string): number | undefined {
51 | return text ? new Date(text).getTime() / 1000 : undefined;
52 | }
53 |
54 | /**
55 | * Converts timestamp string to unix timestamp in millis, as used by Javascript
56 | */
57 | export function getUnixTime(text?: string): number | undefined {
58 | return text ? new Date(text).getTime() : undefined;
59 | }
60 |
61 | /**
62 | * This converts a unix time to a local date string,
63 | * popping to tho nearest minute, and removing the Z for
64 | * javascript fields.
65 | */
66 | export function unixTimeToLocalDateStr(unixTime?: number): string | undefined {
67 | return unixTime
68 | ? convertUTCDateToLocalDate(new Date(unixTime)).toISOString().slice(0, -8)
69 | : undefined;
70 | }
71 |
72 | /**
73 | * Converts a seconds duration, to a readable date-fns string.
74 | */
75 | export function secondsDurationToStr(seconds: number): string {
76 | return formatDistance(0, seconds * 1000, { includeSeconds: true });
77 | }
78 |
79 | /**
80 | * Constructs an alert class from the duration.
81 | * < 1 hour = success
82 | * < 1 day = warning
83 | * else = danger
84 | */
85 | export function secondsDurationToAlertClass(seconds: number): string {
86 | let classes: string;
87 | if (seconds < 3600) {
88 | classes = "success";
89 | } else if (seconds < 86400) {
90 | classes = "warning";
91 | } else {
92 | classes = "danger";
93 | }
94 | return `alert alert-${classes}`;
95 | }
96 |
97 | function convertUTCDateToLocalDate(date: Date): Date {
98 | return new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
99 | }
100 |
101 | export function nowBoolean(bool?: boolean): string | undefined {
102 | return bool ? new Date().toISOString() : undefined;
103 | }
104 |
105 | // Returns true if the date is more than 7 days ago.
106 | // https://stackoverflow.com/a/563442
107 | export function isWeekOld(date: Date): boolean {
108 | const weekAgo = subDays(new Date(), 7);
109 | return date < weekAgo;
110 | }
111 |
--------------------------------------------------------------------------------
/src/shared/components/multi-community/multi-community-entry-form.tsx:
--------------------------------------------------------------------------------
1 | import { debounce, getIdFromString, randomStr } from "@utils/helpers";
2 | import { Component } from "inferno";
3 | import {
4 | CommunityId,
5 | CommunityView,
6 | PagedResponse,
7 | MyUserInfo,
8 | } from "lemmy-js-client";
9 | import { I18NextService } from "../../services";
10 | import {
11 | communityToChoice,
12 | fetchCommunities,
13 | filterCommunitySelection,
14 | } from "@utils/app";
15 | import { tippyMixin } from "../mixins/tippy-mixin";
16 | import { EMPTY_REQUEST, RequestState } from "@services/HttpService";
17 | import { SearchableSelect } from "@components/common/searchable-select";
18 | import { Choice } from "@utils/types";
19 |
20 | interface Props {
21 | currentCommunities: CommunityView[];
22 | onCreate(id: CommunityId): void;
23 | myUserInfo: MyUserInfo | undefined;
24 | }
25 |
26 | interface State {
27 | listCommunitiesRes: RequestState>;
28 | selectedCommunity?: CommunityView;
29 | communitySearchOptions: Choice[];
30 | communitySearchLoading: boolean;
31 | }
32 |
33 | @tippyMixin
34 | export class MultiCommunityEntryForm extends Component {
35 | state: State = {
36 | listCommunitiesRes: EMPTY_REQUEST,
37 | communitySearchOptions: [],
38 | communitySearchLoading: false,
39 | };
40 |
41 | constructor(props: any, context: any) {
42 | super(props, context);
43 | }
44 |
45 | render() {
46 | const id = randomStr();
47 |
48 | return (
49 |
69 | );
70 | }
71 | }
72 |
73 | function handleCommunitySelect(i: MultiCommunityEntryForm, choice: Choice) {
74 | const communityId = getIdFromString(choice.value);
75 | if (communityId) {
76 | i.props.onCreate(communityId);
77 | }
78 | }
79 |
80 | const handleCommunitySearch = debounce(
81 | async (i: MultiCommunityEntryForm, text: string) => {
82 | i.setState({ communitySearchLoading: true });
83 |
84 | const newOptions: Choice[] = [];
85 |
86 | if (text.length > 0) {
87 | newOptions.push(
88 | ...filterCommunitySelection(
89 | await fetchCommunities(text),
90 | i.props.myUserInfo,
91 | )
92 | // Filter out currently selected comms
93 | .filter(
94 | c =>
95 | !i.props.currentCommunities
96 | .map(cc => cc.community.id)
97 | .includes(c.community.id),
98 | )
99 | .map(communityToChoice),
100 | );
101 |
102 | i.setState({
103 | communitySearchOptions: newOptions,
104 | });
105 | }
106 |
107 | i.setState({
108 | communitySearchLoading: false,
109 | });
110 | },
111 | );
112 |
--------------------------------------------------------------------------------
/src/shared/components/person/notification-modlog-item.tsx:
--------------------------------------------------------------------------------
1 | import { Component, InfernoNode, linkEvent } from "inferno";
2 | import {
3 | ModlogView,
4 | MyUserInfo,
5 | Notification,
6 | MarkNotificationAsRead,
7 | } from "lemmy-js-client";
8 | import { Icon, Spinner } from "../common/icon";
9 | import { MomentTime } from "../common/moment-time";
10 | import { tippyMixin } from "../mixins/tippy-mixin";
11 | import { mark_as_read_i18n } from "@utils/app";
12 | import { processModlogEntry } from "@components/modlog";
13 | import { PersonListing } from "./person-listing";
14 | import { I18NextService } from "@services/index";
15 |
16 | interface NotificationModlogItemState {
17 | readLoading: boolean;
18 | }
19 |
20 | interface NotificationModlogItemProps {
21 | myUserInfo: MyUserInfo | undefined;
22 | notification: Notification;
23 | modlog_view: ModlogView;
24 | onMarkRead(form: MarkNotificationAsRead): void;
25 | }
26 |
27 | @tippyMixin
28 | export class NotificationModlogItem extends Component<
29 | NotificationModlogItemProps,
30 | NotificationModlogItemState
31 | > {
32 | state: NotificationModlogItemState = {
33 | readLoading: false,
34 | };
35 |
36 | constructor(props: any, context: any) {
37 | super(props, context);
38 | }
39 |
40 | componentWillReceiveProps(
41 | nextProps: Readonly<
42 | { children?: InfernoNode } & NotificationModlogItemProps
43 | >,
44 | ) {
45 | if (this.props.modlog_view !== nextProps.modlog_view) {
46 | this.setState({ readLoading: false });
47 | }
48 | }
49 |
50 | render() {
51 | const {
52 | modlog: { published_at },
53 | moderator,
54 | data,
55 | } = processModlogEntry(this.props.modlog_view, this.props.myUserInfo);
56 | return (
57 |
58 |
59 |
60 | {moderator ? (
61 |
66 | ) : (
67 | I18NextService.i18n.t("mod")
68 | )}
69 |
70 |
71 |
72 |
73 |
74 |
{data}
75 |
76 | -
77 |
97 |
98 |
99 |
100 | );
101 | }
102 |
103 | handleMarkAsRead(i: NotificationModlogItem) {
104 | const n = i.props.notification;
105 | i.props.onMarkRead({ notification_id: n.id, read: !n.read });
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/assets/css/themes/_variables.RBlind-Light.scss:
--------------------------------------------------------------------------------
1 | @import "./variables";
2 | @import "./variables.litely";
3 |
4 | /*
5 | RBLIND THEME
6 | */
7 | $font-family-sans-serif: Verdana, sans-serif;
8 | $font-family-monospace: "Courier New", monospace;
9 | $h1-font-size: 3.5rem;
10 | $h2-font-size: 3rem;
11 | $h3-font-size: 2.5rem;
12 | $h4-font-size: 2rem;
13 | $h5-font-size: 1.5rem;
14 | $h6-font-size: 1.25rem;
15 |
16 | $font-size-root: 120%;
17 |
18 | :root {
19 | --rblind-focus-indicator: #7764d8;
20 | --gray-200-rgb: 21, 21, 21;
21 | --icon-outline-opacity: 0.7;
22 | --divider: var(--bs-gray-700);
23 | --comment-border-width: 2px;
24 | --comment-node-1-color: #3f003fff;
25 | --comment-node-2-color: #2c1d77ff;
26 | --comment-node-3-color: #114c6aff;
27 | --comment-node-4-color: #166f43ff;
28 | --comment-node-5-color: #427d24ff;
29 | --comment-node-6-color: #87782dff;
30 | --comment-node-7-color: #c46d6dff;
31 | }
32 |
33 | // Breakpoints
34 | // Override default BT variables:
35 | $grid-breakpoints: (
36 | xs: 0,
37 | sm: 768px,
38 | md: 992px,
39 | lg: 1200px,
40 | xl: 1900px,
41 | xxl: 2200px,
42 | );
43 |
44 | // override default BT container sizes
45 | $container-max-widths: (
46 | sm: 960px,
47 | md: 1140px,
48 | lg: 1920px,
49 | xl: 2200,
50 | xxl: 2400,
51 | );
52 |
53 | // $navbar-brand-padding-y: 0.25rem;
54 |
55 | // rBlind Colours
56 | // Colors
57 | $white: #030303ff; // bs-white, bs-emphasis, bs-table, border-white, text-light
58 | $gray-200: #131313ff; // secondary button background
59 | $gray-300: #242424ff; // bs-dark, bs-dark-emphasis and more
60 | $gray-500: #353535ff; // bs-button-bg, and other button styles, input group text (defined in the class by hex code not variable, bs button disabled, etc
61 | $gray-600: #464646ff; // blockquote footer, disabled form, disabled button, dropdown header colour, bs-gray
62 | $gray-700: #c9c9c9ff; // card header background
63 | $gray-800: #dbdbdbff; // card background, bs-light
64 | $gray-900: #edededff; // Background colour
65 | $black: #ffffffff;
66 |
67 | $red: #530d1aff; // bs-danger, danger red, auto converted to rgb values in custom theme
68 | $orange: #7c3e49ff;
69 | $yellow: #5f5525ff; // bs-warning
70 | $green: #0c5625ff; // form check input background and border
71 | $teal: #1f4d45ff; // more green than teal
72 | $cyan: #1b3e76; // bs-info
73 | $blue: #332288ff; // bs-blue
74 |
75 | $danger: $red;
76 | $info: $cyan;
77 |
78 | $primary: $green;
79 | $secondary: $gray-200; // changed for light
80 | $success: $green;
81 | // $secondary-color: $secondary !important; // causing bugs with icon
82 |
83 | $body-color: $gray-300;
84 | $body-bg: $gray-900;
85 | $link-color: $cyan;
86 | $border-color: rgba($body-color, 0.25);
87 | $mark-bg: #333;
88 | $mark-bg-dark: #333;
89 | $yiq-contrasted-threshold: 175;
90 |
91 | // Custom RBlind Colours
92 | $box-shadow: 0 0 0 0.25rem var(--rblind-focus-indicator) !important; //--bs-btn-focus-box-shadow default
93 | $focus-ring-color: var(--rblind-focus-indicator);
94 | $form-check-input-checked-color: $gray-900; // change checkbox check from white to black
95 | $dropdown-link-active-color: $gray-900;
96 | $dropdown-link-active-bg: $gray-200;
97 | $dropdown-link-hover-bg: $gray-200;
98 | $dropdown-link-hover-color: $gray-900;
99 | $code-color: $yellow;
100 | $border-color: $gray-500 !important;
101 | $nav-tabs-link-active-color: $white; // for light theme only
102 | $table-color: $gray-200;
103 | $btn-disabled-color: $gray-200;
104 | $btn-disabled-opacity: 0.8;
105 | $enable-dark-mode: false; // disable data-bs-theme="dark"
106 | $enable-light-mode: true; // enable data-bs-theme="light"
107 |
--------------------------------------------------------------------------------
/src/shared/components/home/oauth/oauth-provider-list-item.tsx:
--------------------------------------------------------------------------------
1 | import { OAuthProvider } from "lemmy-js-client";
2 | import { I18NextService } from "../../../services/I18NextService";
3 | import { Icon } from "../../common/icon";
4 | import { MouseEventHandler } from "inferno";
5 | import { NoOptionI18nKeys } from "i18next";
6 |
7 | type OAuthProviderListItemProps = {
8 | provider: OAuthProvider;
9 | onEdit: MouseEventHandler;
10 | onDelete: MouseEventHandler;
11 | };
12 |
13 | type TextInfoFieldProps = {
14 | i18nKey: NoOptionI18nKeys;
15 | data: string;
16 | };
17 |
18 | function TextInfoField({ i18nKey, data }: TextInfoFieldProps) {
19 | return (
20 |
21 |
{I18NextService.i18n.t(i18nKey)}
22 | {data}
23 |
24 | );
25 | }
26 |
27 | function boolToYesNo(value?: boolean) {
28 | return I18NextService.i18n.t(value ? "yes" : "no");
29 | }
30 |
31 | export default function OAuthProviderListItem({
32 | provider,
33 | onEdit,
34 | onDelete,
35 | }: OAuthProviderListItemProps) {
36 | return (
37 |
38 |
39 |
40 |
41 |
42 | {provider.display_name}
43 |
44 |
45 |
52 |
59 |
60 |
61 |
62 |
63 |
64 |
68 |
72 |
76 |
77 |
81 |
82 |
86 |
90 |
94 |
95 |
96 |
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/src/shared/components/common/pending-follow.tsx:
--------------------------------------------------------------------------------
1 | import { Component, InfernoNode } from "inferno";
2 | import {
3 | ApproveCommunityPendingFollower,
4 | MyUserInfo,
5 | PendingFollow as PendingFollowView,
6 | } from "lemmy-js-client";
7 | import { I18NextService } from "../../services";
8 | import { PersonListing } from "../person/person-listing";
9 | import { Spinner } from "./icon";
10 | import { UserBadges } from "./user-badges";
11 | import { linkEvent } from "inferno";
12 |
13 | interface PendingFollowProps {
14 | pending_follow: PendingFollowView;
15 | myUserInfo: MyUserInfo | undefined;
16 | onApproveFollower(form: ApproveCommunityPendingFollower): void;
17 | }
18 |
19 | interface PendingFollowState {
20 | approveLoading: boolean;
21 | denyLoading: boolean;
22 | }
23 |
24 | export class PendingFollow extends Component<
25 | PendingFollowProps,
26 | PendingFollowState
27 | > {
28 | state: PendingFollowState = {
29 | approveLoading: false,
30 | denyLoading: false,
31 | };
32 |
33 | constructor(props: any, context: any) {
34 | super(props, context);
35 | }
36 | componentWillReceiveProps(
37 | nextProps: Readonly<{ children?: InfernoNode } & PendingFollowProps>,
38 | ): void {
39 | if (this.props !== nextProps) {
40 | this.setState({
41 | approveLoading: false,
42 | denyLoading: false,
43 | });
44 | }
45 | }
46 |
47 | render() {
48 | const p = this.props.pending_follow;
49 | return (
50 |
51 |
52 |
58 |
64 |
65 |
66 | {p.follow_state === "approval_required" && (
67 | <>
68 |
79 |
90 | >
91 | )}
92 |
93 |
94 | );
95 | }
96 |
97 | handleApprove(i: PendingFollow) {
98 | i.setState({ approveLoading: true });
99 | i.props.onApproveFollower({
100 | follower_id: i.props.pending_follow.person.id,
101 | community_id: i.props.pending_follow.community.id,
102 | approve: true,
103 | });
104 | }
105 |
106 | handleDeny(i: PendingFollow) {
107 | i.setState({ denyLoading: true });
108 | i.props.onApproveFollower({
109 | follower_id: i.props.pending_follow.person.id,
110 | community_id: i.props.pending_follow.community.id,
111 | approve: false,
112 | });
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/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 | $orange: #f1641e;
16 | $yellow: #f39c12;
17 | $green: #00bc8c;
18 | $cyan: #3498db;
19 |
20 | $primary: $blue;
21 | $secondary: $gray-500;
22 | $success: $green;
23 | $info: $orange;
24 | $dark: $gray-300;
25 |
26 | $body-color: $gray-300;
27 | $body-bg: $gray-900;
28 | $border-color: rgba($body-color, 0.25);
29 | $mark-bg: #333;
30 | $mark-bg-dark: #333;
31 | $text-muted: $gray-600;
32 | $yiq-contrasted-threshold: 175;
33 |
34 | $font-family-sans-serif:
35 | "Lato",
36 | -apple-system,
37 | BlinkMacSystemFont,
38 | "Segoe UI",
39 | Roboto,
40 | Verdana,
41 | "Arimo",
42 | "Helvetica Neue",
43 | Arial,
44 | sans-serif,
45 | "Apple Color Emoji",
46 | "Segoe UI Emoji",
47 | "Segoe UI Symbol";
48 | $h1-font-size: 3rem;
49 | $h2-font-size: 2.5rem;
50 | $h3-font-size: 2rem;
51 |
52 | $navbar-padding-y: 1rem;
53 | $navbar-dark-color: rgba($white, 0.6);
54 | $navbar-dark-hover-color: $white;
55 | $navbar-light-color: rgba($white, 0.6);
56 | $navbar-light-hover-color: $white;
57 | $navbar-light-active-color: $white;
58 | $navbar-light-toggler-border-color: rgba($gray-900, 0.1);
59 | $navbar-light-brand-color: $white;
60 | $navbar-light-brand-hover-color: $navbar-light-brand-color;
61 |
62 | $nav-link-padding-x: 2rem;
63 | $nav-link-disabled-color: $gray-500;
64 |
65 | $nav-tabs-border-color: $gray-700;
66 | $nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color
67 | transparent;
68 | $nav-tabs-link-active-color: $white;
69 | $nav-tabs-link-active-border-color: $nav-tabs-border-color
70 | $nav-tabs-border-color transparent;
71 |
72 | $input-bg: $gray-700;
73 | $input-color: $white;
74 | $input-disabled-bg: darken($gray-700, 10%);
75 | $input-border-color: $body-bg;
76 | $input-group-addon-color: $gray-500;
77 | $input-group-addon-bg: $gray-700;
78 |
79 | $hr-border-color: rgba($body-color, 0.25);
80 |
81 | $table-border-color: $gray-700;
82 |
83 | $custom-file-color: $gray-500;
84 | $custom-file-border-color: $body-bg;
85 |
86 | $dropdown-bg: $gray-900;
87 | $dropdown-border-color: $gray-700;
88 | $dropdown-divider-bg: $gray-700;
89 | $dropdown-link-color: $white;
90 | $dropdown-link-hover-color: $white;
91 | $dropdown-link-hover-bg: $primary;
92 |
93 | $pagination-color: $white;
94 | $pagination-bg: $success;
95 | $pagination-border-width: 0;
96 | $pagination-border-color: transparent;
97 | $pagination-hover-color: $white;
98 | $pagination-hover-bg: lighten($success, 10%);
99 | $pagination-hover-border-color: transparent;
100 | $pagination-active-bg: $pagination-hover-bg;
101 | $pagination-active-border-color: transparent;
102 | $pagination-disabled-color: $white;
103 | $pagination-disabled-bg: darken($success, 15%);
104 | $pagination-disabled-border-color: transparent;
105 |
106 | $jumbotron-bg: $gray-800;
107 | $popover-bg: $gray-800;
108 | $popover-header-bg: $gray-700;
109 | $toast-background-color: $gray-700;
110 | $toast-header-background-color: $gray-800;
111 | $modal-content-bg: $gray-800;
112 | $modal-content-border-color: $gray-700;
113 | $modal-header-border-color: $gray-700;
114 | $progress-bg: $gray-700;
115 | $list-group-bg: $gray-800;
116 | $list-group-border-color: $gray-700;
117 | $list-group-hover-bg: $gray-700;
118 | $breadcrumb-bg: $gray-700;
119 | $close-color: $white;
120 | $close-text-shadow: none;
121 | $pre-color: inherit;
122 | $custom-select-bg: $gray-700;
123 | $custom-select-color: $white;
124 | $light: $gray-800;
125 |
--------------------------------------------------------------------------------
/src/shared/components/home/instance-allow-form.tsx:
--------------------------------------------------------------------------------
1 | import { Component, linkEvent } from "inferno";
2 | import { AdminAllowInstanceParams } from "lemmy-js-client";
3 | import { I18NextService } from "../../services";
4 | import { randomStr, validInstanceTLD } from "@utils/helpers";
5 | import { Prompt } from "inferno-router";
6 |
7 | type AllowForm = {
8 | instance?: string;
9 | reason?: string;
10 | };
11 |
12 | interface Props {
13 | onCreate(form: AdminAllowInstanceParams): void;
14 | }
15 |
16 | interface State {
17 | form: AllowForm;
18 | bypassNavWarning: boolean;
19 | }
20 |
21 | export class InstanceAllowForm extends Component {
22 | state: State = {
23 | form: {},
24 | bypassNavWarning: true,
25 | };
26 |
27 | constructor(props: any, context: any) {
28 | super(props, context);
29 | }
30 |
31 | render() {
32 | const form = this.state.form;
33 | const id = randomStr();
34 |
35 | return (
36 |
80 | );
81 | }
82 |
83 | formValid(): boolean {
84 | const form = this.state.form;
85 | return !!(
86 | form.instance &&
87 | validInstanceTLD(form.instance ?? "") &&
88 | form.reason
89 | );
90 | }
91 |
92 | handleDomainTextChange(i: InstanceAllowForm, event: any) {
93 | i.setState({
94 | form: { ...i.state.form, instance: event.target.value },
95 | bypassNavWarning: false,
96 | });
97 | }
98 |
99 | handleReasonChange(i: InstanceAllowForm, event: any) {
100 | i.setState({
101 | form: { ...i.state.form, reason: event.target.value },
102 | bypassNavWarning: false,
103 | });
104 | }
105 |
106 | handleSubmit(i: InstanceAllowForm, event: any) {
107 | event.preventDefault();
108 |
109 | const form = i.state.form;
110 |
111 | if (form.instance && validInstanceTLD(form.instance ?? "") && form.reason) {
112 | i.props.onCreate({
113 | instance: form.instance,
114 | allow: true,
115 | reason: form.reason,
116 | });
117 | }
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/shared/components/common/media-uploads.tsx:
--------------------------------------------------------------------------------
1 | import { Component, InfernoNode, linkEvent } from "inferno";
2 | import {
3 | LocalImage,
4 | LocalImageView,
5 | MyUserInfo,
6 | PagedResponse,
7 | } from "lemmy-js-client";
8 | import { HttpService, I18NextService } from "../../services";
9 | import { PersonListing } from "../person/person-listing";
10 | import { tippyMixin } from "../mixins/tippy-mixin";
11 | import { MomentTime } from "./moment-time";
12 | import { PictrsImage } from "./pictrs-image";
13 | import { httpBackendUrl } from "@utils/env";
14 | import { toast } from "@utils/app";
15 | import { TableHr } from "./tables";
16 |
17 | interface Props {
18 | uploads: PagedResponse;
19 | showUploader?: boolean;
20 | myUserInfo: MyUserInfo | undefined;
21 | }
22 |
23 | @tippyMixin
24 | export class MediaUploads extends Component {
25 | constructor(props: any, context: any) {
26 | super(props, context);
27 | }
28 |
29 | componentWillReceiveProps(
30 | nextProps: Readonly<{ children?: InfernoNode } & Props>,
31 | ): void {
32 | if (this.props !== nextProps) {
33 | this.setState({ loading: false });
34 | }
35 | }
36 |
37 | render() {
38 | const images = this.props.uploads.items;
39 |
40 | const cols = "col-6 col-md-3";
41 |
42 | return (
43 |
44 |
45 | {this.props.showUploader && (
46 |
47 | {I18NextService.i18n.t("uploader")}
48 |
49 | )}
50 |
51 | {I18NextService.i18n.t("time")}
52 |
53 |
54 |
55 | {images.map(i => (
56 | <>
57 |
58 | {this.props.showUploader && (
59 |
66 | )}
67 |
68 |
69 |
70 |
73 |
{this.deleteImageBtn(i.local_image)}
74 |
75 |
76 | >
77 | ))}
78 |
79 | );
80 | }
81 |
82 | deleteImageBtn(image: LocalImage) {
83 | return (
84 |
90 | );
91 | }
92 |
93 | async handleDeleteImage(image: LocalImage) {
94 | const filename = image.pictrs_alias;
95 | const res = await HttpService.client.deleteMedia({ filename });
96 | if (res.state === "success") {
97 | const deletePictureText = I18NextService.i18n.t("picture_deleted", {
98 | filename,
99 | });
100 | toast(deletePictureText);
101 | } else if (res.state === "failed") {
102 | const failedDeletePictureText = I18NextService.i18n.t(
103 | "failed_to_delete_picture",
104 | {
105 | filename,
106 | },
107 | );
108 | toast(failedDeletePictureText, "danger");
109 | }
110 | }
111 | }
112 |
113 | function buildImageUrl(pictrsAlias: string): string {
114 | return httpBackendUrl(`/api/v4/image/${pictrsAlias}`);
115 | }
116 |
--------------------------------------------------------------------------------