A first pass at locally working isomorphic integration.

pull/1124/head
Dessalines 4 years ago
parent 8edfc5fcf9
commit 1a0ef37a68

@ -1,15 +1,3 @@
FROM node:10-jessie as node
WORKDIR /app/ui
# Cache deps
COPY ui/package.json ui/yarn.lock ./
RUN yarn install --pure-lockfile
# Build
COPY ui /app/ui
RUN yarn build
FROM ekidd/rust-musl-builder:nightly-2020-05-07 as rust
# Cache deps
@ -64,8 +52,7 @@ RUN apk add espeak
# Copy resources
COPY server/config/defaults.hjson /config/defaults.hjson
COPY --from=rust /app/server/target/x86_64-unknown-linux-musl/debug/lemmy_server /app/lemmy
COPY --from=docs /app/docs/book/ /app/dist/documentation/
COPY --from=node /app/ui/dist /app/dist
COPY --from=docs /app/docs/book/ /app/documentation/
RUN addgroup -g 1000 lemmy
RUN adduser -D -s /bin/sh -u 1000 -G lemmy lemmy

@ -15,6 +15,14 @@ services:
- pictrs
- postgres
- iframely
lemmy-isomorphic-ui:
image: lemmy-isomorphic-ui:latest
ports:
- "1235:1234"
environment:
- LEMMY_HOST=lemmy
depends_on:
- lemmy
postgres:
image: postgres:12-alpine

@ -13,7 +13,6 @@ pub struct Settings {
pub bind: IpAddr,
pub port: u16,
pub jwt_secret: String,
pub front_end_dir: String,
pub pictrs_url: String,
pub rate_limit: RateLimitConfig,
pub email: Option<EmailConfig>,

@ -90,7 +90,6 @@ async fn main() -> Result<(), LemmyError> {
Client::default(),
activity_queue.to_owned(),
);
let settings = Settings::get();
let rate_limiter = rate_limiter.clone();
App::new()
.wrap_fn(add_cache_headers)
@ -101,17 +100,11 @@ async fn main() -> Result<(), LemmyError> {
.configure(federation::config)
.configure(feeds::config)
.configure(|cfg| images::config(cfg, &rate_limiter))
.configure(index::config)
.configure(nodeinfo::config)
.configure(webfinger::config)
// static files
.service(actix_files::Files::new(
"/static",
settings.front_end_dir.to_owned(),
))
.service(actix_files::Files::new(
"/docs",
settings.front_end_dir + "/documentation",
"/documentation",
))
})
.bind((settings.bind, settings.port))?

@ -1,51 +0,0 @@
use actix_files::NamedFile;
use actix_web::*;
use lemmy_utils::settings::Settings;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg
.route("/", web::get().to(index))
.route(
"/home/data_type/{data_type}/listing_type/{listing_type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/login", web::get().to(index))
.route("/create_post", web::get().to(index))
.route("/create_community", web::get().to(index))
.route("/create_private_message", web::get().to(index))
.route("/communities/page/{page}", web::get().to(index))
.route("/communities", web::get().to(index))
.route("/post/{id}/comment/{id2}", web::get().to(index))
.route("/post/{id}", web::get().to(index))
.route(
"/c/{name}/data_type/{data_type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/c/{name}", web::get().to(index))
.route("/community/{id}", web::get().to(index))
.route(
"/u/{username}/view/{view}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/u/{username}", web::get().to(index))
.route("/user/{id}", web::get().to(index))
.route("/inbox", web::get().to(index))
.route("/modlog/community/{community_id}", web::get().to(index))
.route("/modlog", web::get().to(index))
.route("/setup", web::get().to(index))
.route("/admin", web::get().to(index))
.route(
"/search/q/{q}/type/{type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/search", web::get().to(index))
.route("/sponsors", web::get().to(index))
.route("/password_change/{token}", web::get().to(index))
.route("/instances", web::get().to(index));
}
async fn index() -> Result<NamedFile, Error> {
Ok(NamedFile::open(
Settings::get().front_end_dir + "/index.html",
)?)
}

@ -2,7 +2,6 @@ pub mod api;
pub mod federation;
pub mod feeds;
pub mod images;
pub mod index;
pub mod nodeinfo;
pub mod webfinger;
pub mod websocket;

3
ui/.eslintignore vendored

@ -1,3 +0,0 @@
fuse.js
translation_report.ts
src/api_tests

58
ui/.eslintrc.json vendored

@ -1,58 +0,0 @@
{
"root": true,
"env": {
"browser": true
},
"plugins": [
"jane",
"inferno"
],
"extends": [
"plugin:jane/recommended",
"plugin:jane/typescript",
"plugin:inferno/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"warnOnUnsupportedTypeScriptVersion": false
},
"rules": {
"@typescript-eslint/camelcase": 0,
"@typescript-eslint/member-delimiter-style": 0,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-this-alias": 0,
"@typescript-eslint/no-unused-vars": 0,
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-useless-constructor": 0,
"arrow-body-style": 0,
"curly": 0,
"eol-last": 0,
"eqeqeq": 0,
"func-style": 0,
"import/no-duplicates": 0,
"inferno/jsx-key": 0,
"inferno/jsx-no-target-blank": 0,
"inferno/jsx-props-class-name": 0,
"inferno/no-direct-mutation-state": 0,
"inferno/no-unknown-property": 0,
"max-statements": 0,
"max-params": 0,
"new-cap": 0,
"no-console": 0,
"no-duplicate-imports": 0,
"no-extra-parens": 0,
"no-return-assign": 0,
"no-throw-literal": 0,
"no-trailing-spaces": 0,
"no-unused-expressions": 0,
"no-useless-constructor": 0,
"no-useless-escape": 0,
"no-var": 0,
"prefer-const": 0,
"prefer-rest-params": 0,
"quote-props": 0,
"unicorn/filename-case": 0
}
}

27
ui/.gitignore vendored

@ -1,27 +0,0 @@
dist
.fusebox
_site
.alm
.history
.git
build
.build
.idea
.jshintrc
.nyc_output
.sass-cache
.vscode
coverage
jsconfig.json
Gemfile.lock
node_modules
.DS_Store
*.map
*.log
*.swp
*~
test/data/result.json
package-lock.json
*.orig

4
ui/.prettierrc.js vendored

@ -1,4 +0,0 @@
module.exports = Object.assign(require('eslint-plugin-jane/prettier-ts'), {
arrowParens: 'avoid',
semi: true,
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long

@ -1,304 +0,0 @@
.navbar-toggler {
border: 0px;
}
.navbar-expand-lg .navbar-nav .nav-link {
padding-right: .75rem !important;
padding-left: .75rem !important;
}
.pointer {
cursor: pointer;
}
.no-click {
pointer-events:none;
opacity: 0.65;
}
.upvote:hover {
color: var(--info);
}
.upvote {
margin-bottom: -5px;
}
.downvote:hover {
color: var(--danger);
}
.downvote {
margin-top: -10px;
}
.custom-select {
-moz-appearance: none;
}
.md-div p {
overflow: hidden;
text-overflow: ellipsis;
}
.md-div p:last-child {
margin-bottom: 0px;
}
.md-div img {
max-height: 40vh;
max-width: 100%;
height: auto;
}
.md-div h1,
.md-div h2,
.md-div h3,
.md-div h4,
.md-div h5 {
font-size:1.171875rem;
}
.md-div table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
border: 1px solid var(--dark);
}
.md-div table th,
.md-div table td {
padding: 0.3rem;
vertical-align: top;
border-top: 1px solid var(--dark);
border: 1px solid var(--dark);
}
.md-div table thead th {
vertical-align: bottom;
border-bottom: 2px solid var(--dark);
}
.md-div table tbody + tbody {
border-top: 2px solid var(--dark);
}
.vote-bar {
margin-top: -6.5px;
}
.post-title {
line-height: 1.0;
}
.post-title a:visited {
color: var(--gray) !important;
}
.icon {
display: inline-flex;
width: 1em;
height: 1em;
stroke-width: 0;
stroke: currentColor;
fill: currentColor;
vertical-align: middle;
align-self: center;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.icon-inline {
margin-bottom: 2px;
}
.spin {
animation: spins 2s linear infinite;
}
@keyframes spins {
0% { transform: rotate(0deg); }
100% { transform: rotate(359deg); }
}
.dropdown-menu {
z-index: 2000;
}
blockquote {
border-left: 2px solid var(--secondary);
margin: 0.5em 5px;
padding: 0.1em 5px;
}
.mouse-icon {
margin-top: -4px;
}
.new-comments {
max-height: 50vh;
overflow-x: hidden;
overflow-y: auto;
}
.thumbnail {
object-fit: cover;
min-height: 60px;
max-height: 80px;
width: 100%;
}
.thumbnail svg {
height: 1.25rem;
width: 1.25rem;
}
.no-s-hows {
position: absolute !important;
top: -9999px !important;
left: -9999px !important;
}
hr {
border-top: 1px solid var(--light);
}
.emoji {
max-height: 1.2em !important;
}
.text-wrap-truncate {
overflow: hidden;
text-overflow: ellipsis
}
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.fl-1 {
flex: 1;
}
.img-blur {
filter: blur(10px);
-webkit-filter: blur(10px);
-moz-filter: blur(10px);
-o-filter: blur(10px);
-ms-filter: blur(10px);
}
.img-expanded {
max-height: 90vh;
}
.btn-animate:active {
transform: scale(1.2);
-webkit-transform: scale(1.2);
-ms-transform: scale(1.2);
}
.selectr-selected, .selectr-options-container {
background-color: var(--secondary);
color: var(--white);
border: unset;
}
.mini-overlay {
position: absolute;
top: 0;
right: 0;
padding: 2px;
height: 1.5em;
width: 1.5em;
background: rgba(0,0,0,.4);
border-bottom-left-radius: 0.25rem !important;
border-top-right-radius: 0.25rem !important;
}
.link-overlay:hover {
transition: .1s;
opacity: 1;
}
.link-overlay {
transition: opacity .1s ease-in-out;
position: absolute;
opacity: 0;
left: 0;
height: 100%;
width: 100%;
padding: 10px;
background: rgba(0,0,0,.6);
}
.placeholder {
height: 50px;
width: 50px;
}
.unselectable {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.list-inline-item-action {
display: inline-block;
}
.list-inline-item-action:not(:last-child) {
margin-right: 1.2rem;
}
pre {
white-space: pre-wrap;
word-break: keep-all;
}
.form-control.search-input {
float: right !important;
transition: width 0.2s ease-out 0s !important;
}
.show-input {
width: 13em !important;
}
.hide-input {
background: transparent !important;
width: 0px !important;
padding: 0 !important;
}
br.big {
display: block;
content: "";
margin-top: 1rem;
}
.banner {
object-fit: cover;
width: 100%;
max-height: 240px;
}
.avatar-overlay {
width: 20%;
height: 20%;
max-width: 120px;
max-height: 120px;
}
.avatar-pushup {
margin-top: -60px;
}

@ -1,902 +0,0 @@
//
// Variables
// --------------------------------------------------
//== Colors
//
//## Gray and brand colors for use across Bootstrap.
//// colors from bs-2
// Grays
// -------------------------
$black: #000;
$grayDark: #555;
$gray: #bbb;
$grayLight: #bbb;
$white: #FFF;
// Accent colors
// -------------------------
$blue: #5555Ff;
$cyan: #55FFFF;
$cyanDark: #00AAAA;
$blueDark: #000084;
$green: #55FF55;
$greenDark: #00AA00;
$magenta: #FF55FF;
$magentaDark: #AA00AA;
$red: #FF5555;
$redDark: #AA0000;
$yellow: #FEFE54;
$brown: #AA5500;
$orange: #A85400;
$pink: #FE54FE;
$purple: #FE5454;
// end colors
$gray-base: $gray;
$gray-darker: $grayDark;
$gray-dark: $grayDark;
$gray-light: $grayLight;
$gray-lighter: $grayLight;
$brand-primary: $gray;
$brand-primary-bg: $cyanDark;
$brand-success: $greenDark;
$brand-info: $brown;
$brand-warning: $magentaDark;
$brand-danger: $redDark;
//== Scaffolding
//
//## Settings for some of the most global styles.
//** Background color for `<body>`.
$body-bg: $blueDark;
//** Global text color on `<body>`.
$text-color: $gray-light;
//** Global textual link color.
$link-color: $brand-primary;
//** Link hover color set via `darken()` function.
$link-hover-color: $white;
//** Link hover decoration.
$link-hover-decoration: none;
//== Typography
//
//## Font, line-height, and color for body text, headings, and more.
$font-family-sans-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$font-family-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
//** Default monospace fonts for `<code>`, `<kbd>`, and `<pre>`.
$font-family-monospace: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$font-family-base: $font-family-sans-serif;
$baseWidth: 10px;
$font-size-base: 18px;
$font-size-large: $font-size-base;
$font-size-small: $font-size-base;
$font-size-h1: $font-size-base;
$font-size-h2: $font-size-base;
$font-size-h3: $font-size-base;
$font-size-h4: $font-size-base;
$font-size-h5: $font-size-base;
$font-size-h6: $font-size-base;
//** Unit-less `line-height` for use in components like buttons.
$baseLineHeight: 19px;
$line-height-base: $baseLineHeight;
//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
$line-height-computed: $line-height-base;
//** By default, this inherits from the `<body>`.
$headings-font-family: inherit;
$headings-font-weight: normal;
$headings-line-height: $line-height-base;
$headings-color: inherit;
$space: $baseWidth;
$halfbaseLineHeight: ($baseLineHeight / 2);
$borderWidth: 2px;
$baseLineWidth: ($baseLineHeight / 2);
$halfSpace: ($baseWidth / 2);
$lhsNB: ($baseWidth / 2 + 1);
$rhsNB: ($baseWidth / 2 - 1);
$lhs: ($lhsNB - ($borderWidth));
$rhs: ($rhsNB - ($borderWidth / 2));
$tsNB: ($baseLineHeight / 2);
$bsNB: $tsNB;
$ts: ($tsNB - ($borderWidth / 2));
$bs: $ts;
$tsMargin: 3px;
//== Iconography
//
//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
//** Load fonts from this directory.
$icon-font-path: "../fonts/";
//** File name for all font files.
$icon-font-name: "glyphicons-halflings-regular";
//** Element ID within SVG icon file.
$icon-font-svg-id: "glyphicons_halflingsregular";
//== Components
//
//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
$padding-base-vertical: 0px;
$padding-base-horizontal: 0px;
$padding-large-vertical: 0px;
$padding-large-horizontal: $halfSpace;
$padding-small-vertical: 0px;
$padding-small-horizontal: 0px;
$padding-xs-vertical: 0px;
$padding-xs-horizontal: 0px;
$line-height-large: $baseLineHeight;
$line-height-small: $baseLineHeight;
$border-radius-base: 0;
$border-radius-large: 0;
$border-radius-small: 0;
//** Global color for active items (e.g., navs or dropdowns).
$component-active-color: $white;
//** Global background color for active items (e.g., navs or dropdowns).
$component-active-bg: $black;
//** Width of the `border` for generating carets that indicator dropdowns.
$caret-width-base: 4px;
//** Carets increase slightly in size for larger components.
$caret-width-large: 5px;
//== Tables
//
//## Customizes the `.table` component with basic values, each used across all table variations.
//** Padding for `<th>`s and `<td>`s.
$table-cell-padding: $ts $rhs $bs $lhs;
//** Padding for cells in `.table-condensed`.
$table-condensed-cell-padding: $ts $rhs $bs $lhs;
//** Default background color used for all tables.
$table-bg: transparent;
//** Background color used for `.table-striped`.
$table-bg-accent: $black;
//** Background color used for `.table-hover`.
$table-bg-hover: #f5f5f5;
$table-bg-active: $table-bg-hover;
//** Border color for table and cell borders.
$table-border-color: $gray;
//== Buttons
//
//## For each of Bootstrap's buttons, define text, background and border color.
$btn-font-weight: normal;
$btn-default-color: $black;
$btn-default-bg: $grayLight;
$btn-default-border: $grayLight;
$btn-primary-color: $black;
$btn-primary-bg: $cyanDark;
$btn-primary-border: $grayLight;
$btn-success-color: #fff;
$btn-success-bg: $brand-success;
$btn-success-border: $btn-success-bg;
$btn-info-color: #fff;
$btn-info-bg: $brand-info;
$btn-info-border: $btn-info-bg;
$btn-warning-color: #fff;
$btn-warning-bg: $brand-warning;
$btn-warning-border: $btn-warning-bg;
$btn-danger-color: #fff;
$btn-danger-bg: $brand-danger;
$btn-danger-border: $btn-danger-bg;
$btn-link-disabled-color: $gray-light;
//== Forms
//
//##
//** `<input>` background color
$input-bg: $cyanDark;
//** `<input disabled>` background color
$input-bg-disabled: $gray-lighter;
//** Text color for `<input>`s
$input-color: $white;
//** `<input>` border color
$input-border: #ccc;
// TODO: Rename `$input-border-radius` to `$input-border-radius-base` in v4
//** Default `.form-control` border radius
// This has no effect on `<select>`s in some browsers, due to the limited stylability of `<select>`s in CSS.
$input-border-radius: $border-radius-base;
//** Large `.form-control` border radius
$input-border-radius-large: $border-radius-large;
//** Small `.form-control` border radius
$input-border-radius-small: $border-radius-small;
//** Border color for inputs on focus
$input-border-focus: $black;
//** Placeholder text color
$input-color-placeholder: $black;
//** Default `.form-control` height
$input-height-base: $line-height-computed;
//** Large `.form-control` height
$input-height-large: $input-height-base;
//** Small `.form-control` height
$input-height-small: $input-height-base;
$legend-color: $gray-dark;
$legend-border-color: #e5e5e5;
//** Background color for textual input addons
$input-group-addon-bg: $gray-lighter;
//** Border color for textual input addons
$input-group-addon-border-color: $input-border;
//** Disabled cursor for form controls and buttons.
$cursor-disabled: not-allowed;
//== Dropdowns
//
//## Dropdown menu container and contents.
//** Background for the dropdown menu.
$dropdown-bg: $gray;
//** Dropdown menu `border-color`.
$dropdown-border: rgb(0,0,0);
//** Dropdown menu `border-color` **for IE8**.
$dropdown-fallback-border: #ccc;
//** Divider color for between dropdown items.
$dropdown-divider-bg: $black;
//** Dropdown link text color.
$dropdown-link-color: $black;
//** Hover color for dropdown links.
$dropdown-link-hover-color: $gray;
//** Hover background for dropdown links.
$dropdown-link-hover-bg: $black;
//** Active dropdown menu item text color.
$dropdown-link-active-color: $component-active-color;
//** Active dropdown menu item background color.
$dropdown-link-active-bg: $component-active-bg;
//** Disabled dropdown menu item background color.
$dropdown-link-disabled-color: $gray-light;
//** Text color for headers within dropdown menus.
$dropdown-header-color: $black;
//** Deprecated `$dropdown-caret-color` as of v3.1.0
$dropdown-caret-color: #000;
//-- Z-index master list
//
// Warning: Avoid customizing these values. They're used for a bird's eye view
// of components dependent on the z-axis and are designed to all work together.
//
// Note: These variables are not generated into the Customizer.
$zindex-navbar: 1000;
$zindex-dropdown: 1000;
$zindex-popover: 1060;
$zindex-tooltip: 1070;
$zindex-navbar-fixed: 1030;
$zindex-modal: 1040;
//== Media queries breakpoints
//
//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
// Extra small screen / phone
//** Deprecated `$screen-xs` as of v3.0.1
$screen-xs: 480px;
//** Deprecated `$screen-xs-min` as of v3.2.0
$screen-xs-min: $screen-xs;
//** Deprecated `$screen-phone` as of v3.0.1
$screen-phone: $screen-xs-min;
// Small screen / tablet
//** Deprecated `$screen-sm` as of v3.0.1
$screen-sm: 768px;
$screen-sm-min: $screen-sm;
//** Deprecated `$screen-tablet` as of v3.0.1
$screen-tablet: $screen-sm-min;
// Medium screen / desktop
//** Deprecated `$screen-md` as of v3.0.1
$screen-md: 992px;
$screen-md-min: $screen-md;
//** Deprecated `$screen-desktop` as of v3.0.1
$screen-desktop: $screen-md-min;
// Large screen / wide desktop
//** Deprecated `$screen-lg` as of v3.0.1
$screen-lg: 1200px;
$screen-lg-min: $screen-lg;
//** Deprecated `$screen-lg-desktop` as of v3.0.1
$screen-lg-desktop: $screen-lg-min;
// So media queries don't overlap when required, provide a maximum
$screen-xs-max: ($screen-sm-min - 1);
$screen-sm-max: ($screen-md-min - 1);
$screen-md-max: ($screen-lg-min - 1);
//== Grid system
//
//## Define your custom responsive grid.
//** Number of columns in the grid.
$grid-columns: 12;
//** Padding between columns. Gets divided in half for the left and right.
$grid-gutter-width: ($baseWidth * 2);
// Navbar collapse
//** Point at which the navbar becomes uncollapsed.
$grid-float-breakpoint: $screen-sm-min;
//** Point at which the navbar begins collapsing.
$grid-float-breakpoint-max: ($grid-float-breakpoint);
//== Container sizes
//
//## Define the maximum width of `.container` for different screen sizes.
// Small screen / tablet
$container-tablet: (720px + $grid-gutter-width);
//** For `$screen-sm-min` and up.
$container-sm: $container-tablet;
// Medium screen / desktop
$container-desktop: (940px + $grid-gutter-width);
//** For `$screen-md-min` and up.
$container-md: $container-desktop;
// Large screen / wide desktop
$container-large-desktop: (1140px + $grid-gutter-width);
//** For `$screen-lg-min` and up.
$container-lg: $container-large-desktop;
//== Navbar
//
//##
// Basics of a navbar
$navbar-height: 0px;
$navbar-margin-bottom: $line-height-computed;
$navbar-border-radius: $border-radius-base;
$navbar-padding-horizontal: ($baseWidth * 2);
$navbar-padding-vertical: 0;
$navbar-collapse-max-height: 340px;
$navbar-default-color: $black;
$navbar-default-bg: $grayLight;
$navbar-default-border: $navbar-default-bg;
// Navbar links
$navbar-default-link-color: $black;
$navbar-default-link-hover-color: $white;
$navbar-default-link-hover-bg: $black;
$navbar-default-link-active-color: $white;
$navbar-default-link-active-bg: $black;
$navbar-default-link-disabled-color: $gray;
$navbar-default-link-disabled-bg: transparent;
// Navbar brand label
$navbar-default-brand-color: $navbar-default-link-color;
$navbar-default-brand-hover-color: $navbar-default-brand-color;
$navbar-default-brand-hover-bg: transparent;
// Navbar toggle
$navbar-default-toggle-hover-bg: #ddd;
$navbar-default-toggle-icon-bar-bg: #888;
$navbar-default-toggle-border-color: #ddd;
// Inverted navbar
// Reset inverted navbar basics
$navbar-inverse-color: $gray;
$navbar-inverse-bg: $black;
$navbar-inverse-border: $navbar-inverse-bg;
// Inverted navbar links
$navbar-inverse-link-color: $gray-light;
$navbar-inverse-link-hover-color: $black;
$navbar-inverse-link-hover-bg: $grayLight;
$navbar-inverse-link-active-color: $white;
$navbar-inverse-link-active-bg: $grayDark;
$navbar-inverse-link-disabled-color: $gray;
$navbar-inverse-link-disabled-bg: transparent;
// Inverted navbar brand label
$navbar-inverse-brand-color: $navbar-inverse-link-color;
$navbar-inverse-brand-hover-color: #fff;
$navbar-inverse-brand-hover-bg: transparent;
// Inverted navbar toggle
$navbar-inverse-toggle-hover-bg: $grayLight;
$navbar-inverse-toggle-icon-bar-bg: #fff;
$navbar-inverse-toggle-border-color: #333;
//== Navs
//
//##
//=== Shared nav styles
$nav-link-padding: 0 $baseWidth;
$nav-link-hover-bg: $gray-lighter;
$nav-disabled-link-color: $gray-light;
$nav-disabled-link-hover-color: $gray-light;
//== Tabs
$nav-tabs-border-color: #ddd;
$nav-tabs-link-hover-border-color: $gray-lighter;
$nav-tabs-active-link-hover-bg: $black;
$nav-tabs-active-link-hover-color: $white;
$nav-tabs-justified-active-link-border-color: $body-bg;
//== Pills
$nav-pills-border-radius: $border-radius-base;
$nav-pills-active-link-hover-bg: $component-active-bg;
$nav-pills-active-link-hover-color: $component-active-color;
//== Pagination
//
//##
$pagination-color: $black;
$pagination-bg: $gray;
$pagination-border: #ddd;
$pagination-hover-color: $link-hover-color;
$pagination-hover-bg: $gray-lighter;
$pagination-hover-border: #ddd;
$pagination-active-color: #fff;
$pagination-active-bg: $brand-primary;
$pagination-active-border: $brand-primary;
$pagination-disabled-color: $gray-light;
$pagination-disabled-bg: #fff;
$pagination-disabled-border: #ddd;
//== Pager
//
//##
$pager-bg: $pagination-bg;
$pager-border: $pagination-border;
$pager-border-radius: 0;
$pager-hover-bg: $pagination-hover-bg;
$pager-active-bg: $pagination-active-bg;
$pager-active-color: $pagination-active-color;
$pager-disabled-color: $pagination-disabled-color;
//== Jumbotron
//
//##
$jumbotron-padding: ($ts) ($rhs + $baseWidth) ($bs) ($lhs + $baseWidth);
$jumbotron-color: $white;
$jumbotron-bg: transparent;
$jumbotron-heading-color: inherit;
$jumbotron-font-size: $font-size-base;
//== Form states and alerts
//
//## Define colors for form feedback states and, by default, alerts.
$state-success-text: $green;
$state-success-bg: $greenDark;
$state-success-border: $state-success-bg;
$state-info-text: $yellow;
$state-info-bg: $brown;
$state-info-border: $state-info-bg;
$state-warning-text: $magenta;
$state-warning-bg: $magentaDark;
$state-warning-border: $state-warning-bg;
$state-danger-text: $red;
$state-danger-bg: $black;
$state-danger-border: $state-danger-bg;
//== Tooltips
//
//##
//** Tooltip max width
$tooltip-max-width: ($baseWidth * 25);
//** Tooltip text color
$tooltip-color: $white;
//** Tooltip background color
$tooltip-bg: $grayDark;
$tooltip-opacity: 1;
//** Tooltip arrow width
$tooltip-arrow-width: 0px;
//** Tooltip arrow color
$tooltip-arrow-color: $tooltip-bg;
//== Popovers
//
//##
//** Popover body background color
$popover-bg: $gray;
//** Popover maximum width
$popover-max-width: ($baseWidth * 20);
//** Popover border color
$popover-border-color: rgb(0,0,0);
//** Popover fallback border color
$popover-fallback-border-color: #ccc;
//** Popover title background color
$popover-title-bg: $greenDark;
//** Popover arrow width
$popover-arrow-width: 10px;
//** Popover arrow color
$popover-arrow-color: $popover-bg;
//** Popover outer arrow width
$popover-arrow-outer-width: ($popover-arrow-width + 1);
//** Popover outer arrow color
$popover-arrow-outer-color: $popover-border-color;
//** Popover outer arrow fallback color
$popover-arrow-outer-fallback-color: $popover-fallback-border-color;
//== Labels
//
//##
//** Default label background color
$label-default-bg: $gray-light;
//** Primary label background color
$label-primary-bg: $brand-primary-bg;
//** Success label background color
$label-success-bg: $brand-success;
//** Info label background color
$label-info-bg: $brand-info;
//** Warning label background color
$label-warning-bg: $brand-warning;
//** Danger label background color
$label-danger-bg: $brand-danger;
//** Default label text color
$label-color: #fff;
//** Default text color of a linked label
$label-link-hover-color: #fff;
//== Modals
//
//##
//** Padding applied to the modal body
$modal-inner-padding: 0 $baseWidth;
//** Padding applied to the modal title
$modal-title-padding: 0 $baseWidth;
//** Modal title line-height
$modal-title-line-height: $line-height-base;
//** Background color of modal content area
$modal-content-bg: $gray;
//** Modal content border color
$modal-content-border-color: rgb(0,0,0);
//** Modal content border color **for IE8**
$modal-content-fallback-border-color: #999;
//** Modal backdrop background color
$modal-backdrop-bg: #000;
//** Modal backdrop opacity
// $modal-backdrop-opacity: @include 5;
//** Modal header border color
$modal-header-border-color: #e5e5e5;
//** Modal footer border color
$modal-footer-border-color: $modal-header-border-color;
$modal-lg: 900px;
$modal-md: 600px;
$modal-sm: 300px;
//== Alerts
//
//## Define alert colors, border radius, and padding.
$alert-padding: $line-height-base ($baseWidth * 2);
$alert-border-radius: $border-radius-base;
$alert-link-font-weight: normal;
$alert-success-bg: $state-success-bg;
$alert-success-text: $state-success-text;
$alert-success-border: $state-success-border;
$alert-info-bg: $state-info-bg;
$alert-info-text: $state-info-text;
$alert-info-border: $state-info-border;
$alert-warning-bg: $state-warning-bg;
$alert-warning-text: $state-warning-text;
$alert-warning-border: $state-warning-border;
$alert-danger-bg: $state-danger-bg;
$alert-danger-text: $state-danger-text;
$alert-danger-border: $state-danger-border;
//== Progress bars
//
//##
//** Background color of the whole progress component
$progress-bg: $black;
//** Progress bar text color
$progress-bar-color: $black;
//** Variable for setting rounded corners on progress bar.
$progress-border-radius: $border-radius-base;
//** Default progress bar color
$progress-bar-bg: $brand-primary;
//** Success progress bar color
$progress-bar-success-bg: $brand-success;
//** Warning progress bar color
$progress-bar-warning-bg: $brand-warning;
//** Danger progress bar color
$progress-bar-danger-bg: $brand-danger;
//** Info progress bar color
$progress-bar-info-bg: $brand-info;
//== List group
//
//##
//** Background color on `.list-group-item`
$list-group-bg: $gray;
//** `.list-group-item` border color
$list-group-border: #ddd;
//** List group border radius
$list-group-border-radius: $border-radius-base;
//** Background color of single list items on hover
$list-group-hover-bg: $black;
//** Text color of active list items
$list-group-active-color: $component-active-color;
//** Background color of active list items
$list-group-active-bg: $component-active-bg;
//** Border color of active list elements
$list-group-active-border: $list-group-active-bg;
//** Text color for content within active list items
$list-group-active-text-color: $component-active-color;
//** Text color of disabled list items
$list-group-disabled-color: $gray-dark;
//** Background color of disabled list items
$list-group-disabled-bg: $gray-lighter;
//** Text color for content within disabled list items
$list-group-disabled-text-color: $list-group-disabled-color;
$list-group-link-color: $black;
$list-group-link-hover-color: $list-group-link-color;
$list-group-link-heading-color: #333;
//== Panels
//
//##
$panel-bg: $gray;
$panel-body-padding: 0 $rhsNB 0 $lhsNB;
$panel-heading-padding: 0 $rhsNB 0 $lhsNB;
$panel-footer-padding: $panel-heading-padding;
$panel-border-radius: $border-radius-base;
//** Border color for elements within panels
$panel-inner-border: #ddd;
$panel-footer-bg: #f5f5f5;
$panel-default-text: $white;
$panel-default-border: #ddd;
$panel-default-heading-bg: $grayDark;
$panel-primary-text: $white;
$panel-primary-border: $brand-primary;
$panel-primary-heading-bg: $cyanDark;
$panel-success-text: $state-success-text;
$panel-success-border: $state-success-border;
$panel-success-heading-bg: $state-success-bg;
$panel-info-text: $state-info-text;
$panel-info-border: $state-info-border;
$panel-info-heading-bg: $state-info-bg;
$panel-warning-text: $state-warning-text;
$panel-warning-border: $state-warning-border;
$panel-warning-heading-bg: $state-warning-bg;
$panel-danger-text: $state-danger-text;
$panel-danger-border: $state-danger-border;
$panel-danger-heading-bg: $state-danger-bg;
//== Thumbnails
//
//##
//** Padding around the thumbnail image
$thumbnail-padding: 4px;
//** Thumbnail background color
$thumbnail-bg: $body-bg;
//** Thumbnail border color
$thumbnail-border: #ddd;
//** Thumbnail border radius
$thumbnail-border-radius: $border-radius-base;
//** Custom text color for thumbnail captions
$thumbnail-caption-color: $text-color;
//** Padding around the thumbnail caption
$thumbnail-caption-padding: 9px;
//== Wells
//
//##
$well-bg: $greenDark;
$well-border: $well-bg;
//== Badges
//
//##
$badge-color: $black;
//** Linked badge text color on hover
$badge-link-hover-color: #fff;
$badge-bg: $gray-light;
//** Badge text color in active nav link
$badge-active-color: $link-color;
//** Badge background color in active nav link
$badge-active-bg: $black;
$badge-font-weight: normal;
$badge-line-height: $line-height-base;
$badge-border-radius: 0;
//== Breadcrumbs
//
//##
$breadcrumb-padding-vertical: 8px;
$breadcrumb-padding-horizontal: 15px;
//** Breadcrumb background color
$breadcrumb-bg: #f5f5f5;
//** Breadcrumb text color
$breadcrumb-color: #ccc;
//** Text color of current page in the breadcrumb
$breadcrumb-active-color: $gray-light;
//** Textual separator for between breadcrumb elements
$breadcrumb-separator: "/";
//== Carousel
//
//##
$carousel-text-shadow: none;
$carousel-control-color: #fff;
$carousel-control-width: 15%;
$carousel-control-opacity: 1;
$carousel-control-font-size: $font-size-base;
$carousel-indicator-active-bg: #fff;
$carousel-indicator-border-color: #fff;
$carousel-caption-color: #fff;
//== Close
//
//##
$close-font-weight: normal;
$close-color: #000;
$close-text-shadow: none;
//== Code
//
//##
$code-color: #c7254e;
$code-bg: #f9f2f4;
$kbd-color: #fff;
$kbd-bg: #333;
$pre-bg: #f5f5f5;
$pre-color: $gray-dark;
$pre-border-color: #ccc;
$pre-scrollable-max-height: 340px;
//== Type
//
//##
//** Horizontal offset for forms and lists.
$component-offset-horizontal: 180px;
//** Text muted color
$text-muted: $gray-dark;
//** Abbreviations and acronyms border color
$abbr-border-color: $gray-light;
//** Headings small color
$headings-small-color: $gray-light;
//** Blockquote small color
$blockquote-small-color: $gray-light;
//** Blockquote font size
$blockquote-font-size: $font-size-base;
//** Blockquote border color
$blockquote-border-color: $gray-lighter;
//** Page header border color
$page-header-border-color: $gray-lighter;
//** Width of horizontal description list titles
$dl-horizontal-offset: $component-offset-horizontal;
//** Horizontal line color.
$hr-border: $black;

@ -1,104 +0,0 @@
$white: #fff;
$gray-100: #f8f9fa;
$gray-200: #ebebeb;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #888;
$gray-700: #444;
$gray-800: #303030;
$gray-900: #222;
$black: #000;
$blue: #375a7f;
$indigo: #6610f2;
$purple: #6f42c1;
$pink: #e83e8c;
$red: #e74c3c;
$orange: #fd7e14;
$yellow: #f39c12;
$green: #00bc8c;
$teal: #20c997;
$cyan: #3498db;
$primary: $blue;
$secondary: $gray-700;
$success: $green;
$info: $cyan;
$warning: $yellow;
$danger: $red;
$dark: $gray-300;
$yiq-contrasted-threshold: 175;
$body-bg: $gray-900;
$body-color: $gray-300;
$link-color: $success;
$font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-size-base: 0.9375rem;
$h1-font-size: 3rem;
$h2-font-size: 2.5rem;
$h3-font-size: 2rem;
$text-muted: $gray-600;
$table-accent-bg: $gray-800;
$table-border-color: $gray-700;
$input-border-color: $body-bg;
$input-group-addon-color: $gray-500;
$input-group-addon-bg: $gray-700;
$custom-file-color: $gray-500;
$custom-file-border-color: $body-bg;
$dropdown-bg: $gray-900;
$dropdown-border-color: $gray-700;
$dropdown-divider-bg: $gray-700;
$dropdown-link-color: $white;
$dropdown-link-hover-color: $white;
$dropdown-link-hover-bg: $primary;
$nav-link-padding-x: 2rem;
$nav-link-disabled-color: $gray-500;
$nav-tabs-border-color: $gray-700;
$nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color transparent;
$nav-tabs-link-active-color: $white;
$nav-tabs-link-active-border-color: $nav-tabs-border-color $nav-tabs-border-color transparent;
$navbar-padding-y: 1rem;
$navbar-dark-color: rgba($white,.6);
$navbar-dark-hover-color: $white;
$navbar-light-color: rgba($white,.6);
$navbar-light-hover-color: $white;
$navbar-light-active-color: $white;
$navbar-light-toggler-border-color: rgba($gray-900, .1);
$pagination-color: $white;
$pagination-bg: $success;
$pagination-border-width: 0;
$pagination-border-color: transparent;
$pagination-hover-color: $white;
$pagination-hover-bg: lighten($success, 10%);
$pagination-hover-border-color: transparent;
$pagination-active-bg: $pagination-hover-bg;
$pagination-active-border-color: transparent;
$pagination-disabled-color: $white;
$pagination-disabled-bg: darken($success, 15%);
$pagination-disabled-border-color: transparent;
$jumbotron-bg: $gray-800;
$card-cap-bg: $gray-700;
$card-bg: $gray-800;
$popover-bg: $gray-800;
$popover-header-bg: $gray-700;
$toast-background-color: $gray-700;
$toast-header-background-color: $gray-800;
$modal-content-bg: $gray-800;
$modal-content-border-color: $gray-700;
$modal-header-border-color: $gray-700;
$progress-bg: $gray-700;
$list-group-bg: $gray-800;
$list-group-border-color: $gray-700;
$list-group-hover-bg: $gray-700;
$breadcrumb-bg: $gray-700;
$close-color: $white;
$close-text-shadow: none;
$pre-color: inherit;
$mark-bg: #333;
$custom-select-bg: $secondary;
$custom-select-color: $white;
$input-bg: $secondary;
$input-color: $white;
$input-disabled-bg: darken($secondary, 10%);;
$light: $gray-800;
$navbar-light-brand-color: $navbar-dark-active-color;
$navbar-light-brand-hover-color: $navbar-dark-active-color;

@ -1,40 +0,0 @@
$blue: #5555Ff;
$cyan: #55FFFF;
$green: #55FF55;
$indigo: #FF55FF;
$red: #FF5555;
$yellow: #FEFE54;
$orange: #A85400;
$pink: #FE54FE;
$purple: #FE5454;
$primary: #FEFE54;
$body-bg: #000084;
$gray-300: #bbb;
$body-color: $gray-300;
$link-hover-color: $white;
$font-family-sans-serif: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$font-family-monospace: DOS, Monaco, Menlo, Consolas, "Courier New", monospace;
$navbar-dark-color: $gray-300;
$navbar-light-brand-color: $gray-300;
$success: #00AA00;
$danger: #AA0000;
$info: #00AAAA;
$warning: #AA00AA;
$navbar-dark-active-color: $gray-100;
$enable-rounded: false;
$input-color: $white;
$input-bg: rgb(102, 102, 102);
$input-disabled-bg: $gray-800;
$nav-tabs-link-active-color: $gray-100;
$navbar-dark-hover-color: rgba($gray-300, .75);
$light: $gray-800;
$navbar-light-disabled-color: $gray-800;
$navbar-light-active-color: $gray-100;
$navbar-light-hover-color: $gray-200;
$navbar-light-color: $gray-300;
$card-bg: $gray-800;
$card-border-color: $white;
$input-placeholder-color: $gray-500;
$mark-bg: #463b00;
$secondary: $gray-900;

@ -1,33 +0,0 @@
$white: #ffffff;
$orange: #f1641e;
$cyan: #02bdc2;
$green: #00C853;
$secondary: $green;
$body-color: $gray-700;
$link-color: theme-color("primary");;
$primary: $orange;
$red: #d8486a;
$border-radius: 0.5rem;
$border-radius-lg: 0.5rem;
$border-radius-sm: 1rem;
$font-family-sans-serif: -apple-system,BlinkMacSystemFont,"Droid Sans","Segoe UI","Helvetica",Arial,sans-serif;
$headings-color: $gray-700;
$input-btn-focus-color: rgba($component-active-bg, .75);
$form-feedback-valid-color: theme-color("info");
$navbar-light-color: $gray-600;
$black: #222222;
$navbar-dark-toggler-border-color: rgba($black, .1);
$navbar-light-active-color: $gray-900;
$card-color: $gray-700;
$card-cap-color: $gray-700;
$info: $blue;
$body-bg: #fff;
$success: $indigo;
$danger: darken($primary, 25%);
$navbar-light-hover-color: $gray-900;
$card-bg: $gray-100;
$border-color: $gray-700;
$mark-bg: rgb(255, 252, 239);
$font-weight-bold: 600;
$rounded-pill: 0.25rem;

@ -1,38 +0,0 @@
$blue: #01cdfe;
$indigo: #b967ff;
$purple: #b967ff;
$pink: rgb(255, 64, 186);
$red: rgb(255, 95, 110);
$orange: rgb(255, 167, 93);
$yellow: #fffb96;
$green: #05ffa1;
$teal: #01cdfe;
$cyan: #01cdfe;
$enable-shadows: true;
$enable-gradients: true;
$enable-responsive-font-sizes: true;
$body-bg: $gray-900;
$body-color: $gray-200;
$border-radius: 1rem;
$border-radius-lg: 1rem;
$font-family-monospace: Arial, "Noto Sans", sans-serif;
$yiq-text-light: $gray-300;
$secondary: $blue;
$text-muted: $gray-500;
$primary: $pink;
$navbar-light-hover-color: rgba($primary, .7);
$light: darken($gray-100,1.5);
$font-family-sans-serif: "Lucida Console", Monaco, monospace;
$card-bg: $body-bg;
$navbar-dark-color: rgba($body-bg, .5);
$navbar-light-active-color: rgba($gray-200, .9);
$navbar-light-disabled-color: rgba($gray-200, .3);
$navbar-light-color: rgba($white, .5);
$input-bg: $gray-700;
$input-color: $gray-200;
$input-disabled-bg: $gray-800;
$input-border-color: $gray-800;
$mark-bg: $gray-600;
$pre-color: $gray-200;
mark-bg: $gray-600;

@ -1,25 +0,0 @@
$blue: #01cdfe;
$indigo: #b967ff;
$purple: #b967ff;
$pink: rgb(255, 64, 186);
$red: rgb(255, 95, 110);
$orange: rgb(255, 167, 93);
$yellow: #fffb96;
$green: #05ffa1;
$teal: #01cdfe;
$cyan: #01cdfe;
$enable-shadows: true;
$enable-gradients: true;
$enable-responsive-font-sizes: true;
$body-bg: $gray-100;
$body-color: $gray-700;
$border-radius: 1rem;
$border-radius-lg: 1rem;
$font-family-monospace: Arial, "Noto Sans", sans-serif;
$yiq-text-light: $gray-300;
$secondary: $blue;
$text-muted: $gray-500;
$primary: $pink;
$navbar-light-hover-color: rgba($primary, .7);
$light: darken($gray-100,1.5);
$font-family-sans-serif: "Lucida Console", Monaco, monospace;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}.tippy-iOS{cursor:pointer!important;-webkit-tap-highlight-color:transparent}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{border-width:8px 8px 0;border-top-color:#333;bottom:-7px;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;border-width:0 8px 8px;border-bottom-color:#333;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:#333;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:#333;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}

@ -1,78 +0,0 @@
/*!
* Toastify js 1.6.2
* https://github.com/apvarun/toastify-js
* @license MIT licensed
*
* Copyright (C) 2018 Varun A P
*/
.toastify {
padding: 12px 20px;
color: #ffffff;
display: inline-block;
box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3);
background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5);
background: linear-gradient(135deg, #73a5ff, #5477f5);
position: fixed;
opacity: 0;
transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
border-radius: 2px;
cursor: pointer;
text-decoration: none;
max-width: calc(50% - 20px);
z-index: 2147483647;
}
.toastify.on {
opacity: 1;
}
.toast-close {
opacity: 0.4;
padding: 0 5px;
}
.toastify-right {
right: 15px;
}
.toastify-left {
left: 15px;
}
.toastify-top {
top: -150px;
}
.toastify-bottom {
bottom: -150px;
}
.toastify-rounded {
border-radius: 25px;
}
.toastify-avatar {
width: 1.5em;
height: 1.5em;
margin: 0 5px;
border-radius: 2px;
}
.toastify-center {
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
max-width: fit-content;
}
@media only screen and (max-width: 360px) {
.toastify-right, .toastify-left {
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
max-width: fit-content;
}
}

@ -1,31 +0,0 @@
body {
position: relative;
}
.tribute-container {
position: absolute;
top: 0;
left: 0;
height: auto;
max-height: 300px;
max-width: 500px;
overflow: auto;
display: block;
z-index: 999999; }
.tribute-container ul {
margin: 0;
margin-top: 2px;
padding: 0;
list-style: none;
background: var(--light); }
.tribute-container li {
padding: 5px 5px;
cursor: pointer; }
.tribute-container li.highlight {
background: var(--primary); }
.tribute-container li span {
font-weight: bold; }
.tribute-container li.no-match {
cursor: default; }
.tribute-container .menu-highlighted {
font-weight: bold; }

@ -1,118 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1024"
height="1024"
viewBox="0 0 1024 1024"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (unknown)"
sodipodi:docname="lemmy-logo-border.svg"
inkscape:export-filename="/home/andres/Pictures/References/Logos/Lemmy/lemmy-logo-border.png"
inkscape:export-xdpi="300"
inkscape:export-ydpi="300"
enable-background="new">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.49497475"
inkscape:cx="452.38625"
inkscape:cy="470.53357"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:showpageshadow="false"
inkscape:window-width="1366"
inkscape:window-height="740"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
showguides="true"
inkscape:guide-bbox="true"
inkscape:snap-global="true"
inkscape:snap-midpoints="false"
inkscape:snap-smooth-nodes="false"
inkscape:object-paths="false"
inkscape:pagecheckerboard="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-26.066658)"
style="display:inline">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 167.03908,270.78735 c -0.94784,-0.002 -1.8939,0.004 -2.83789,0.0215 -4.31538,0.0778 -8.58934,0.3593 -12.8125,0.8457 -33.78522,3.89116 -64.215716,21.86394 -82.871086,53.27344 -18.27982,30.77718 -22.77749,64.66635 -13.46094,96.06837 9.31655,31.40203 31.88488,59.93174 65.296886,82.5332 0.20163,0.13618 0.40678,0.26709 0.61523,0.39258 28.65434,17.27768 57.18167,28.93179 87.74218,34.95508 -0.74566,12.61339 -0.72532,25.5717 0.082,38.84375 2.43989,40.10943 16.60718,77.03742 38.0957,109.67187 l -77.00781,31.4375 c -8.30605,3.25932 -12.34178,12.68234 -8.96967,20.94324 3.37211,8.2609 12.84919,12.16798 21.06342,8.68371 l 84.69727,-34.57617 c 15.70675,18.72702 33.75346,35.68305 53.12109,50.57032 0.74013,0.56891 1.4904,1.12236 2.23437,1.68554 l -49.61132,65.69141 c -5.45446,7.0474 -4.10058,17.19288 3.01098,22.5634 7.11156,5.37052 17.24028,3.89649 22.52612,-3.27824 l 50.38672,-66.71876 c 27.68572,17.53469 57.07524,31.20388 86.07227,40.25196 14.88153,27.28008 43.96965,44.64648 77.58789,44.64648 33.93762,0 63.04252,-18.68693 77.80082,-45.4375 28.7072,-9.21295 57.7527,-22.93196 85.1484,-40.40234 l 51.0977,67.66016 c 5.2858,7.17473 15.4145,8.64876 22.5261,3.27824 7.1115,-5.37052 8.4654,-15.516 3.011,-22.5634 l -50.3614,-66.68555 c 0.334,-0.25394 0.6727,-0.50077 1.0059,-0.75586 19.1376,-14.64919 37.0259,-31.28581 52.7031,-49.63476 l 82.5625,33.70507 c 8.2143,3.48427 17.6913,-0.42281 21.0634,-8.68371 3.3722,-8.2609 -0.6636,-17.68392 -8.9696,-20.94324 l -74.5391,-30.42773 c 22.1722,-32.82971 37.0383,-70.03397 40.1426,-110.46094 1.0253,-13.35251 1.2292,-26.42535 0.6387,-39.17578 30.3557,-6.05408 58.7164,-17.66833 87.2011,-34.84375 0.2085,-0.12549 0.4136,-0.2564 0.6153,-0.39258 33.412,-22.60147 55.9803,-51.13117 65.2968,-82.5332 9.3166,-31.40202 4.8189,-65.29118 -13.4609,-96.06837 -18.6553,-31.40951 -49.0859,-49.38228 -82.8711,-53.27344 -4.2231,-0.4864 -8.4971,-0.76791 -12.8125,-0.8457 -30.2077,-0.54448 -62.4407,8.82427 -93.4316,26.71484 -22.7976,13.16063 -43.3521,33.31423 -59.4375,55.30469 -44.9968,-25.75094 -103.5444,-40.25065 -175.4785,-41.43945 -6.4522,-0.10663 -13.0125,-0.10696 -19.67974,0.002 -80.18875,1.30929 -144.38284,16.5086 -192.87109,43.9922 -0.11914,-0.19111 -0.24287,-0.37932 -0.37109,-0.56446 -16.29,-22.764 -37.41085,-43.73706 -60.89649,-57.29493 -30.02247,-17.33149 -61.21051,-26.66489 -90.59375,-26.73633 z"
id="path817-3"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccssccccccscccccscccscccscccccsccscccssccscscccscc"
inkscape:label="white-border"
sodipodi:insensitive="true" />
<path
id="path1087"
style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 716.85595,362.96478 c 15.29075,-21.36763 35.36198,-41.10921 56.50979,-53.31749 66.66377,-38.48393 137.02617,-33.22172 170.08018,22.43043 33.09493,55.72093 14.98656,117.48866 -47.64399,159.85496 -31.95554,19.26819 -62.93318,30.92309 -97.22892,35.54473 M 307.14407,362.96478 C 291.85332,341.59715 271.78209,321.85557 250.63429,309.64729 183.97051,271.16336 113.60811,276.42557 80.554051,332.07772 47.459131,387.79865 65.56752,449.56638 128.19809,491.93268 c 31.95554,19.26819 62.93319,30.92309 97.22893,35.54473"
inkscape:connector-curvature="0"
inkscape:label="ears"
sodipodi:insensitive="true" />
<path
style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 801.23205,576.8699 C 812.73478,427.06971 720.58431,321.98291 511.99999,325.38859 303.41568,328.79426 213.71393,428.0311 222.76794,576.8699 c 8.64289,142.08048 176.80223,246.40388 288.12038,246.40388 111.31815,0 279.45076,-104.5447 290.34373,-246.40388 z"
id="path969"
inkscape:connector-curvature="0"
sodipodi:nodetypes="szszs"
inkscape:label="head"
sodipodi:insensitive="true" />
<path
id="path1084"
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 610.4991,644.28932 c 0,23.11198 18.70595,41.84795 41.78091,41.84795 23.07495,0 41.7809,-18.73597 41.7809,-41.84795 0,-23.112 -18.70594,-41.84796 -41.7809,-41.84796 -23.07496,0 -41.78091,18.73596 -41.78091,41.84796 z m -280.56002,0 c 0,23.32492 18.87829,42.23352 42.16586,42.23352 23.28755,0 42.16585,-18.9086 42.16585,-42.23352 0,-23.32494 -18.87829,-42.23353 -42.16585,-42.23353 -23.28757,0 -42.16586,18.90859 -42.16586,42.23353 z"
inkscape:connector-curvature="0"
inkscape:label="eyes"
sodipodi:nodetypes="ssssssssss"
sodipodi:insensitive="true" />
<path
id="path1008"
style="display:inline;opacity:1;fill:none;stroke:#000000;stroke-width:32;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 339.72919,769.2467 -54.54422,72.22481 m 399.08582,-72.22481 54.54423,72.22481 M 263.68341,697.82002 175.92752,733.64353 m 579.85765,-35.82351 87.7559,35.82351"
inkscape:connector-curvature="0"
inkscape:label="whiskers"
sodipodi:nodetypes="cccccccc"
sodipodi:insensitive="true" />
<path
style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 512.00082,713.08977 c -45.86417,0 -75.13006,31.84485 -74.14159,71.10084 1.07048,42.51275 32.46865,71.10323 74.14159,71.10323 41.67296,0 74.05118,-32.99608 74.14161,-71.10323 0.0932,-39.26839 -28.27742,-71.10084 -74.14161,-71.10084 z"
id="path1115"
inkscape:connector-curvature="0"
inkscape:label="nose"
sodipodi:nodetypes="zszsz"
sodipodi:insensitive="true" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

@ -1,2 +0,0 @@
/*! sortable.js 0.8.0 */
(function(){var a,b,c,d,e,f,g;a="table[data-sortable]",d=/^-?[£$¤]?[\d,.]+%?$/,g=/^\s+|\s+$/g,c=["click"],f="ontouchstart"in document.documentElement,f&&c.push("touchstart"),b=function(a,b,c){return null!=a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent("on"+b,c)},e={init:function(b){var c,d,f,g,h;for(null==b&&(b={}),null==b.selector&&(b.selector=a),d=document.querySelectorAll(b.selector),h=[],f=0,g=d.length;g>f;f++)c=d[f],h.push(e.initTable(c));return h},initTable:function(a){var b,c,d,f,g,h;if(1===(null!=(h=a.tHead)?h.rows.length:void 0)&&"true"!==a.getAttribute("data-sortable-initialized")){for(a.setAttribute("data-sortable-initialized","true"),d=a.querySelectorAll("th"),b=f=0,g=d.length;g>f;b=++f)c=d[b],"false"!==c.getAttribute("data-sortable")&&e.setupClickableTH(a,c,b);return a}},setupClickableTH:function(a,d,f){var g,h,i,j,k,l;for(i=e.getColumnType(a,f),h=function(b){var c,g,h,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,A,B,C,D;if(b.handled===!0)return!1;for(b.handled=!0,m="true"===this.getAttribute("data-sorted"),n=this.getAttribute("data-sorted-direction"),h=m?"ascending"===n?"descending":"ascending":i.defaultSortDirection,p=this.parentNode.querySelectorAll("th"),s=0,w=p.length;w>s;s++)d=p[s],d.setAttribute("data-sorted","false"),d.removeAttribute("data-sorted-direction");if(this.setAttribute("data-sorted","true"),this.setAttribute("data-sorted-direction",h),o=a.tBodies[0],l=[],m){for(D=o.rows,v=0,z=D.length;z>v;v++)g=D[v],l.push(g);for(l.reverse(),B=0,A=l.length;A>B;B++)k=l[B],o.appendChild(k)}else{for(r=null!=i.compare?i.compare:function(a,b){return b-a},c=function(a,b){return a[0]===b[0]?a[2]-b[2]:i.reverse?r(b[0],a[0]):r(a[0],b[0])},C=o.rows,j=t=0,x=C.length;x>t;j=++t)k=C[j],q=e.getNodeValue(k.cells[f]),null!=i.comparator&&(q=i.comparator(q)),l.push([q,k,j]);for(l.sort(c),u=0,y=l.length;y>u;u++)k=l[u],o.appendChild(k[1])}return"function"==typeof window.CustomEvent&&"function"==typeof a.dispatchEvent?a.dispatchEvent(new CustomEvent("Sortable.sorted",{bubbles:!0})):void 0},l=[],j=0,k=c.length;k>j;j++)g=c[j],l.push(b(d,g,h));return l},getColumnType:function(a,b){var c,d,f,g,h,i,j,k,l,m,n;if(d=null!=(l=a.querySelectorAll("th")[b])?l.getAttribute("data-sortable-type"):void 0,null!=d)return e.typesObject[d];for(m=a.tBodies[0].rows,h=0,j=m.length;j>h;h++)for(c=m[h],f=e.getNodeValue(c.cells[b]),n=e.types,i=0,k=n.length;k>i;i++)if(g=n[i],g.match(f))return g;return e.typesObject.alpha},getNodeValue:function(a){var b;return a?(b=a.getAttribute("data-value"),null!==b?b:"undefined"!=typeof a.innerText?a.innerText.replace(g,""):a.textContent.replace(g,"")):""},setupTypes:function(a){var b,c,d,f;for(e.types=a,e.typesObject={},f=[],c=0,d=a.length;d>c;c++)b=a[c],f.push(e.typesObject[b.name]=b);return f}},e.setupTypes([{name:"numeric",defaultSortDirection:"descending",match:function(a){return a.match(d)},comparator:function(a){return parseFloat(a.replace(/[^0-9.-]/g,""),10)||0}},{name:"date",defaultSortDirection:"ascending",reverse:!0,match:function(a){return!isNaN(Date.parse(a))},comparator:function(a){return Date.parse(a)||0}},{name:"alpha",defaultSortDirection:"ascending",match:function(){return!0},compare:function(a,b){return a.localeCompare(b)}}]),setTimeout(e.init,0),"function"==typeof define&&define.amd?define(function(){return e}):"undefined"!=typeof exports?module.exports=e:window.Sortable=e}).call(this);

@ -1,49 +0,0 @@
{
"name": "Lemmy",
"description": "A link aggregator for the fediverse",
"start_url": "/",
"display": "minimal-ui",
"background_color": "#222222",
"icons": [
{
"src": "/static/assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/static/assets/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/static/assets/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/static/assets/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/static/assets/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/static/assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/assets/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/static/assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

60
ui/fuse.js vendored

@ -1,60 +0,0 @@
const {
FuseBox,
Sparky,
EnvPlugin,
CSSPlugin,
WebIndexPlugin,
QuantumPlugin,
} = require('fuse-box');
const transformInferno = require('ts-transform-inferno').default;
const transformClasscat = require('ts-transform-classcat').default;
let fuse, app;
let isProduction = false;
Sparky.task('config', _ => {
fuse = new FuseBox({
homeDir: 'src',
hash: isProduction,
output: 'dist/$name.js',
experimentalFeatures: true,
cache: !isProduction,
sourceMaps: !isProduction,
transformers: {
before: [transformClasscat(), transformInferno()],
},
alias: {
locale: 'moment/locale',
},
plugins: [
EnvPlugin({ NODE_ENV: isProduction ? 'production' : 'development' }),
CSSPlugin(),
WebIndexPlugin({
title: 'Inferno Typescript FuseBox Example',
template: 'src/index.html',
path: isProduction ? '/static' : '/',
}),
isProduction &&
QuantumPlugin({
bakeApiIntoBundle: 'app',
treeshake: true,
uglify: true,
}),
],
});
app = fuse.bundle('app').instructions('>index.tsx');
});
Sparky.task('clean', _ => Sparky.src('dist/').clean('dist/'));
Sparky.task('env', _ => (isProduction = true));
Sparky.task('copy-assets', () =>
Sparky.src('assets/**/**.*').dest(isProduction ? 'dist/' : 'dist/static')
);
Sparky.task('dev', ['clean', 'config', 'copy-assets'], _ => {
fuse.dev({
fallback: 'index.html',
});
app.hmr().watch();
return fuse.run();
});
Sparky.task('prod', ['clean', 'env', 'config', 'copy-assets'], _ => {
return fuse.run();
});

@ -1,25 +0,0 @@
fs = require('fs');
fs.mkdirSync('src/translations/', { recursive: true });
fs.readdir('translations', (err, files) => {
files.forEach(filename => {
const lang = filename.split('.')[0];
try {
const json = JSON.parse(
fs.readFileSync('translations/' + filename, 'utf8')
);
var data = `export const ${lang} = {\n translation: {`;
for (var key in json) {
if (key in json) {
const value = json[key].replace(/"/g, '\\"');
data = `${data}\n ${key}: "${value}",`;
}
}
data += '\n },\n};';
const target = 'src/translations/' + lang + '.ts';
fs.writeFileSync(target, data);
} catch (err) {
console.error(err);
}
});
});

10
ui/jest.config.js vendored

@ -1,10 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testTimeout: 30000,
globals: {
'ts-jest': {
diagnostics: false,
},
},
};

93
ui/package.json vendored

@ -1,93 +0,0 @@
{
"name": "lemmy",
"description": "The official Lemmy UI",
"version": "1.0.0",
"author": "Dessalines",
"license": "AGPL-3.0-or-later",
"main": "index.js",
"scripts": {
"api-test": "jest src/api_tests/ -i --verbose",
"build": "node fuse prod",
"lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src",
"prebuild": "node generate_translations.js",
"prestart": "node generate_translations.js",
"start": "node fuse dev"
},
"keywords": [],
"dependencies": {
"@types/autosize": "^3.0.6",
"@types/jest": "^26.0.7",
"@types/js-cookie": "^2.2.6",
"@types/jwt-decode": "^2.2.1",
"@types/markdown-it": "^10.0.1",
"@types/markdown-it-container": "^2.0.2",
"@types/node": "^14.0.26",
"@types/node-fetch": "^2.5.6",
"autosize": "^4.0.2",
"bootswatch": "^4.3.1",
"choices.js": "^9.0.1",
"classcat": "^4.0.2",
"dotenv": "^8.2.0",
"emoji-short-name": "^1.0.0",
"husky": "^4.2.5",
"i18next": "^19.4.1",
"inferno": "^7.4.2",
"inferno-helmet": "^5.2.1",
"inferno-i18next": "github:nimbusec-oss/inferno-i18next#semver:^7.4.2",
"inferno-router": "^7.4.2",
"js-cookie": "^2.2.0",
"jwt-decode": "^2.2.0",
"lemmy-js-client": "^1.0.9",
"markdown-it": "^11.0.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^1.4.0",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"moment": "^2.24.0",
"node-fetch": "^2.6.0",
"prettier": "^2.0.4",
"reconnecting-websocket": "^4.4.0",
"register-service-worker": "^1.7.1",
"rxjs": "^6.5.5",
"terser": "^4.6.11",
"tippy.js": "^6.1.1",
"toastify-js": "^1.7.0",
"tributejs": "^5.1.3",
"ws": "^7.2.3"
},
"devDependencies": {
"eslint": "^7.5.0",
"eslint-plugin-inferno": "^7.14.3",
"eslint-plugin-jane": "^8.0.4",
"fuse-box": "^3.1.3",
"jest": "^26.0.7",
"lint-staged": "^10.1.3",
"sortpack": "^2.1.4",
"ts-jest": "^26.1.3",
"ts-node": "^8.8.2",
"ts-transform-classcat": "^1.0.0",
"ts-transform-inferno": "^4.0.3",
"typescript": "^3.8.3"
},
"engines": {
"node": ">=8.9.0"
},
"engineStrict": true,
"husky": {
"hooks": {
"pre-commit": "cargo +nightly clippy --manifest-path ../server/Cargo.toml --all-targets --workspace -- -D warnings && lint-staged"
}
},
"lint-staged": {
"*.{ts,tsx,js}": [
"prettier --write",
"eslint --fix"
],
"../server/src/**/*.rs": [
"rustfmt +nightly --config-path ../server/.rustfmt.toml"
],
"package.json": [
"sortpack"
]
}
}

@ -1,367 +0,0 @@
jest.setTimeout(120000);
import {
alpha,
beta,
gamma,
setupLogins,
createPost,
getPost,
searchComment,
likeComment,
followBeta,
searchForBetaCommunity,
createComment,
updateComment,
deleteComment,
removeComment,
getMentions,
searchPost,
unfollowRemotes,
createCommunity,
registerUser,
API,
delay,
} from './shared';
import { PostResponse } from 'lemmy-js-client';
let postRes: PostResponse;
beforeAll(async () => {
await setupLogins();
await followBeta(alpha);
await followBeta(gamma);
let search = await searchForBetaCommunity(alpha);
await delay(10000);
postRes = await createPost(
alpha,
search.communities.filter(c => c.local == false)[0].id
);
});
afterAll(async () => {
await unfollowRemotes(alpha);
await unfollowRemotes(gamma);
});
test('Create a comment', async () => {
let commentRes = await createComment(alpha, postRes.post.id);
expect(commentRes.comment.content).toBeDefined();
expect(commentRes.comment.community_local).toBe(false);
expect(commentRes.comment.creator_local).toBe(true);
expect(commentRes.comment.score).toBe(1);
await delay();
// Make sure that comment is liked on beta
let searchBeta = await searchComment(beta, commentRes.comment);
let betaComment = searchBeta.comments[0];
expect(betaComment).toBeDefined();
expect(betaComment.community_local).toBe(true);
expect(betaComment.creator_local).toBe(false);
expect(betaComment.score).toBe(1);
});
test('Create a comment in a non-existent post', async () => {
let commentRes = await createComment(alpha, -1);
expect(commentRes).toStrictEqual({ error: 'couldnt_find_post' });
});
test('Update a comment', async () => {
let commentRes = await createComment(alpha, postRes.post.id);
await delay();
let updateCommentRes = await updateComment(alpha, commentRes.comment.id);
expect(updateCommentRes.comment.content).toBe(
'A jest test federated comment update'
);
expect(updateCommentRes.comment.community_local).toBe(false);
expect(updateCommentRes.comment.creator_local).toBe(true);
await delay();
// Make sure that post is updated on beta
let searchBeta = await searchComment(beta, commentRes.comment);
let betaComment = searchBeta.comments[0];
expect(betaComment.content).toBe('A jest test federated comment update');
});
test('Delete a comment', async () => {
let commentRes = await createComment(alpha, postRes.post.id);
await delay();
let deleteCommentRes = await deleteComment(
alpha,
true,
commentRes.comment.id
);
expect(deleteCommentRes.comment.deleted).toBe(true);
await delay();
// Make sure that comment is undefined on beta
let searchBeta = await searchComment(beta, commentRes.comment);
let betaComment = searchBeta.comments[0];
expect(betaComment).toBeUndefined();
await delay();
let undeleteCommentRes = await deleteComment(
alpha,
false,
commentRes.comment.id
);
expect(undeleteCommentRes.comment.deleted).toBe(false);
await delay();
// Make sure that comment is undeleted on beta
let searchBeta2 = await searchComment(beta, commentRes.comment);
let betaComment2 = searchBeta2.comments[0];
expect(betaComment2.deleted).toBe(false);
});
test('Remove a comment from admin and community on the same instance', async () => {
let commentRes = await createComment(alpha, postRes.post.id);
await delay();
// Get the id for beta
let betaCommentId = (await searchComment(beta, commentRes.comment))
.comments[0].id;
// The beta admin removes it (the community lives on beta)
let removeCommentRes = await removeComment(beta, true, betaCommentId);
expect(removeCommentRes.comment.removed).toBe(true);
await delay();
// Make sure that comment is removed on alpha (it gets pushed since an admin from beta removed it)
let refetchedPost = await getPost(alpha, postRes.post.id);
expect(refetchedPost.comments[0].removed).toBe(true);
let unremoveCommentRes = await removeComment(beta, false, betaCommentId);
expect(unremoveCommentRes.comment.removed).toBe(false);
await delay();
// Make sure that comment is unremoved on beta
let refetchedPost2 = await getPost(alpha, postRes.post.id);
expect(refetchedPost2.comments[0].removed).toBe(false);
});
test('Remove a comment from admin and community on different instance', async () => {
let alphaUser = await registerUser(alpha);
let newAlphaApi: API = {
client: alpha.client,
auth: alphaUser.jwt,
};
// New alpha user creates a community, post, and comment.
let newCommunity = await createCommunity(newAlphaApi);
await delay();
let newPost = await createPost(newAlphaApi, newCommunity.community.id);
await delay();
let commentRes = await createComment(newAlphaApi, newPost.post.id);
expect(commentRes.comment.content).toBeDefined();
await delay();
// Beta searches that to cache it, then removes it
let searchBeta = await searchComment(beta, commentRes.comment);
let betaComment = searchBeta.comments[0];
let removeCommentRes = await removeComment(beta, true, betaComment.id);
expect(removeCommentRes.comment.removed).toBe(true);
await delay();
// Make sure its not removed on alpha
let refetchedPost = await getPost(newAlphaApi, newPost.post.id);
expect(refetchedPost.comments[0].removed).toBe(false);
});
test('Unlike a comment', async () => {
let commentRes = await createComment(alpha, postRes.post.id);
await delay();
let unlike = await likeComment(alpha, 0, commentRes.comment);
expect(unlike.comment.score).toBe(0);
await delay();
// Make sure that post is unliked on beta
let searchBeta = await searchComment(beta, commentRes.comment);
let betaComment = searchBeta.comments[0];
expect(betaComment).toBeDefined();
expect(betaComment.community_local).toBe(true);
expect(betaComment.creator_local).toBe(false);
expect(betaComment.score).toBe(0);
});
test('Federated comment like', async () => {
let commentRes = await createComment(alpha, postRes.post.id);
await delay();
// Find the comment on beta
let searchBeta = await searchComment(beta, commentRes.comment);
let betaComment = searchBeta.comments[0];
let like = await likeComment(beta, 1, betaComment);
expect(like.comment.score).toBe(2);
await delay();
// Get the post from alpha, check the likes
let post = await getPost(alpha, postRes.post.id);
expect(post.comments[0].score).toBe(2);
});
test('Reply to a comment', async () => {
// Create a comment on alpha, find it on beta
let commentRes = await createComment(alpha, postRes.post.id);
await delay();
let searchBeta = await searchComment(beta, commentRes.comment);
let betaComment = searchBeta.comments[0];
// find that comment id on beta
// Reply from beta
let replyRes = await createComment(beta, betaComment.post_id, betaComment.id);
expect(replyRes.comment.content).toBeDefined();
expect(replyRes.comment.community_local).toBe(true);
expect(replyRes.comment.creator_local).toBe(true);
expect(replyRes.comment.parent_id).toBe(betaComment.id);
expect(replyRes.comment.score).toBe(1);
await delay();
// Make sure that comment is seen on alpha
// TODO not sure why, but a searchComment back to alpha, for the ap_id of betas
// comment, isn't working.
// let searchAlpha = await searchComment(alpha, replyRes.comment);
let post = await getPost(alpha, postRes.post.id);
let alphaComment = post.comments[0];
expect(alphaComment.content).toBeDefined();
expect(alphaComment.parent_id).toBe(post.comments[1].id);
expect(alphaComment.community_local).toBe(false);
expect(alphaComment.creator_local).toBe(false);
expect(alphaComment.score).toBe(1);
});
test('Mention beta', async () => {
// Create a mention on alpha
let mentionContent = 'A test mention of @lemmy_beta@lemmy-beta:8550';
let commentRes = await createComment(alpha, postRes.post.id);
await delay();
let mentionRes = await createComment(
alpha,
postRes.post.id,
commentRes.comment.id,
mentionContent
);
expect(mentionRes.comment.content).toBeDefined();
expect(mentionRes.comment.community_local).toBe(false);
expect(mentionRes.comment.creator_local).toBe(true);
expect(mentionRes.comment.score).toBe(1);
await delay();
let mentionsRes = await getMentions(beta);
expect(mentionsRes.mentions[0].content).toBeDefined();
expect(mentionsRes.mentions[0].community_local).toBe(true);
expect(mentionsRes.mentions[0].creator_local).toBe(false);
expect(mentionsRes.mentions[0].score).toBe(1);
});
test('Comment Search', async () => {
let commentRes = await createComment(alpha, postRes.post.id);
await delay();
let searchBeta = await searchComment(beta, commentRes.comment);
expect(searchBeta.comments[0].ap_id).toBe(commentRes.comment.ap_id);
});
test('A and G subscribe to B (center) A posts, G mentions B, it gets announced to A', async () => {
// Create a local post
let alphaPost = await createPost(alpha, 2);
expect(alphaPost.post.community_local).toBe(true);
await delay();
// Make sure gamma sees it
let search = await searchPost(gamma, alphaPost.post);
let gammaPost = search.posts[0];
let commentContent =
'A jest test federated comment announce, lets mention @lemmy_beta@lemmy-beta:8550';
let commentRes = await createComment(
gamma,
gammaPost.id,
undefined,
commentContent
);
expect(commentRes.comment.content).toBe(commentContent);
expect(commentRes.comment.community_local).toBe(false);
expect(commentRes.comment.creator_local).toBe(true);
expect(commentRes.comment.score).toBe(1);
await delay();
// Make sure alpha sees it
let alphaPost2 = await getPost(alpha, alphaPost.post.id);
expect(alphaPost2.comments[0].content).toBe(commentContent);
expect(alphaPost2.comments[0].community_local).toBe(true);
expect(alphaPost2.comments[0].creator_local).toBe(false);
expect(alphaPost2.comments[0].score).toBe(1);
// Make sure beta has mentions
let mentionsRes = await getMentions(beta);
expect(mentionsRes.mentions[0].content).toBe(commentContent);
expect(mentionsRes.mentions[0].community_local).toBe(false);
expect(mentionsRes.mentions[0].creator_local).toBe(false);
// TODO this is failing because fetchInReplyTos aren't getting score
// expect(mentionsRes.mentions[0].score).toBe(1);
});
test('Fetch in_reply_tos: A is unsubbed from B, B makes a post, and some embedded comments, A subs to B, B updates the lowest level comment, A fetches both the post and all the inreplyto comments for that post.', async () => {
// Unfollow all remote communities
let followed = await unfollowRemotes(alpha);
expect(
followed.communities.filter(c => c.community_local == false).length
).toBe(0);
// B creates a post, and two comments, should be invisible to A
let postRes = await createPost(beta, 2);
expect(postRes.post.name).toBeDefined();
await delay();
let parentCommentContent = 'An invisible top level comment from beta';
let parentCommentRes = await createComment(
beta,
postRes.post.id,
undefined,
parentCommentContent
);
expect(parentCommentRes.comment.content).toBe(parentCommentContent);
await delay();
// B creates a comment, then a child one of that.
let childCommentContent = 'An invisible child comment from beta';
let childCommentRes = await createComment(
beta,
postRes.post.id,
parentCommentRes.comment.id,
childCommentContent
);
expect(childCommentRes.comment.content).toBe(childCommentContent);
await delay();
// Follow beta again
let follow = await followBeta(alpha);
expect(follow.community.local).toBe(false);
expect(follow.community.name).toBe('main');
await delay();
// An update to the child comment on beta, should push the post, parent, and child to alpha now
let updatedCommentContent = 'An update child comment from beta';
let updateRes = await updateComment(
beta,
childCommentRes.comment.id,
updatedCommentContent
);
expect(updateRes.comment.content).toBe(updatedCommentContent);
await delay();
// Get the post from alpha
let search = await searchPost(alpha, postRes.post);
let alphaPostB = search.posts[0];
await delay();
let alphaPost = await getPost(alpha, alphaPostB.id);
expect(alphaPost.post.name).toBeDefined();
expect(alphaPost.comments[1].content).toBe(parentCommentContent);
expect(alphaPost.comments[0].content).toBe(updatedCommentContent);
expect(alphaPost.post.community_local).toBe(false);
expect(alphaPost.post.creator_local).toBe(false);
});

@ -1,95 +0,0 @@
import {
alpha,
beta,
setupLogins,
searchForBetaCommunity,
createCommunity,
deleteCommunity,
removeCommunity,
delay,
} from './shared';
beforeAll(async () => {
await setupLogins();
});
test('Create community', async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community.name).toBeDefined();
// A dupe check
let prevName = communityRes.community.name;
let communityRes2 = await createCommunity(alpha, prevName);
expect(communityRes2['error']).toBe('community_already_exists');
});
test('Delete community', async () => {
let communityRes = await createCommunity(beta);
await delay();
let deleteCommunityRes = await deleteCommunity(
beta,
true,
communityRes.community.id
);
expect(deleteCommunityRes.community.deleted).toBe(true);
await delay();
// Make sure it got deleted on A
let search = await searchForBetaCommunity(alpha);
let communityA = search.communities[0];
// TODO this fails currently, because no updates are pushed
// expect(communityA.deleted).toBe(true);
// Undelete
let undeleteCommunityRes = await deleteCommunity(
beta,
false,
communityRes.community.id
);
expect(undeleteCommunityRes.community.deleted).toBe(false);
await delay();
// Make sure it got undeleted on A
let search2 = await searchForBetaCommunity(alpha);
let communityA2 = search2.communities[0];
// TODO this fails currently, because no updates are pushed
// expect(communityA2.deleted).toBe(false);
});
test('Remove community', async () => {
let communityRes = await createCommunity(beta);
await delay();
let removeCommunityRes = await removeCommunity(
beta,
true,
communityRes.community.id
);
expect(removeCommunityRes.community.removed).toBe(true);
// Make sure it got removed on A
let search = await searchForBetaCommunity(alpha);
let communityA = search.communities[0];
// TODO this fails currently, because no updates are pushed
// expect(communityA.removed).toBe(true);
await delay();
// unremove
let unremoveCommunityRes = await removeCommunity(
beta,
false,
communityRes.community.id
);
expect(unremoveCommunityRes.community.removed).toBe(false);
await delay();
// Make sure it got unremoved on A
let search2 = await searchForBetaCommunity(alpha);
let communityA2 = search2.communities[0];
// TODO this fails currently, because no updates are pushed
// expect(communityA2.removed).toBe(false);
});
test('Search for beta community', async () => {
let search = await searchForBetaCommunity(alpha);
expect(search.communities[0].name).toBe('main');
});

@ -1,43 +0,0 @@
import {
alpha,
setupLogins,
searchForBetaCommunity,
followCommunity,
checkFollowedCommunities,
unfollowRemotes,
delay,
} from './shared';
beforeAll(async () => {
await setupLogins();
});
afterAll(async () => {
await unfollowRemotes(alpha);
});
test('Follow federated community', async () => {
let search = await searchForBetaCommunity(alpha); // TODO sometimes this is returning null?
let follow = await followCommunity(alpha, true, search.communities[0].id);
// Make sure the follow response went through
expect(follow.community.local).toBe(false);
expect(follow.community.name).toBe('main');
await delay();
// Check it from local
let followCheck = await checkFollowedCommunities(alpha);
let remoteCommunityId = followCheck.communities.filter(
c => c.community_local == false
)[0].community_id;
expect(remoteCommunityId).toBeDefined();
// Test an unfollow
let unfollow = await followCommunity(alpha, false, remoteCommunityId);
expect(unfollow.community.local).toBe(false);
await delay();
// Make sure you are unsubbed locally
let unfollowCheck = await checkFollowedCommunities(alpha);
expect(unfollowCheck.communities.length).toBeGreaterThanOrEqual(1);
});

@ -1,305 +0,0 @@
jest.setTimeout(120000);
import {
alpha,
beta,
gamma,
delta,
epsilon,
setupLogins,
createPost,
updatePost,
stickyPost,
lockPost,
searchPost,
likePost,
followBeta,
searchForBetaCommunity,
createComment,
deletePost,
removePost,
getPost,
unfollowRemotes,
delay,
} from './shared';
beforeAll(async () => {
await setupLogins();
await followBeta(alpha);
await followBeta(gamma);
await followBeta(delta);
await followBeta(epsilon);
await delay(10000);
});
afterAll(async () => {
await unfollowRemotes(alpha);
await unfollowRemotes(gamma);
await unfollowRemotes(delta);
await unfollowRemotes(epsilon);
});
test('Create a post', async () => {
let search = await searchForBetaCommunity(alpha);
await delay();
let postRes = await createPost(alpha, search.communities[0].id);
expect(postRes.post).toBeDefined();
expect(postRes.post.community_local).toBe(false);
expect(postRes.post.creator_local).toBe(true);
expect(postRes.post.score).toBe(1);
await delay();
// Make sure that post is liked on beta
let searchBeta = await searchPost(beta, postRes.post);
let betaPost = searchBeta.posts[0];
expect(betaPost).toBeDefined();
expect(betaPost.community_local).toBe(true);
expect(betaPost.creator_local).toBe(false);
expect(betaPost.score).toBe(1);
// Delta only follows beta, so it should not see an alpha ap_id
let searchDelta = await searchPost(delta, postRes.post);
expect(searchDelta.posts[0]).toBeUndefined();
// Epsilon has alpha blocked, it should not see the alpha post
let searchEpsilon = await searchPost(epsilon, postRes.post);
expect(searchEpsilon.posts[0]).toBeUndefined();
});
test('Create a post in a non-existent community', async () => {
let postRes = await createPost(alpha, -2);
expect(postRes).toStrictEqual({ error: 'couldnt_create_post' });
});
test('Unlike a post', async () => {
let search = await searchForBetaCommunity(alpha);
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
let unlike = await likePost(alpha, 0, postRes.post);
expect(unlike.post.score).toBe(0);
await delay();
// Try to unlike it again, make sure it stays at 0
let unlike2 = await likePost(alpha, 0, postRes.post);
expect(unlike2.post.score).toBe(0);
await delay();
// Make sure that post is unliked on beta
let searchBeta = await searchPost(beta, postRes.post);
let betaPost = searchBeta.posts[0];
expect(betaPost).toBeDefined();
expect(betaPost.community_local).toBe(true);
expect(betaPost.creator_local).toBe(false);
expect(betaPost.score).toBe(0);
});
test('Update a post', async () => {
let search = await searchForBetaCommunity(alpha);
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
let updatedName = 'A jest test federated post, updated';
let updatedPost = await updatePost(alpha, postRes.post);
expect(updatedPost.post.name).toBe(updatedName);
expect(updatedPost.post.community_local).toBe(false);
expect(updatedPost.post.creator_local).toBe(true);
await delay();
// Make sure that post is updated on beta
let searchBeta = await searchPost(beta, postRes.post);
let betaPost = searchBeta.posts[0];
expect(betaPost.community_local).toBe(true);
expect(betaPost.creator_local).toBe(false);
expect(betaPost.name).toBe(updatedName);
await delay();
// Make sure lemmy beta cannot update the post
let updatedPostBeta = await updatePost(beta, betaPost);
expect(updatedPostBeta).toStrictEqual({ error: 'no_post_edit_allowed' });
});
test('Sticky a post', async () => {
let search = await searchForBetaCommunity(alpha);
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
let stickiedPostRes = await stickyPost(alpha, true, postRes.post);
expect(stickiedPostRes.post.stickied).toBe(true);
await delay();
// Make sure that post is stickied on beta
let searchBeta = await searchPost(beta, postRes.post);
let betaPost = searchBeta.posts[0];
expect(betaPost.community_local).toBe(true);
expect(betaPost.creator_local).toBe(false);
expect(betaPost.stickied).toBe(true);
// Unsticky a post
let unstickiedPost = await stickyPost(alpha, false, postRes.post);
expect(unstickiedPost.post.stickied).toBe(false);
await delay();
// Make sure that post is unstickied on beta
let searchBeta2 = await searchPost(beta, postRes.post);
let betaPost2 = searchBeta2.posts[0];
expect(betaPost2.community_local).toBe(true);
expect(betaPost2.creator_local).toBe(false);
expect(betaPost2.stickied).toBe(false);
// Make sure that gamma cannot sticky the post on beta
let searchGamma = await searchPost(gamma, postRes.post);
let gammaPost = searchGamma.posts[0];
let gammaTrySticky = await stickyPost(gamma, true, gammaPost);
await delay();
let searchBeta3 = await searchPost(beta, postRes.post);
let betaPost3 = searchBeta3.posts[0];
expect(gammaTrySticky.post.stickied).toBe(true);
expect(betaPost3.stickied).toBe(false);
});
test('Lock a post', async () => {
let search = await searchForBetaCommunity(alpha);
await delay();
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
let lockedPostRes = await lockPost(alpha, true, postRes.post);
expect(lockedPostRes.post.locked).toBe(true);
await delay();
// Make sure that post is locked on beta
let searchBeta = await searchPost(beta, postRes.post);
let betaPost = searchBeta.posts[0];
expect(betaPost.community_local).toBe(true);
expect(betaPost.creator_local).toBe(false);
expect(betaPost.locked).toBe(true);
// Try to make a new comment there, on alpha
let comment = await createComment(alpha, postRes.post.id);
expect(comment['error']).toBe('locked');
await delay();
// Try to create a new comment, on beta
let commentBeta = await createComment(beta, betaPost.id);
expect(commentBeta['error']).toBe('locked');
await delay();
// Unlock a post
let unlockedPost = await lockPost(alpha, false, postRes.post);
expect(unlockedPost.post.locked).toBe(false);
await delay();
// Make sure that post is unlocked on beta
let searchBeta2 = await searchPost(beta, postRes.post);
let betaPost2 = searchBeta2.posts[0];
expect(betaPost2.community_local).toBe(true);
expect(betaPost2.creator_local).toBe(false);
expect(betaPost2.locked).toBe(false);
});
test('Delete a post', async () => {
let search = await searchForBetaCommunity(alpha);
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
let deletedPost = await deletePost(alpha, true, postRes.post);
expect(deletedPost.post.deleted).toBe(true);
await delay();
// Make sure lemmy beta sees post is deleted
let searchBeta = await searchPost(beta, postRes.post);
let betaPost = searchBeta.posts[0];
// This will be undefined because of the tombstone
expect(betaPost).toBeUndefined();
await delay();
// Undelete
let undeletedPost = await deletePost(alpha, false, postRes.post);
expect(undeletedPost.post.deleted).toBe(false);
await delay();
// Make sure lemmy beta sees post is undeleted
let searchBeta2 = await searchPost(beta, postRes.post);
let betaPost2 = searchBeta2.posts[0];
expect(betaPost2.deleted).toBe(false);
// Make sure lemmy beta cannot delete the post
let deletedPostBeta = await deletePost(beta, true, betaPost2);
expect(deletedPostBeta).toStrictEqual({ error: 'no_post_edit_allowed' });
});
test('Remove a post from admin and community on different instance', async () => {
let search = await searchForBetaCommunity(alpha);
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
let removedPost = await removePost(alpha, true, postRes.post);
expect(removedPost.post.removed).toBe(true);
await delay();
// Make sure lemmy beta sees post is NOT removed
let searchBeta = await searchPost(beta, postRes.post);
let betaPost = searchBeta.posts[0];
expect(betaPost.removed).toBe(false);
await delay();
// Undelete
let undeletedPost = await removePost(alpha, false, postRes.post);
expect(undeletedPost.post.removed).toBe(false);
await delay();
// Make sure lemmy beta sees post is undeleted
let searchBeta2 = await searchPost(beta, postRes.post);
let betaPost2 = searchBeta2.posts[0];
expect(betaPost2.removed).toBe(false);
});
test('Remove a post from admin and community on same instance', async () => {
let search = await searchForBetaCommunity(alpha);
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
// Get the id for beta
let searchBeta = await searchPost(beta, postRes.post);
let betaPost = searchBeta.posts[0];
await delay();
// The beta admin removes it (the community lives on beta)
let removePostRes = await removePost(beta, true, betaPost);
expect(removePostRes.post.removed).toBe(true);
await delay();
// Make sure lemmy alpha sees post is removed
let alphaPost = await getPost(alpha, postRes.post.id);
expect(alphaPost.post.removed).toBe(true);
await delay();
// Undelete
let undeletedPost = await removePost(beta, false, betaPost);
expect(undeletedPost.post.removed).toBe(false);
await delay();
// Make sure lemmy alpha sees post is undeleted
let alphaPost2 = await getPost(alpha, postRes.post.id);
expect(alphaPost2.post.removed).toBe(false);
});
test('Search for a post', async () => {
let search = await searchForBetaCommunity(alpha);
await delay();
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
let searchBeta = await searchPost(beta, postRes.post);
expect(searchBeta.posts[0].name).toBeDefined();
});
test('A and G subscribe to B (center) A posts, it gets announced to G', async () => {
let search = await searchForBetaCommunity(alpha);
let postRes = await createPost(alpha, search.communities[0].id);
await delay();
let search2 = await searchPost(gamma, postRes.post);
expect(search2.posts[0].name).toBeDefined();
});

@ -1,80 +0,0 @@
import {
alpha,
beta,
setupLogins,
followBeta,
createPrivateMessage,
updatePrivateMessage,
listPrivateMessages,
deletePrivateMessage,
unfollowRemotes,
delay,
} from './shared';
let recipient_id: number;
beforeAll(async () => {
await setupLogins();
let follow = await followBeta(alpha);
await delay(10000);
recipient_id = follow.community.creator_id;
});
afterAll(async () => {
await unfollowRemotes(alpha);
});
test('Create a private message', async () => {
let pmRes = await createPrivateMessage(alpha, recipient_id);
expect(pmRes.message.content).toBeDefined();
expect(pmRes.message.local).toBe(true);
expect(pmRes.message.creator_local).toBe(true);
expect(pmRes.message.recipient_local).toBe(false);
await delay();
let betaPms = await listPrivateMessages(beta);
expect(betaPms.messages[0].content).toBeDefined();
expect(betaPms.messages[0].local).toBe(false);
expect(betaPms.messages[0].creator_local).toBe(false);
expect(betaPms.messages[0].recipient_local).toBe(true);
});
test('Update a private message', async () => {
let updatedContent = 'A jest test federated private message edited';
let pmRes = await createPrivateMessage(alpha, recipient_id);
let pmUpdated = await updatePrivateMessage(alpha, pmRes.message.id);
expect(pmUpdated.message.content).toBe(updatedContent);
await delay();
let betaPms = await listPrivateMessages(beta);
expect(betaPms.messages[0].content).toBe(updatedContent);
});
test('Delete a private message', async () => {
let pmRes = await createPrivateMessage(alpha, recipient_id);
await delay();
let betaPms1 = await listPrivateMessages(beta);
let deletedPmRes = await deletePrivateMessage(alpha, true, pmRes.message.id);
expect(deletedPmRes.message.deleted).toBe(true);
await delay();
// The GetPrivateMessages filters out deleted,
// even though they are in the actual database.
// no reason to show them
let betaPms2 = await listPrivateMessages(beta);
expect(betaPms2.messages.length).toBe(betaPms1.messages.length - 1);
await delay();
// Undelete
let undeletedPmRes = await deletePrivateMessage(
alpha,
false,
pmRes.message.id
);
expect(undeletedPmRes.message.deleted).toBe(false);
await delay();
let betaPms3 = await listPrivateMessages(beta);
expect(betaPms3.messages.length).toBe(betaPms1.messages.length);
});

@ -1,544 +0,0 @@
import {
LoginForm,
LoginResponse,
Post,
PostForm,
Comment,
DeletePostForm,
RemovePostForm,
StickyPostForm,
LockPostForm,
PostResponse,
SearchResponse,
FollowCommunityForm,
CommunityResponse,
GetFollowedCommunitiesResponse,
GetPostResponse,
RegisterForm,
CommentForm,
DeleteCommentForm,
RemoveCommentForm,
SearchForm,
CommentResponse,
CommunityForm,
DeleteCommunityForm,
RemoveCommunityForm,
GetUserMentionsForm,
CommentLikeForm,
CreatePostLikeForm,
PrivateMessageForm,
EditPrivateMessageForm,
DeletePrivateMessageForm,
GetFollowedCommunitiesForm,
GetPrivateMessagesForm,
GetSiteForm,
GetPostForm,
PrivateMessageResponse,
PrivateMessagesResponse,
GetUserMentionsResponse,
UserSettingsForm,
SortType,
ListingType,
GetSiteResponse,
SearchType,
LemmyHttp,
} from 'lemmy-js-client';
export interface API {
client: LemmyHttp;
auth?: string;
}
export let alpha: API = {
client: new LemmyHttp('http://localhost:8540/api/v1'),
};
export let beta: API = {
client: new LemmyHttp('http://localhost:8550/api/v1'),
};
export let gamma: API = {
client: new LemmyHttp('http://localhost:8560/api/v1'),
};
export let delta: API = {
client: new LemmyHttp('http://localhost:8570/api/v1'),
};
export let epsilon: API = {
client: new LemmyHttp('http://localhost:8580/api/v1'),
};
export async function setupLogins() {
let formAlpha: LoginForm = {
username_or_email: 'lemmy_alpha',
password: 'lemmy',
};
let resAlpha = alpha.client.login(formAlpha);
let formBeta = {
username_or_email: 'lemmy_beta',
password: 'lemmy',
};
let resBeta = beta.client.login(formBeta);
let formGamma = {
username_or_email: 'lemmy_gamma',
password: 'lemmy',
};
let resGamma = gamma.client.login(formGamma);
let formDelta = {
username_or_email: 'lemmy_delta',
password: 'lemmy',
};
let resDelta = delta.client.login(formDelta);
let formEpsilon = {
username_or_email: 'lemmy_epsilon',
password: 'lemmy',
};
let resEpsilon = epsilon.client.login(formEpsilon);
let res = await Promise.all([
resAlpha,
resBeta,
resGamma,
resDelta,
resEpsilon,
]);
alpha.auth = res[0].jwt;
beta.auth = res[1].jwt;
gamma.auth = res[2].jwt;
delta.auth = res[3].jwt;
epsilon.auth = res[4].jwt;
}
export async function createPost(
api: API,
community_id: number
): Promise<PostResponse> {
let name = 'A jest test post';
let form: PostForm = {
name,
auth: api.auth,
community_id,
nsfw: false,
};
return api.client.createPost(form);
}
export async function updatePost(api: API, post: Post): Promise<PostResponse> {
let name = 'A jest test federated post, updated';
let form: PostForm = {
name,
edit_id: post.id,
auth: api.auth,
nsfw: false,
};
return api.client.editPost(form);
}
export async function deletePost(
api: API,
deleted: boolean,
post: Post
): Promise<PostResponse> {
let form: DeletePostForm = {
edit_id: post.id,
deleted: deleted,
auth: api.auth,
};
return api.client.deletePost(form);
}
export async function removePost(
api: API,
removed: boolean,
post: Post
): Promise<PostResponse> {
let form: RemovePostForm = {
edit_id: post.id,
removed,
auth: api.auth,
};
return api.client.removePost(form);
}
export async function stickyPost(
api: API,
stickied: boolean,
post: Post
): Promise<PostResponse> {
let form: StickyPostForm = {
edit_id: post.id,
stickied,
auth: api.auth,
};
return api.client.stickyPost(form);
}
export async function lockPost(
api: API,
locked: boolean,
post: Post
): Promise<PostResponse> {
let form: LockPostForm = {
edit_id: post.id,
locked,
auth: api.auth,
};
return api.client.lockPost(form);
}
export async function searchPost(
api: API,
post: Post
): Promise<SearchResponse> {
let form: SearchForm = {
q: post.ap_id,
type_: SearchType.Posts,
sort: SortType.TopAll,
};
return api.client.search(form);
}
export async function getPost(
api: API,
post_id: number
): Promise<GetPostResponse> {
let form: GetPostForm = {
id: post_id,
};
return api.client.getPost(form);
}
export async function searchComment(
api: API,
comment: Comment
): Promise<SearchResponse> {
let form: SearchForm = {
q: comment.ap_id,
type_: SearchType.Comments,
sort: SortType.TopAll,
};
return api.client.search(form);
}
export async function searchForBetaCommunity(
api: API
): Promise<SearchResponse> {
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
// Use short-hand search url
let form: SearchForm = {
q: '!main@lemmy-beta:8550',
type_: SearchType.Communities,
sort: SortType.TopAll,
};
return api.client.search(form);
}
export async function searchForUser(
api: API,
apShortname: string
): Promise<SearchResponse> {
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
// Use short-hand search url
let form: SearchForm = {
q: apShortname,
type_: SearchType.Users,
sort: SortType.TopAll,
};
return api.client.search(form);
}
export async function followCommunity(
api: API,
follow: boolean,
community_id: number
): Promise<CommunityResponse> {
let form: FollowCommunityForm = {
community_id,
follow,
auth: api.auth,
};
return api.client.followCommunity(form);
}
export async function checkFollowedCommunities(
api: API
): Promise<GetFollowedCommunitiesResponse> {
let form: GetFollowedCommunitiesForm = {
auth: api.auth,
};
return api.client.getFollowedCommunities(form);
}
export async function likePost(
api: API,
score: number,
post: Post
): Promise<PostResponse> {
let form: CreatePostLikeForm = {
post_id: post.id,
score: score,
auth: api.auth,
};
return api.client.likePost(form);
}
export async function createComment(
api: API,
post_id: number,
parent_id?: number,
content = 'a jest test comment'
): Promise<CommentResponse> {
let form: CommentForm = {
content,
post_id,
parent_id,
auth: api.auth,
};
return api.client.createComment(form);
}
export async function updateComment(
api: API,
edit_id: number,
content = 'A jest test federated comment update'
): Promise<CommentResponse> {
let form: CommentForm = {
content,
edit_id,
auth: api.auth,
};
return api.client.editComment(form);
}
export async function deleteComment(
api: API,
deleted: boolean,
edit_id: number
): Promise<CommentResponse> {
let form: DeleteCommentForm = {
edit_id,
deleted,
auth: api.auth,
};
return api.client.deleteComment(form);
}
export async function removeComment(
api: API,
removed: boolean,
edit_id: number
): Promise<CommentResponse> {
let form: RemoveCommentForm = {
edit_id,
removed,
auth: api.auth,
};
return api.client.removeComment(form);
}
export async function getMentions(api: API): Promise<GetUserMentionsResponse> {
let form: GetUserMentionsForm = {
sort: SortType.New,
unread_only: false,
auth: api.auth,
};
return api.client.getUserMentions(form);
}
export async function likeComment(
api: API,
score: number,
comment: Comment
): Promise<CommentResponse> {
let form: CommentLikeForm = {
comment_id: comment.id,
score,
auth: api.auth,
};
return api.client.likeComment(form);
}
export async function createCommunity(
api: API,
name_: string = randomString(5)
): Promise<CommunityResponse> {
let form: CommunityForm = {
name: name_,
title: name_,
category_id: 1,
nsfw: false,
auth: api.auth,
};
return api.client.createCommunity(form);
}
export async function deleteCommunity(
api: API,
deleted: boolean,
edit_id: number
): Promise<CommunityResponse> {
let form: DeleteCommunityForm = {
edit_id,
deleted,
auth: api.auth,
};
return api.client.deleteCommunity(form);
}
export async function removeCommunity(
api: API,
removed: boolean,
edit_id: number
): Promise<CommunityResponse> {
let form: RemoveCommunityForm = {
edit_id,
removed,
auth: api.auth,
};
return api.client.removeCommunity(form);
}
export async function createPrivateMessage(
api: API,
recipient_id: number
): Promise<PrivateMessageResponse> {
let content = 'A jest test federated private message';
let form: PrivateMessageForm = {
content,
recipient_id,
auth: api.auth,
};
return api.client.createPrivateMessage(form);
}
export async function updatePrivateMessage(
api: API,
edit_id: number
): Promise<PrivateMessageResponse> {
let updatedContent = 'A jest test federated private message edited';
let form: EditPrivateMessageForm = {
content: updatedContent,
edit_id,
auth: api.auth,
};
return api.client.editPrivateMessage(form);
}
export async function deletePrivateMessage(
api: API,
deleted: boolean,
edit_id: number
): Promise<PrivateMessageResponse> {
let form: DeletePrivateMessageForm = {
deleted,
edit_id,
auth: api.auth,
};
return api.client.deletePrivateMessage(form);
}
export async function registerUser(
api: API,
username: string = randomString(5)
): Promise<LoginResponse> {
let form: RegisterForm = {
username,
password: 'test',
password_verify: 'test',
admin: false,
show_nsfw: true,
};
return api.client.register(form);
}
export async function saveUserSettingsBio(
api: API,
auth: string
): Promise<LoginResponse> {
let form: UserSettingsForm = {
show_nsfw: true,
theme: 'darkly',
default_sort_type: Object.keys(SortType).indexOf(SortType.Active),
default_listing_type: Object.keys(ListingType).indexOf(ListingType.All),
lang: 'en',
show_avatars: true,
send_notifications_to_email: false,
bio: 'a changed bio',
auth,
};
return api.client.saveUserSettings(form);
}
export async function getSite(
api: API,
auth: string
): Promise<GetSiteResponse> {
let form: GetSiteForm = {
auth,
};
return api.client.getSite(form);
}
export async function listPrivateMessages(
api: API
): Promise<PrivateMessagesResponse> {
let form: GetPrivateMessagesForm = {
auth: api.auth,
unread_only: false,
limit: 999,
};
return api.client.getPrivateMessages(form);
}
export async function unfollowRemotes(
api: API
): Promise<GetFollowedCommunitiesResponse> {
// Unfollow all remote communities
let followed = await checkFollowedCommunities(api);
let remoteFollowed = followed.communities.filter(
c => c.community_local == false
);
for (let cu of remoteFollowed) {
await followCommunity(api, false, cu.community_id);
}
let followed2 = await checkFollowedCommunities(api);
return followed2;
}
export async function followBeta(api: API): Promise<CommunityResponse> {
await unfollowRemotes(api);
// Cache it
let search = await searchForBetaCommunity(api);
let com = search.communities.filter(c => c.local == false);
if (com[0]) {
let follow = await followCommunity(api, true, com[0].id);
return follow;
}
}
export const delay = (millis: number = 1500) =>
new Promise((resolve, _reject) => {
setTimeout(_ => resolve(), millis);
});
export function wrapper(form: any): string {
return JSON.stringify(form);
}
function randomString(length: number): string {
var result = '';
var characters = 'abcdefghijklmnopqrstuvwxyz0123456789_';
var charactersLength = characters.length;
for (var i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}

@ -1,34 +0,0 @@
import {
alpha,
beta,
registerUser,
searchForUser,
saveUserSettingsBio,
getSite,
} from './shared';
let auth: string;
let apShortname: string;
test('Create user', async () => {
let userRes = await registerUser(alpha);
expect(userRes.jwt).toBeDefined();
auth = userRes.jwt;
let site = await getSite(alpha, auth);
expect(site.my_user).toBeDefined();
apShortname = `@${site.my_user.name}@lemmy-alpha:8540`;
});
test('Save user settings, check changed bio from beta', async () => {
let bio = 'a changed bio';
let userRes = await saveUserSettingsBio(alpha, auth);
expect(userRes.jwt).toBeDefined();
let site = await getSite(alpha, auth);
expect(site.my_user.bio).toBe(bio);
// Make sure beta sees this bio is changed
let search = await searchForUser(beta, apShortname);
expect(search.users[0].bio).toBe(bio);
});

@ -1,260 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
SiteResponse,
GetSiteResponse,
SiteConfigForm,
GetSiteConfigResponse,
WebSocketJsonResponse,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { wsJsonToRes, capitalizeFirstLetter, toast, randomStr } from '../utils';
import autosize from 'autosize';
import { SiteForm } from './site-form';
import { UserListing } from './user-listing';
import { i18n } from '../i18next';
interface AdminSettingsState {
siteRes: GetSiteResponse;
siteConfigRes: GetSiteConfigResponse;
siteConfigForm: SiteConfigForm;
loading: boolean;
siteConfigLoading: boolean;
}
export class AdminSettings extends Component<any, AdminSettingsState> {
private siteConfigTextAreaId = `site-config-${randomStr()}`;
private subscription: Subscription;
private emptyState: AdminSettingsState = {
siteRes: {
site: {
id: null,
name: null,
creator_id: null,
creator_name: null,
published: null,
number_of_users: null,
number_of_posts: null,
number_of_comments: null,
number_of_communities: null,
enable_downvotes: null,
open_registration: null,
enable_nsfw: null,
},
admins: [],
banned: [],
online: null,
version: null,
federated_instances: null,
},
siteConfigForm: {
config_hjson: null,
auth: null,
},
siteConfigRes: {
config_hjson: null,
},
loading: true,
siteConfigLoading: null,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
WebSocketService.Instance.getSiteConfig();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.siteRes.site.name) {
return `${i18n.t('admin_settings')} - ${this.state.siteRes.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div class="row">
<div class="col-12 col-md-6">
{this.state.siteRes.site.id && (
<SiteForm site={this.state.siteRes.site} />
)}
{this.admins()}
{this.bannedUsers()}
</div>
<div class="col-12 col-md-6">{this.adminSettings()}</div>
</div>
)}
</div>
);
}
admins() {
return (
<>
<h5>{capitalizeFirstLetter(i18n.t('admins'))}</h5>
<ul class="list-unstyled">
{this.state.siteRes.admins.map(admin => (
<li class="list-inline-item">
<UserListing
user={{
name: admin.name,
preferred_username: admin.preferred_username,
avatar: admin.avatar,
id: admin.id,
local: admin.local,
actor_id: admin.actor_id,
}}
/>
</li>
))}
</ul>
</>
);
}
bannedUsers() {
return (
<>
<h5>{i18n.t('banned_users')}</h5>
<ul class="list-unstyled">
{this.state.siteRes.banned.map(banned => (
<li class="list-inline-item">
<UserListing
user={{
name: banned.name,
preferred_username: banned.preferred_username,
avatar: banned.avatar,
id: banned.id,
local: banned.local,
actor_id: banned.actor_id,
}}
/>
</li>
))}
</ul>
</>
);
}
adminSettings() {
return (
<div>
<h5>{i18n.t('admin_settings')}</h5>
<form onSubmit={linkEvent(this, this.handleSiteConfigSubmit)}>
<div class="form-group row">
<label
class="col-12 col-form-label"
htmlFor={this.siteConfigTextAreaId}
>
{i18n.t('site_config')}
</label>
<div class="col-12">
<textarea
id={this.siteConfigTextAreaId}
value={this.state.siteConfigForm.config_hjson}
onInput={linkEvent(this, this.handleSiteConfigHjsonChange)}
class="form-control text-monospace"
rows={3}
/>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<button type="submit" class="btn btn-secondary mr-2">
{this.state.siteConfigLoading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
capitalizeFirstLetter(i18n.t('save'))
)}
</button>
</div>
</div>
</form>
</div>
);
}
handleSiteConfigSubmit(i: AdminSettings, event: any) {
event.preventDefault();
i.state.siteConfigLoading = true;
WebSocketService.Instance.saveSiteConfig(i.state.siteConfigForm);
i.setState(i.state);
}
handleSiteConfigHjsonChange(i: AdminSettings, event: any) {
i.state.siteConfigForm.config_hjson = event.target.value;
i.setState(i.state);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.context.router.history.push('/');
this.state.loading = false;
this.setState(this.state);
return;
} else if (msg.reconnect) {
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
// This means it hasn't been set up yet
if (!data.site) {
this.context.router.history.push('/setup');
}
this.state.siteRes = data;
this.setState(this.state);
} else if (res.op == UserOperation.EditSite) {
let data = res.data as SiteResponse;
this.state.siteRes.site = data.site;
this.setState(this.state);
toast(i18n.t('site_saved'));
} else if (res.op == UserOperation.GetSiteConfig) {
let data = res.data as GetSiteConfigResponse;
this.state.siteConfigRes = data;
this.state.loading = false;
this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson;
this.setState(this.state);
var textarea: any = document.getElementById(this.siteConfigTextAreaId);
autosize(textarea);
} else if (res.op == UserOperation.SaveSiteConfig) {
let data = res.data as GetSiteConfigResponse;
this.state.siteConfigRes = data;
this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson;
this.state.siteConfigLoading = false;
toast(i18n.t('site_saved'));
this.setState(this.state);
}
}
}

@ -1,30 +0,0 @@
import { Component } from 'inferno';
interface BannerIconHeaderProps {
banner?: string;
icon?: string;
}
export class BannerIconHeader extends Component<BannerIconHeaderProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<div class="position-relative mb-2">
{this.props.banner && (
<img src={this.props.banner} class="banner img-fluid" />
)}
{this.props.icon && (
<img
src={this.props.icon}
className={`ml-2 mb-0 ${
this.props.banner ? 'avatar-pushup' : ''
} rounded-circle avatar-overlay`}
/>
)}
</div>
);
}
}

@ -1,25 +0,0 @@
import { Component } from 'inferno';
import { i18n } from '../i18next';
interface CakeDayProps {
creatorName: string;
}
export class CakeDay extends Component<CakeDayProps, any> {
render() {
return (
<div
className={`mx-2 d-inline-block unselectable pointer`}
data-tippy-content={this.cakeDayTippy()}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-cake"></use>
</svg>
</div>
);
}
cakeDayTippy(): string {
return i18n.t('cake_day_info', { creator_name: this.props.creatorName });
}
}

@ -1,154 +0,0 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
CommentNode as CommentNodeI,
CommentForm as CommentFormI,
WebSocketJsonResponse,
UserOperation,
CommentResponse,
} from 'lemmy-js-client';
import { capitalizeFirstLetter, wsJsonToRes } from '../utils';
import { WebSocketService, UserService } from '../services';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
import { MarkdownTextArea } from './markdown-textarea';
interface CommentFormProps {
postId?: number;
node?: CommentNodeI;
onReplyCancel?(): any;
edit?: boolean;
disabled?: boolean;
focus?: boolean;
}
interface CommentFormState {
commentForm: CommentFormI;
buttonTitle: string;
finished: boolean;
}
export class CommentForm extends Component<CommentFormProps, CommentFormState> {
private subscription: Subscription;
private emptyState: CommentFormState = {
commentForm: {
auth: null,
content: null,
post_id: this.props.node
? this.props.node.comment.post_id
: this.props.postId,
creator_id: UserService.Instance.user
? UserService.Instance.user.id
: null,
},
buttonTitle: !this.props.node
? capitalizeFirstLetter(i18n.t('post'))
: this.props.edit
? capitalizeFirstLetter(i18n.t('save'))
: capitalizeFirstLetter(i18n.t('reply')),
finished: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handleCommentSubmit = this.handleCommentSubmit.bind(this);
this.handleReplyCancel = this.handleReplyCancel.bind(this);
this.state = this.emptyState;
if (this.props.node) {
if (this.props.edit) {
this.state.commentForm.edit_id = this.props.node.comment.id;
this.state.commentForm.parent_id = this.props.node.comment.parent_id;
this.state.commentForm.content = this.props.node.comment.content;
this.state.commentForm.creator_id = this.props.node.comment.creator_id;
} else {
// A reply gets a new parent id
this.state.commentForm.parent_id = this.props.node.comment.id;
}
}
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
render() {
return (
<div class="mb-3">
{UserService.Instance.user ? (
<MarkdownTextArea
initialContent={this.state.commentForm.content}
buttonTitle={this.state.buttonTitle}
finished={this.state.finished}
replyType={!!this.props.node}
focus={this.props.focus}
disabled={this.props.disabled}
onSubmit={this.handleCommentSubmit}
onReplyCancel={this.handleReplyCancel}
/>
) : (
<div class="alert alert-light" role="alert">
<svg class="icon icon-inline mr-2">
<use xlinkHref="#icon-alert-triangle"></use>
</svg>
<T i18nKey="must_login" class="d-inline">
#
<Link class="alert-link" to="/login">
#
</Link>
</T>
</div>
)}
</div>
);
}
handleCommentSubmit(msg: { val: string; formId: string }) {
this.state.commentForm.content = msg.val;
this.state.commentForm.form_id = msg.formId;
if (this.props.edit) {
WebSocketService.Instance.editComment(this.state.commentForm);
} else {
WebSocketService.Instance.createComment(this.state.commentForm);
}
this.setState(this.state);
}
handleReplyCancel() {
this.props.onReplyCancel();
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
// Only do the showing and hiding if logged in
if (UserService.Instance.user) {
if (
res.op == UserOperation.CreateComment ||
res.op == UserOperation.EditComment
) {
let data = res.data as CommentResponse;
// This only finishes this form, if the randomly generated form_id matches the one received
if (this.state.commentForm.form_id == data.form_id) {
this.setState({ finished: true });
// Necessary because it broke tribute for some reaso
this.setState({ finished: false });
}
}
}
}
}

File diff suppressed because it is too large Load Diff

@ -1,74 +0,0 @@
import { Component } from 'inferno';
import { CommentSortType } from '../interfaces';
import {
CommentNode as CommentNodeI,
CommunityUser,
UserView,
SortType,
} from 'lemmy-js-client';
import { commentSort, commentSortSortType } from '../utils';
import { CommentNode } from './comment-node';
interface CommentNodesState {}
interface CommentNodesProps {
nodes: Array<CommentNodeI>;
moderators?: Array<CommunityUser>;
admins?: Array<UserView>;
postCreatorId?: number;
noBorder?: boolean;
noIndent?: boolean;
viewOnly?: boolean;
locked?: boolean;
markable?: boolean;
showContext?: boolean;
showCommunity?: boolean;
sort?: CommentSortType;
sortType?: SortType;
enableDownvotes: boolean;
}
export class CommentNodes extends Component<
CommentNodesProps,
CommentNodesState
> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<div className="comments">
{this.sorter().map(node => (
<CommentNode
key={node.comment.id}
node={node}
noBorder={this.props.noBorder}
noIndent={this.props.noIndent}
viewOnly={this.props.viewOnly}
locked={this.props.locked}
moderators={this.props.moderators}
admins={this.props.admins}
postCreatorId={this.props.postCreatorId}
markable={this.props.markable}
showContext={this.props.showContext}
showCommunity={this.props.showCommunity}
sort={this.props.sort}
sortType={this.props.sortType}
enableDownvotes={this.props.enableDownvotes}
/>
))}
</div>
);
}
sorter(): Array<CommentNodeI> {
if (this.props.sort !== undefined) {
commentSort(this.props.nodes, this.props.sort);
} else if (this.props.sortType !== undefined) {
commentSortSortType(this.props.nodes, this.props.sortType);
}
return this.props.nodes;
}
}

@ -1,258 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
Community,
ListCommunitiesResponse,
CommunityResponse,
FollowCommunityForm,
ListCommunitiesForm,
SortType,
WebSocketJsonResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { wsJsonToRes, toast, getPageFromProps } from '../utils';
import { CommunityLink } from './community-link';
import { i18n } from '../i18next';
declare const Sortable: any;
const communityLimit = 100;
interface CommunitiesState {
communities: Array<Community>;
page: number;
loading: boolean;
site: Site;
}
interface CommunitiesProps {
page: number;
}
export class Communities extends Component<any, CommunitiesState> {
private subscription: Subscription;
private emptyState: CommunitiesState = {
communities: [],
loading: true,
page: getPageFromProps(this.props),
site: undefined,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
this.refetch();
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
static getDerivedStateFromProps(props: any): CommunitiesProps {
return {
page: getPageFromProps(props),
};
}
componentDidUpdate(_: any, lastState: CommunitiesState) {
if (lastState.page !== this.state.page) {
this.setState({ loading: true });
this.refetch();
}
}
get documentTitle(): string {
if (this.state.site) {
return `${i18n.t('communities')} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
{this.state.loading ? (
<h5 class="">
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div>
<h5>{i18n.t('list_of_communities')}</h5>
<div class="table-responsive">
<table id="community_table" class="table table-sm table-hover">
<thead class="pointer">
<tr>
<th>{i18n.t('name')}</th>
<th>{i18n.t('category')}</th>
<th class="text-right">{i18n.t('subscribers')}</th>
<th class="text-right d-none d-lg-table-cell">
{i18n.t('posts')}
</th>
<th class="text-right d-none d-lg-table-cell">
{i18n.t('comments')}
</th>
<th></th>
</tr>
</thead>
<tbody>
{this.state.communities.map(community => (
<tr>
<td>
<CommunityLink community={community} />
</td>
<td>{community.category_name}</td>
<td class="text-right">
{community.number_of_subscribers}
</td>
<td class="text-right d-none d-lg-table-cell">
{community.number_of_posts}
</td>
<td class="text-right d-none d-lg-table-cell">
{community.number_of_comments}
</td>
<td class="text-right">
{community.subscribed ? (
<span
class="pointer btn-link"
onClick={linkEvent(
community.id,
this.handleUnsubscribe
)}
>
{i18n.t('unsubscribe')}
</span>
) : (
<span
class="pointer btn-link"
onClick={linkEvent(
community.id,
this.handleSubscribe
)}
>
{i18n.t('subscribe')}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{this.paginator()}
</div>
)}
</div>
);
}
paginator() {
return (
<div class="mt-2">
{this.state.page > 1 && (
<button
class="btn btn-secondary mr-1"
onClick={linkEvent(this, this.prevPage)}
>
{i18n.t('prev')}
</button>
)}
{this.state.communities.length > 0 && (
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.nextPage)}
>
{i18n.t('next')}
</button>
)}
</div>
);
}
updateUrl(paramUpdates: CommunitiesProps) {
const page = paramUpdates.page || this.state.page;
this.props.history.push(`/communities/page/${page}`);
}
nextPage(i: Communities) {
i.updateUrl({ page: i.state.page + 1 });
}
prevPage(i: Communities) {
i.updateUrl({ page: i.state.page - 1 });
}
handleUnsubscribe(communityId: number) {
let form: FollowCommunityForm = {
community_id: communityId,
follow: false,
};
WebSocketService.Instance.followCommunity(form);
}
handleSubscribe(communityId: number) {
let form: FollowCommunityForm = {
community_id: communityId,
follow: true,
};
WebSocketService.Instance.followCommunity(form);
}
refetch() {
let listCommunitiesForm: ListCommunitiesForm = {
sort: SortType.TopAll,
limit: communityLimit,
page: this.state.page,
};
WebSocketService.Instance.listCommunities(listCommunitiesForm);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op == UserOperation.ListCommunities) {
let data = res.data as ListCommunitiesResponse;
this.state.communities = data.communities;
this.state.communities.sort(
(a, b) => b.number_of_subscribers - a.number_of_subscribers
);
this.state.loading = false;
window.scrollTo(0, 0);
this.setState(this.state);
let table = document.querySelector('#community_table');
Sortable.initTable(table);
} else if (res.op == UserOperation.FollowCommunity) {
let data = res.data as CommunityResponse;
let found = this.state.communities.find(c => c.id == data.community.id);
found.subscribed = data.community.subscribed;
found.number_of_subscribers = data.community.number_of_subscribers;
this.setState(this.state);
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

@ -1,364 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Prompt } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
CommunityForm as CommunityFormI,
UserOperation,
Category,
ListCategoriesResponse,
CommunityResponse,
WebSocketJsonResponse,
Community,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { wsJsonToRes, capitalizeFirstLetter, toast, randomStr } from '../utils';
import { i18n } from '../i18next';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
interface CommunityFormProps {
community?: Community; // If a community is given, that means this is an edit
onCancel?(): any;
onCreate?(community: Community): any;
onEdit?(community: Community): any;
enableNsfw: boolean;
}
interface CommunityFormState {
communityForm: CommunityFormI;
categories: Array<Category>;
loading: boolean;
}
export class CommunityForm extends Component<
CommunityFormProps,
CommunityFormState
> {
private id = `community-form-${randomStr()}`;
private subscription: Subscription;
private emptyState: CommunityFormState = {
communityForm: {
name: null,
title: null,
category_id: null,
nsfw: false,
icon: null,
banner: null,
},
categories: [],
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleCommunityDescriptionChange = this.handleCommunityDescriptionChange.bind(
this
);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
if (this.props.community) {
this.state.communityForm = {
name: this.props.community.name,
title: this.props.community.title,
category_id: this.props.community.category_id,
description: this.props.community.description,
edit_id: this.props.community.id,
nsfw: this.props.community.nsfw,
icon: this.props.community.icon,
banner: this.props.community.banner,
auth: null,
};
}
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.listCategories();
}
componentDidUpdate() {
if (
!this.state.loading &&
(this.state.communityForm.name ||
this.state.communityForm.title ||
this.state.communityForm.description)
) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = undefined;
}
}
componentWillUnmount() {
this.subscription.unsubscribe();
window.onbeforeunload = null;
}
render() {
return (
<>
<Prompt
when={
!this.state.loading &&
(this.state.communityForm.name ||
this.state.communityForm.title ||
this.state.communityForm.description)
}
message={i18n.t('block_leaving')}
/>
<form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}>
{!this.props.community && (
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor="community-name">
{i18n.t('name')}
<span
class="pointer unselectable ml-2 text-muted"
data-tippy-content={i18n.t('name_explain')}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-help-circle"></use>
</svg>
</span>
</label>
<div class="col-12">
<input
type="text"
id="community-name"
class="form-control"
value={this.state.communityForm.name}
onInput={linkEvent(this, this.handleCommunityNameChange)}
required
minLength={3}
maxLength={20}
pattern="[a-z0-9_]+"
title={i18n.t('community_reqs')}
/>
</div>
</div>
)}
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor="community-title">
{i18n.t('display_name')}
<span
class="pointer unselectable ml-2 text-muted"
data-tippy-content={i18n.t('display_name_explain')}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-help-circle"></use>
</svg>
</span>
</label>
<div class="col-12">
<input
type="text"
id="community-title"
value={this.state.communityForm.title}
onInput={linkEvent(this, this.handleCommunityTitleChange)}
class="form-control"
required
minLength={3}
maxLength={100}
/>
</div>
</div>
<div class="form-group">
<label>{i18n.t('icon')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_icon')}
imageSrc={this.state.communityForm.icon}
onUpload={this.handleIconUpload}
onRemove={this.handleIconRemove}
rounded
/>
</div>
<div class="form-group">
<label>{i18n.t('banner')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_banner')}
imageSrc={this.state.communityForm.banner}
onUpload={this.handleBannerUpload}
onRemove={this.handleBannerRemove}
/>
</div>
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor={this.id}>
{i18n.t('sidebar')}
</label>
<div class="col-12">
<MarkdownTextArea
initialContent={this.state.communityForm.description}
onContentChange={this.handleCommunityDescriptionChange}
/>
</div>
</div>
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor="community-category">
{i18n.t('category')}
</label>
<div class="col-12">
<select
class="form-control"
id="community-category"
value={this.state.communityForm.category_id}
onInput={linkEvent(this, this.handleCommunityCategoryChange)}
>
{this.state.categories.map(category => (
<option value={category.id}>{category.name}</option>
))}
</select>
</div>
</div>
{this.props.enableNsfw && (
<div class="form-group row">
<div class="col-12">
<div class="form-check">
<input
class="form-check-input"
id="community-nsfw"
type="checkbox"
checked={this.state.communityForm.nsfw}
onChange={linkEvent(this, this.handleCommunityNsfwChange)}
/>
<label class="form-check-label" htmlFor="community-nsfw">
{i18n.t('nsfw')}
</label>
</div>
</div>
</div>
)}
<div class="form-group row">
<div class="col-12">
<button
type="submit"
class="btn btn-secondary mr-2"
disabled={this.state.loading}
>
{this.state.loading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : this.props.community ? (
capitalizeFirstLetter(i18n.t('save'))
) : (
capitalizeFirstLetter(i18n.t('create'))
)}
</button>
{this.props.community && (
<button
type="button"
class="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)}
>
{i18n.t('cancel')}
</button>
)}
</div>
</div>
</form>
</>
);
}
handleCreateCommunitySubmit(i: CommunityForm, event: any) {
event.preventDefault();
i.state.loading = true;
if (i.props.community) {
WebSocketService.Instance.editCommunity(i.state.communityForm);
} else {
WebSocketService.Instance.createCommunity(i.state.communityForm);
}
i.setState(i.state);
}
handleCommunityNameChange(i: CommunityForm, event: any) {
i.state.communityForm.name = event.target.value;
i.setState(i.state);
}
handleCommunityTitleChange(i: CommunityForm, event: any) {
i.state.communityForm.title = event.target.value;
i.setState(i.state);
}
handleCommunityDescriptionChange(val: string) {
this.state.communityForm.description = val;
this.setState(this.state);
}
handleCommunityCategoryChange(i: CommunityForm, event: any) {
i.state.communityForm.category_id = Number(event.target.value);
i.setState(i.state);
}
handleCommunityNsfwChange(i: CommunityForm, event: any) {
i.state.communityForm.nsfw = event.target.checked;
i.setState(i.state);
}
handleCancel(i: CommunityForm) {
i.props.onCancel();
}
handleIconUpload(url: string) {
this.state.communityForm.icon = url;
this.setState(this.state);
}
handleIconRemove() {
this.state.communityForm.icon = '';
this.setState(this.state);
}
handleBannerUpload(url: string) {
this.state.communityForm.banner = url;
this.setState(this.state);
}
handleBannerRemove() {
this.state.communityForm.banner = '';
this.setState(this.state);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
console.log(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.state.loading = false;
this.setState(this.state);
return;
} else if (res.op == UserOperation.ListCategories) {
let data = res.data as ListCategoriesResponse;
this.state.categories = data.categories;
if (!this.props.community) {
this.state.communityForm.category_id = data.categories[0].id;
}
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommunity) {
let data = res.data as CommunityResponse;
this.state.loading = false;
this.props.onCreate(data.community);
} else if (res.op == UserOperation.EditCommunity) {
let data = res.data as CommunityResponse;
this.state.loading = false;
this.props.onEdit(data.community);
}
}
}

@ -1,60 +0,0 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { Community } from 'lemmy-js-client';
import { hostname, pictrsAvatarThumbnail, showAvatars } from '../utils';
interface CommunityOther {
name: string;
id?: number; // Necessary if its federated
icon?: string;
local?: boolean;
actor_id?: string;
}
interface CommunityLinkProps {
community: Community | CommunityOther;
realLink?: boolean;
useApubName?: boolean;
muted?: boolean;
hideAvatar?: boolean;
}
export class CommunityLink extends Component<CommunityLinkProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
let community = this.props.community;
let name_: string, link: string;
let local = community.local == null ? true : community.local;
if (local) {
name_ = community.name;
link = `/c/${community.name}`;
} else {
name_ = `${community.name}@${hostname(community.actor_id)}`;
link = !this.props.realLink
? `/community/${community.id}`
: community.actor_id;
}
let apubName = `!${name_}`;
let displayName = this.props.useApubName ? apubName : name_;
return (
<Link
title={apubName}
className={`${this.props.muted ? 'text-muted' : ''}`}
to={link}
>
{!this.props.hideAvatar && community.icon && showAvatars() && (
<img
style="width: 2rem; height: 2rem;"
src={pictrsAvatarThumbnail(community.icon)}
class="rounded-circle mr-2"
/>
)}
<span>{displayName}</span>
</Link>
);
}
}

@ -1,480 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import { DataType } from '../interfaces';
import {
UserOperation,
Community as CommunityI,
GetCommunityResponse,
CommunityResponse,
CommunityUser,
UserView,
SortType,
Post,
GetPostsForm,
GetCommunityForm,
ListingType,
GetPostsResponse,
PostResponse,
AddModToCommunityResponse,
BanFromCommunityResponse,
Comment,
GetCommentsForm,
GetCommentsResponse,
CommentResponse,
WebSocketJsonResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { PostListings } from './post-listings';
import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select';
import { DataTypeSelect } from './data-type-select';
import { Sidebar } from './sidebar';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import {
wsJsonToRes,
fetchLimit,
toast,
getPageFromProps,
getSortTypeFromProps,
getDataTypeFromProps,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
editPostFindRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
notifyPost,
} from '../utils';
import { i18n } from '../i18next';
interface State {
community: CommunityI;
communityId: number;
communityName: string;
moderators: Array<CommunityUser>;
admins: Array<UserView>;
online: number;
loading: boolean;
posts: Array<Post>;
comments: Array<Comment>;
dataType: DataType;
sort: SortType;
page: number;
site: Site;
}
interface CommunityProps {
dataType: DataType;
sort: SortType;
page: number;
}
interface UrlParams {
dataType?: string;
sort?: SortType;
page?: number;
}
export class Community extends Component<any, State> {
private subscription: Subscription;
private emptyState: State = {
community: {
id: null,
name: null,
title: null,
category_id: null,
category_name: null,
creator_id: null,
creator_name: null,
number_of_subscribers: null,
number_of_posts: null,
number_of_comments: null,
published: null,
removed: null,
nsfw: false,
deleted: null,
local: null,
actor_id: null,
last_refreshed_at: null,
creator_actor_id: null,
creator_local: null,
},
moderators: [],
admins: [],
communityId: Number(this.props.match.params.id),
communityName: this.props.match.params.name,
online: null,
loading: true,
posts: [],
comments: [],
dataType: getDataTypeFromProps(this.props),
sort: getSortTypeFromProps(this.props),
page: getPageFromProps(this.props),
site: {
id: undefined,
name: undefined,
creator_id: undefined,
published: undefined,
creator_name: undefined,
number_of_users: undefined,
number_of_posts: undefined,
number_of_comments: undefined,
number_of_communities: undefined,
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
icon: undefined,
banner: undefined,
creator_preferred_username: undefined,
},
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleSortChange = this.handleSortChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
let form: GetCommunityForm = {
id: this.state.communityId ? this.state.communityId : null,
name: this.state.communityName ? this.state.communityName : null,
};
WebSocketService.Instance.getCommunity(form);
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
static getDerivedStateFromProps(props: any): CommunityProps {
return {
dataType: getDataTypeFromProps(props),
sort: getSortTypeFromProps(props),
page: getPageFromProps(props),
};
}
componentDidUpdate(_: any, lastState: State) {
if (
lastState.dataType !== this.state.dataType ||
lastState.sort !== this.state.sort ||
lastState.page !== this.state.page
) {
this.setState({ loading: true });
this.fetchData();
}
}
get documentTitle(): string {
if (this.state.community.title) {
return `${this.state.community.title} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
get favIcon(): string {
return this.state.site.icon ? this.state.site.icon : favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div class="row">
<div class="col-12 col-md-8">
{this.communityInfo()}
{this.selects()}
{this.listings()}
{this.paginator()}
</div>
<div class="col-12 col-md-4">
<Sidebar
community={this.state.community}
moderators={this.state.moderators}
admins={this.state.admins}
online={this.state.online}
enableNsfw={this.state.site.enable_nsfw}
/>
</div>
</div>
)}
</div>
);
}
listings() {
return this.state.dataType == DataType.Post ? (
<PostListings
posts={this.state.posts}
removeDuplicates
sort={this.state.sort}
enableDownvotes={this.state.site.enable_downvotes}
enableNsfw={this.state.site.enable_nsfw}
/>
) : (
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
noIndent
sortType={this.state.sort}
showContext
enableDownvotes={this.state.site.enable_downvotes}
/>
);
}
communityInfo() {
return (
<div>
<BannerIconHeader
banner={this.state.community.banner}
icon={this.state.community.icon}
/>
<h5 class="mb-0">{this.state.community.title}</h5>
<CommunityLink
community={this.state.community}
realLink
useApubName
muted
hideAvatar
/>
<hr />
</div>
);
}
selects() {
return (
<div class="mb-3">
<span class="mr-3">
<DataTypeSelect
type_={this.state.dataType}
onChange={this.handleDataTypeChange}
/>
</span>
<span class="mr-2">
<SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
</span>
<a
href={`/feeds/c/${this.state.communityName}.xml?sort=${this.state.sort}`}
target="_blank"
title="RSS"
rel="noopener"
>
<svg class="icon text-muted small">
<use xlinkHref="#icon-rss">#</use>
</svg>
</a>
</div>
);
}
paginator() {
return (
<div class="my-2">
{this.state.page > 1 && (
<button
class="btn btn-secondary mr-1"
onClick={linkEvent(this, this.prevPage)}
>
{i18n.t('prev')}
</button>
)}
{this.state.posts.length > 0 && (
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.nextPage)}
>
{i18n.t('next')}
</button>
)}
</div>
);
}
nextPage(i: Community) {
i.updateUrl({ page: i.state.page + 1 });
window.scrollTo(0, 0);
}
prevPage(i: Community) {
i.updateUrl({ page: i.state.page - 1 });
window.scrollTo(0, 0);
}
handleSortChange(val: SortType) {
this.updateUrl({ sort: val, page: 1 });
window.scrollTo(0, 0);
}
handleDataTypeChange(val: DataType) {
this.updateUrl({ dataType: DataType[val], page: 1 });
window.scrollTo(0, 0);
}
updateUrl(paramUpdates: UrlParams) {
const dataTypeStr = paramUpdates.dataType || DataType[this.state.dataType];
const sortStr = paramUpdates.sort || this.state.sort;
const page = paramUpdates.page || this.state.page;
this.props.history.push(
`/c/${this.state.community.name}/data_type/${dataTypeStr}/sort/${sortStr}/page/${page}`
);
}
fetchData() {
if (this.state.dataType == DataType.Post) {
let getPostsForm: GetPostsForm = {
page: this.state.page,
limit: fetchLimit,
sort: this.state.sort,
type_: ListingType.Community,
community_id: this.state.community.id,
};
WebSocketService.Instance.getPosts(getPostsForm);
} else {
let getCommentsForm: GetCommentsForm = {
page: this.state.page,
limit: fetchLimit,
sort: this.state.sort,
type_: ListingType.Community,
community_id: this.state.community.id,
};
WebSocketService.Instance.getComments(getCommentsForm);
}
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.context.router.history.push('/');
return;
} else if (msg.reconnect) {
this.fetchData();
} else if (res.op == UserOperation.GetCommunity) {
let data = res.data as GetCommunityResponse;
this.state.community = data.community;
this.state.moderators = data.moderators;
this.state.online = data.online;
this.setState(this.state);
this.fetchData();
} else if (
res.op == UserOperation.EditCommunity ||
res.op == UserOperation.DeleteCommunity ||
res.op == UserOperation.RemoveCommunity
) {
let data = res.data as CommunityResponse;
this.state.community = data.community;
this.setState(this.state);
} else if (res.op == UserOperation.FollowCommunity) {
let data = res.data as CommunityResponse;
this.state.community.subscribed = data.community.subscribed;
this.state.community.number_of_subscribers =
data.community.number_of_subscribers;
this.setState(this.state);
} else if (res.op == UserOperation.GetPosts) {
let data = res.data as GetPostsResponse;
this.state.posts = data.posts;
this.state.loading = false;
this.setState(this.state);
setupTippy();
} else if (
res.op == UserOperation.EditPost ||
res.op == UserOperation.DeletePost ||
res.op == UserOperation.RemovePost ||
res.op == UserOperation.LockPost ||
res.op == UserOperation.StickyPost
) {
let data = res.data as PostResponse;
editPostFindRes(data, this.state.posts);
this.setState(this.state);
} else if (res.op == UserOperation.CreatePost) {
let data = res.data as PostResponse;
this.state.posts.unshift(data.post);
notifyPost(data.post, this.context.router);
this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
createPostLikeFindRes(data, this.state.posts);
this.setState(this.state);
} else if (res.op == UserOperation.AddModToCommunity) {
let data = res.data as AddModToCommunityResponse;
this.state.moderators = data.moderators;
this.setState(this.state);
} else if (res.op == UserOperation.BanFromCommunity) {
let data = res.data as BanFromCommunityResponse;
this.state.posts
.filter(p => p.creator_id == data.user.id)
.forEach(p => (p.banned = data.banned));
this.setState(this.state);
} else if (res.op == UserOperation.GetComments) {
let data = res.data as GetCommentsResponse;
this.state.comments = data.comments;
this.state.loading = false;
this.setState(this.state);
} else if (
res.op == UserOperation.EditComment ||
res.op == UserOperation.DeleteComment ||
res.op == UserOperation.RemoveComment
) {
let data = res.data as CommentResponse;
editCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
// Necessary since it might be a user reply
if (data.recipient_ids.length == 0) {
this.state.comments.unshift(data.comment);
this.setState(this.state);
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
saveCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.state.admins = data.admins;
this.setState(this.state);
}
}
}

@ -1,105 +0,0 @@
import { Component } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import { CommunityForm } from './community-form';
import {
Community,
UserOperation,
WebSocketJsonResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { toast, wsJsonToRes } from '../utils';
import { WebSocketService, UserService } from '../services';
import { i18n } from '../i18next';
interface CreateCommunityState {
site: Site;
}
export class CreateCommunity extends Component<any, CreateCommunityState> {
private subscription: Subscription;
private emptyState: CreateCommunityState = {
site: {
id: undefined,
name: undefined,
creator_id: undefined,
published: undefined,
creator_name: undefined,
number_of_users: undefined,
number_of_posts: undefined,
number_of_comments: undefined,
number_of_communities: undefined,
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
},
};
constructor(props: any, context: any) {
super(props, context);
this.handleCommunityCreate = this.handleCommunityCreate.bind(this);
this.state = this.emptyState;
if (!UserService.Instance.user) {
toast(i18n.t('not_logged_in'), 'danger');
this.context.router.history.push(`/login`);
}
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.site.name) {
return `${i18n.t('create_community')} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<div class="row">
<div class="col-12 col-lg-6 offset-lg-3 mb-4">
<h5>{i18n.t('create_community')}</h5>
<CommunityForm
onCreate={this.handleCommunityCreate}
enableNsfw={this.state.site.enable_nsfw}
/>
</div>
</div>
</div>
);
}
handleCommunityCreate(community: Community) {
this.props.history.push(`/c/${community.name}`);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
// Toast errors are already handled by community-form
return;
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

@ -1,132 +0,0 @@
import { Component } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import { PostForm } from './post-form';
import { toast, wsJsonToRes } from '../utils';
import { WebSocketService, UserService } from '../services';
import {
UserOperation,
PostFormParams,
WebSocketJsonResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { i18n } from '../i18next';
interface CreatePostState {
site: Site;
}
export class CreatePost extends Component<any, CreatePostState> {
private subscription: Subscription;
private emptyState: CreatePostState = {
site: {
id: undefined,
name: undefined,
creator_id: undefined,
published: undefined,
creator_name: undefined,
number_of_users: undefined,
number_of_posts: undefined,
number_of_comments: undefined,
number_of_communities: undefined,
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
},
};
constructor(props: any, context: any) {
super(props, context);
this.handlePostCreate = this.handlePostCreate.bind(this);
this.state = this.emptyState;
if (!UserService.Instance.user) {
toast(i18n.t('not_logged_in'), 'danger');
this.context.router.history.push(`/login`);
}
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.site.name) {
return `${i18n.t('create_post')} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<div class="row">
<div class="col-12 col-lg-6 offset-lg-3 mb-4">
<h5>{i18n.t('create_post')}</h5>
<PostForm
onCreate={this.handlePostCreate}
params={this.params}
enableDownvotes={this.state.site.enable_downvotes}
enableNsfw={this.state.site.enable_nsfw}
/>
</div>
</div>
</div>
);
}
get params(): PostFormParams {
let urlParams = new URLSearchParams(this.props.location.search);
let params: PostFormParams = {
name: urlParams.get('title'),
community: urlParams.get('community') || this.prevCommunityName,
body: urlParams.get('body'),
url: urlParams.get('url'),
};
return params;
}
get prevCommunityName(): string {
if (this.props.match.params.name) {
return this.props.match.params.name;
} else if (this.props.location.state) {
let lastLocation = this.props.location.state.prevPath;
if (lastLocation.includes('/c/')) {
return lastLocation.split('/c/')[1];
}
}
return;
}
handlePostCreate(id: number) {
this.props.history.push(`/post/${id}`);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

@ -1,109 +0,0 @@
import { Component } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import { PrivateMessageForm } from './private-message-form';
import { WebSocketService, UserService } from '../services';
import {
UserOperation,
WebSocketJsonResponse,
GetSiteResponse,
Site,
PrivateMessageFormParams,
} from 'lemmy-js-client';
import { toast, wsJsonToRes } from '../utils';
import { i18n } from '../i18next';
interface CreatePrivateMessageState {
site: Site;
}
export class CreatePrivateMessage extends Component<
any,
CreatePrivateMessageState
> {
private subscription: Subscription;
private emptyState: CreatePrivateMessageState = {
site: undefined,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handlePrivateMessageCreate = this.handlePrivateMessageCreate.bind(
this
);
if (!UserService.Instance.user) {
toast(i18n.t('not_logged_in'), 'danger');
this.context.router.history.push(`/login`);
}
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.site) {
return `${i18n.t('create_private_message')} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<div class="row">
<div class="col-12 col-lg-6 offset-lg-3 mb-4">
<h5>{i18n.t('create_private_message')}</h5>
<PrivateMessageForm
onCreate={this.handlePrivateMessageCreate}
params={this.params}
/>
</div>
</div>
</div>
);
}
get params(): PrivateMessageFormParams {
let urlParams = new URLSearchParams(this.props.location.search);
let params: PrivateMessageFormParams = {
recipient_id: Number(urlParams.get('recipient_id')),
};
return params;
}
handlePrivateMessageCreate() {
toast(i18n.t('message_sent'));
// Navigate to the front
this.props.history.push(`/`);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

@ -1,70 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { DataType } from '../interfaces';
import { i18n } from '../i18next';
interface DataTypeSelectProps {
type_: DataType;
onChange?(val: DataType): any;
}
interface DataTypeSelectState {
type_: DataType;
}
export class DataTypeSelect extends Component<
DataTypeSelectProps,
DataTypeSelectState
> {
private emptyState: DataTypeSelectState = {
type_: this.props.type_,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
static getDerivedStateFromProps(props: any): DataTypeSelectProps {
return {
type_: props.type_,
};
}
render() {
return (
<div class="btn-group btn-group-toggle flex-wrap mb-2">
<label
className={`pointer btn btn-outline-secondary
${this.state.type_ == DataType.Post && 'active'}
`}
>
<input
type="radio"
value={DataType.Post}
checked={this.state.type_ == DataType.Post}
onChange={linkEvent(this, this.handleTypeChange)}
/>
{i18n.t('posts')}
</label>
<label
className={`pointer btn btn-outline-secondary ${
this.state.type_ == DataType.Comment && 'active'
}`}
>
<input
type="radio"
value={DataType.Comment}
checked={this.state.type_ == DataType.Comment}
onChange={linkEvent(this, this.handleTypeChange)}
/>
{i18n.t('comments')}
</label>
</div>
);
}
handleTypeChange(i: DataTypeSelect, event: any) {
i.props.onChange(Number(event.target.value));
}
}

@ -1,87 +0,0 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { i18n } from '../i18next';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import { WebSocketService } from '../services';
import { repoUrl, wsJsonToRes } from '../utils';
import {
UserOperation,
WebSocketJsonResponse,
GetSiteResponse,
} from 'lemmy-js-client';
interface FooterState {
version: string;
}
export class Footer extends Component<any, FooterState> {
private wsSub: Subscription;
emptyState: FooterState = {
version: null,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.wsSub = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
}
componentWillUnmount() {
this.wsSub.unsubscribe();
}
render() {
return (
<nav class="container navbar navbar-expand-md navbar-light navbar-bg p-0 px-3 mt-2">
<div className="navbar-collapse">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<span class="navbar-text">{this.state.version}</span>
</li>
<li class="nav-item">
<Link class="nav-link" to="/modlog">
{i18n.t('modlog')}
</Link>
</li>
<li class="nav-item">
<Link class="nav-link" to="/instances">
{i18n.t('instances')}
</Link>
</li>
<li class="nav-item">
<a class="nav-link" href={'/docs/index.html'}>
{i18n.t('docs')}
</a>
</li>
<li class="nav-item">
<Link class="nav-link" to="/sponsors">
{i18n.t('donate')}
</Link>
</li>
<li class="nav-item">
<a class="nav-link" href={repoUrl}>
{i18n.t('code')}
</a>
</li>
</ul>
</div>
</nav>
);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.setState({ version: data.version });
}
}
}

@ -1,105 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Post } from 'lemmy-js-client';
import { mdToHtml } from '../utils';
import { i18n } from '../i18next';
interface FramelyCardProps {
post: Post;
}
interface FramelyCardState {
expanded: boolean;
}
export class IFramelyCard extends Component<
FramelyCardProps,
FramelyCardState
> {
private emptyState: FramelyCardState = {
expanded: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
render() {
let post = this.props.post;
return (
<>
{post.embed_title && !this.state.expanded && (
<div class="card bg-transparent border-secondary mt-3 mb-2">
<div class="row">
<div class="col-12">
<div class="card-body">
<h5 class="card-title d-inline">
{post.embed_html ? (
<span
class="unselectable pointer"
onClick={linkEvent(this, this.handleIframeExpand)}
data-tippy-content={i18n.t('expand_here')}
>
{post.embed_title}
</span>
) : (
<span>
<a
class="text-body"
target="_blank"
href={post.url}
rel="noopener"
>
{post.embed_title}
</a>
</span>
)}
</h5>
<span class="d-inline-block ml-2 mb-2 small text-muted">
<a
class="text-muted font-italic"
target="_blank"
href={post.url}
rel="noopener"
>
{new URL(post.url).hostname}
<svg class="ml-1 icon">
<use xlinkHref="#icon-external-link"></use>
</svg>
</a>
{post.embed_html && (
<span
class="ml-2 pointer text-monospace"
onClick={linkEvent(this, this.handleIframeExpand)}
data-tippy-content={i18n.t('expand_here')}
>
{this.state.expanded ? '[-]' : '[+]'}
</span>
)}
</span>
{post.embed_description && (
<div
className="card-text small text-muted md-div"
dangerouslySetInnerHTML={mdToHtml(post.embed_description)}
/>
)}
</div>
</div>
</div>
</div>
)}
{this.state.expanded && (
<div
class="mt-3 mb-2"
dangerouslySetInnerHTML={{ __html: post.embed_html }}
/>
)}
</>
);
}
handleIframeExpand(i: IFramelyCard) {
i.state.expanded = !i.state.expanded;
i.setState(i.state);
}
}

@ -1,114 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { UserService } from '../services';
import { toast, randomStr } from '../utils';
interface ImageUploadFormProps {
uploadTitle: string;
imageSrc: string;
onUpload(url: string): any;
onRemove(): any;
rounded?: boolean;
}
interface ImageUploadFormState {
loading: boolean;
}
export class ImageUploadForm extends Component<
ImageUploadFormProps,
ImageUploadFormState
> {
private id = `image-upload-form-${randomStr()}`;
private emptyState: ImageUploadFormState = {
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
render() {
return (
<form class="d-inline">
<label
htmlFor={this.id}
class="pointer ml-4 text-muted small font-weight-bold"
>
{!this.props.imageSrc ? (
<span class="btn btn-secondary">{this.props.uploadTitle}</span>
) : (
<span class="d-inline-block position-relative">
<img
src={this.props.imageSrc}
height={this.props.rounded ? 60 : ''}
width={this.props.rounded ? 60 : ''}
className={`img-fluid ${
this.props.rounded ? 'rounded-circle' : ''
}`}
/>
<a onClick={linkEvent(this, this.handleRemoveImage)}>
<svg class="icon mini-overlay">
<use xlinkHref="#icon-x"></use>
</svg>
</a>
</span>
)}
</label>
<input
id={this.id}
type="file"
accept="image/*,video/*"
name={this.id}
class="d-none"
disabled={!UserService.Instance.user}
onChange={linkEvent(this, this.handleImageUpload)}
/>
</form>
);
}
handleImageUpload(i: ImageUploadForm, event: any) {
event.preventDefault();
let file = event.target.files[0];
const imageUploadUrl = `/pictrs/image`;
const formData = new FormData();
formData.append('images[]', file);
i.state.loading = true;
i.setState(i.state);
fetch(imageUploadUrl, {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(res => {
console.log('pictrs upload:');
console.log(res);
if (res.msg == 'ok') {
let hash = res.files[0].file;
let url = `${window.location.origin}/pictrs/image/${hash}`;
i.state.loading = false;
i.setState(i.state);
i.props.onUpload(url);
} else {
i.state.loading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
}
})
.catch(error => {
i.state.loading = false;
i.setState(i.state);
toast(error, 'danger');
});
}
handleRemoveImage(i: ImageUploadForm, event: any) {
event.preventDefault();
i.state.loading = true;
i.setState(i.state);
i.props.onRemove();
}
}

@ -1,607 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
Comment,
SortType,
GetRepliesForm,
GetRepliesResponse,
GetUserMentionsForm,
GetUserMentionsResponse,
UserMentionResponse,
CommentResponse,
WebSocketJsonResponse,
PrivateMessage as PrivateMessageI,
GetPrivateMessagesForm,
PrivateMessagesResponse,
PrivateMessageResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { WebSocketService, UserService } from '../services';
import {
wsJsonToRes,
fetchLimit,
isCommentType,
toast,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
commentsToFlatNodes,
setupTippy,
} from '../utils';
import { CommentNodes } from './comment-nodes';
import { PrivateMessage } from './private-message';
import { SortSelect } from './sort-select';
import { i18n } from '../i18next';
enum UnreadOrAll {
Unread,
All,
}
enum MessageType {
All,
Replies,
Mentions,
Messages,
}
type ReplyType = Comment | PrivateMessageI;
interface InboxState {
unreadOrAll: UnreadOrAll;
messageType: MessageType;
replies: Array<Comment>;
mentions: Array<Comment>;
messages: Array<PrivateMessageI>;
sort: SortType;
page: number;
site: Site;
}
export class Inbox extends Component<any, InboxState> {
private subscription: Subscription;
private emptyState: InboxState = {
unreadOrAll: UnreadOrAll.Unread,
messageType: MessageType.All,
replies: [],
mentions: [],
messages: [],
sort: SortType.New,
page: 1,
site: {
id: undefined,
name: undefined,
creator_id: undefined,
published: undefined,
creator_name: undefined,
number_of_users: undefined,
number_of_posts: undefined,
number_of_comments: undefined,
number_of_communities: undefined,
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
},
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleSortChange = this.handleSortChange.bind(this);
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
this.refetch();
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.site.name) {
return `@${UserService.Instance.user.name} ${i18n.t('inbox')} - ${
this.state.site.name
}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<div class="row">
<div class="col-12">
<h5 class="mb-1">
{i18n.t('inbox')}
<small>
<a
href={`/feeds/inbox/${UserService.Instance.auth}.xml`}
target="_blank"
title="RSS"
rel="noopener"
>
<svg class="icon ml-2 text-muted small">
<use xlinkHref="#icon-rss">#</use>
</svg>
</a>
</small>
</h5>
{this.state.replies.length +
this.state.mentions.length +
this.state.messages.length >
0 &&
this.state.unreadOrAll == UnreadOrAll.Unread && (
<ul class="list-inline mb-1 text-muted small font-weight-bold">
<li className="list-inline-item">
<span
class="pointer"
onClick={linkEvent(this, this.markAllAsRead)}
>
{i18n.t('mark_all_as_read')}
</span>
</li>
</ul>
)}
{this.selects()}
{this.state.messageType == MessageType.All && this.all()}
{this.state.messageType == MessageType.Replies && this.replies()}
{this.state.messageType == MessageType.Mentions && this.mentions()}
{this.state.messageType == MessageType.Messages && this.messages()}
{this.paginator()}
</div>
</div>
</div>
);
}
unreadOrAllRadios() {
return (
<div class="btn-group btn-group-toggle flex-wrap mb-2">
<label
className={`btn btn-outline-secondary pointer
${this.state.unreadOrAll == UnreadOrAll.Unread && 'active'}
`}
>
<input
type="radio"
value={UnreadOrAll.Unread}
checked={this.state.unreadOrAll == UnreadOrAll.Unread}
onChange={linkEvent(this, this.handleUnreadOrAllChange)}
/>
{i18n.t('unread')}
</label>
<label
className={`btn btn-outline-secondary pointer
${this.state.unreadOrAll == UnreadOrAll.All && 'active'}
`}
>
<input
type="radio"
value={UnreadOrAll.All}
checked={this.state.unreadOrAll == UnreadOrAll.All}
onChange={linkEvent(this, this.handleUnreadOrAllChange)}
/>
{i18n.t('all')}
</label>
</div>
);
}
messageTypeRadios() {
return (
<div class="btn-group btn-group-toggle flex-wrap mb-2">
<label
className={`btn btn-outline-secondary pointer
${this.state.messageType == MessageType.All && 'active'}
`}
>
<input
type="radio"
value={MessageType.All}
checked={this.state.messageType == MessageType.All}
onChange={linkEvent(this, this.handleMessageTypeChange)}
/>
{i18n.t('all')}
</label>
<label
className={`btn btn-outline-secondary pointer
${this.state.messageType == MessageType.Replies && 'active'}
`}
>
<input
type="radio"
value={MessageType.Replies}
checked={this.state.messageType == MessageType.Replies}
onChange={linkEvent(this, this.handleMessageTypeChange)}
/>
{i18n.t('replies')}
</label>
<label
className={`btn btn-outline-secondary pointer
${this.state.messageType == MessageType.Mentions && 'active'}
`}
>
<input
type="radio"
value={MessageType.Mentions}
checked={this.state.messageType == MessageType.Mentions}
onChange={linkEvent(this, this.handleMessageTypeChange)}
/>
{i18n.t('mentions')}
</label>
<label
className={`btn btn-outline-secondary pointer
${this.state.messageType == MessageType.Messages && 'active'}
`}
>
<input
type="radio"
value={MessageType.Messages}
checked={this.state.messageType == MessageType.Messages}
onChange={linkEvent(this, this.handleMessageTypeChange)}
/>
{i18n.t('messages')}
</label>
</div>
);
}
selects() {
return (
<div className="mb-2">
<span class="mr-3">{this.unreadOrAllRadios()}</span>
<span class="mr-3">{this.messageTypeRadios()}</span>
<SortSelect
sort={this.state.sort}
onChange={this.handleSortChange}
hideHot
/>
</div>
);
}
combined(): Array<ReplyType> {
return [
...this.state.replies,
...this.state.mentions,
...this.state.messages,
].sort((a, b) => b.published.localeCompare(a.published));
}
all() {
return (
<div>
{this.combined().map(i =>
isCommentType(i) ? (
<CommentNodes
key={i.id}
nodes={[{ comment: i }]}
noIndent
markable
showCommunity
showContext
enableDownvotes={this.state.site.enable_downvotes}
/>
) : (
<PrivateMessage key={i.id} privateMessage={i} />
)
)}
</div>
);
}
replies() {
return (
<div>
<CommentNodes
nodes={commentsToFlatNodes(this.state.replies)}
noIndent
markable
showCommunity
showContext
enableDownvotes={this.state.site.enable_downvotes}
/>
</div>
);
}
mentions() {
return (
<div>
{this.state.mentions.map(mention => (
<CommentNodes
key={mention.id}
nodes={[{ comment: mention }]}
noIndent
markable
showCommunity
showContext
enableDownvotes={this.state.site.enable_downvotes}
/>
))}
</div>
);
}
messages() {
return (
<div>
{this.state.messages.map(message => (
<PrivateMessage key={message.id} privateMessage={message} />
))}
</div>
);
}
paginator() {
return (
<div class="mt-2">
{this.state.page > 1 && (
<button
class="btn btn-secondary mr-1"
onClick={linkEvent(this, this.prevPage)}
>
{i18n.t('prev')}
</button>
)}
{this.unreadCount() > 0 && (
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.nextPage)}
>
{i18n.t('next')}
</button>
)}
</div>
);
}
nextPage(i: Inbox) {
i.state.page++;
i.setState(i.state);
i.refetch();
}
prevPage(i: Inbox) {
i.state.page--;
i.setState(i.state);
i.refetch();
}
handleUnreadOrAllChange(i: Inbox, event: any) {
i.state.unreadOrAll = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.refetch();
}
handleMessageTypeChange(i: Inbox, event: any) {
i.state.messageType = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.refetch();
}
refetch() {
let repliesForm: GetRepliesForm = {
sort: this.state.sort,
unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
page: this.state.page,
limit: fetchLimit,
};
WebSocketService.Instance.getReplies(repliesForm);
let userMentionsForm: GetUserMentionsForm = {
sort: this.state.sort,
unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
page: this.state.page,
limit: fetchLimit,
};
WebSocketService.Instance.getUserMentions(userMentionsForm);
let privateMessagesForm: GetPrivateMessagesForm = {
unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
page: this.state.page,
limit: fetchLimit,
};
WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
}
handleSortChange(val: SortType) {
this.state.sort = val;
this.state.page = 1;
this.setState(this.state);
this.refetch();
}
markAllAsRead(i: Inbox) {
WebSocketService.Instance.markAllAsRead();
i.state.replies = [];
i.state.mentions = [];
i.state.messages = [];
i.sendUnreadCount();
window.scrollTo(0, 0);
i.setState(i.state);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (msg.reconnect) {
this.refetch();
} else if (res.op == UserOperation.GetReplies) {
let data = res.data as GetRepliesResponse;
this.state.replies = data.replies;
this.sendUnreadCount();
window.scrollTo(0, 0);
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.GetUserMentions) {
let data = res.data as GetUserMentionsResponse;
this.state.mentions = data.mentions;
this.sendUnreadCount();
window.scrollTo(0, 0);
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.GetPrivateMessages) {
let data = res.data as PrivateMessagesResponse;
this.state.messages = data.messages;
this.sendUnreadCount();
window.scrollTo(0, 0);
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.EditPrivateMessage) {
let data = res.data as PrivateMessageResponse;
let found: PrivateMessageI = this.state.messages.find(
m => m.id === data.message.id
);
if (found) {
found.content = data.message.content;
found.updated = data.message.updated;
}
this.setState(this.state);
} else if (res.op == UserOperation.DeletePrivateMessage) {
let data = res.data as PrivateMessageResponse;
let found: PrivateMessageI = this.state.messages.find(
m => m.id === data.message.id
);
if (found) {
found.deleted = data.message.deleted;
found.updated = data.message.updated;
}
this.setState(this.state);
} else if (res.op == UserOperation.MarkPrivateMessageAsRead) {
let data = res.data as PrivateMessageResponse;
let found: PrivateMessageI = this.state.messages.find(
m => m.id === data.message.id
);
if (found) {
found.updated = data.message.updated;
// If youre in the unread view, just remove it from the list
if (this.state.unreadOrAll == UnreadOrAll.Unread && data.message.read) {
this.state.messages = this.state.messages.filter(
r => r.id !== data.message.id
);
} else {
let found = this.state.messages.find(c => c.id == data.message.id);
found.read = data.message.read;
}
}
this.sendUnreadCount();
this.setState(this.state);
} else if (res.op == UserOperation.MarkAllAsRead) {
// Moved to be instant
} else if (
res.op == UserOperation.EditComment ||
res.op == UserOperation.DeleteComment ||
res.op == UserOperation.RemoveComment
) {
let data = res.data as CommentResponse;
editCommentRes(data, this.state.replies);
this.setState(this.state);
} else if (res.op == UserOperation.MarkCommentAsRead) {
let data = res.data as CommentResponse;
// If youre in the unread view, just remove it from the list
if (this.state.unreadOrAll == UnreadOrAll.Unread && data.comment.read) {
this.state.replies = this.state.replies.filter(
r => r.id !== data.comment.id
);
} else {
let found = this.state.replies.find(c => c.id == data.comment.id);
found.read = data.comment.read;
}
this.sendUnreadCount();
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.MarkUserMentionAsRead) {
let data = res.data as UserMentionResponse;
let found = this.state.mentions.find(c => c.id == data.mention.id);
found.content = data.mention.content;
found.updated = data.mention.updated;
found.removed = data.mention.removed;
found.deleted = data.mention.deleted;
found.upvotes = data.mention.upvotes;
found.downvotes = data.mention.downvotes;
found.score = data.mention.score;
// If youre in the unread view, just remove it from the list
if (this.state.unreadOrAll == UnreadOrAll.Unread && data.mention.read) {
this.state.mentions = this.state.mentions.filter(
r => r.id !== data.mention.id
);
} else {
let found = this.state.mentions.find(c => c.id == data.mention.id);
found.read = data.mention.read;
}
this.sendUnreadCount();
this.setState(this.state);
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
if (data.recipient_ids.includes(UserService.Instance.user.id)) {
this.state.replies.unshift(data.comment);
this.setState(this.state);
} else if (data.comment.creator_id == UserService.Instance.user.id) {
toast(i18n.t('reply_sent'));
}
} else if (res.op == UserOperation.CreatePrivateMessage) {
let data = res.data as PrivateMessageResponse;
if (data.message.recipient_id == UserService.Instance.user.id) {
this.state.messages.unshift(data.message);
this.setState(this.state);
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
saveCommentRes(data, this.state.replies);
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.replies);
this.setState(this.state);
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
sendUnreadCount() {
UserService.Instance.unreadCountSub.next(this.unreadCount());
}
unreadCount(): number {
return (
this.state.replies.filter(r => !r.read).length +
this.state.mentions.filter(r => !r.read).length +
this.state.messages.filter(
r =>
UserService.Instance.user &&
!r.read &&
r.creator_id !== UserService.Instance.user.id
).length
);
}
}

@ -1,98 +0,0 @@
import { Component } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
WebSocketJsonResponse,
GetSiteResponse,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { wsJsonToRes, toast } from '../utils';
import { i18n } from '../i18next';
interface InstancesState {
loading: boolean;
siteRes: GetSiteResponse;
}
export class Instances extends Component<any, InstancesState> {
private subscription: Subscription;
private emptyState: InstancesState = {
loading: true,
siteRes: undefined,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.siteRes) {
return `${i18n.t('instances')} - ${this.state.siteRes.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
{this.state.loading ? (
<h5 class="">
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div>
<h5>{i18n.t('linked_instances')}</h5>
{this.state.siteRes &&
this.state.siteRes.federated_instances.length ? (
<ul>
{this.state.siteRes.federated_instances.map(i => (
<li>
<a href={`https://${i}`} target="_blank" rel="noopener">
{i}
</a>
</li>
))}
</ul>
) : (
<div>{i18n.t('none_found')}</div>
)}
</div>
)}
</div>
);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.siteRes = data;
this.state.loading = false;
this.setState(this.state);
}
}
}

@ -1,95 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { ListingType } from 'lemmy-js-client';
import { UserService } from '../services';
import { randomStr } from '../utils';
import { i18n } from '../i18next';
interface ListingTypeSelectProps {
type_: ListingType;
showLocal?: boolean;
onChange?(val: ListingType): any;
}
interface ListingTypeSelectState {
type_: ListingType;
}
export class ListingTypeSelect extends Component<
ListingTypeSelectProps,
ListingTypeSelectState
> {
private id = `listing-type-input-${randomStr()}`;
private emptyState: ListingTypeSelectState = {
type_: this.props.type_,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
static getDerivedStateFromProps(props: any): ListingTypeSelectProps {
return {
type_: props.type_,
showLocal: props.showLocal,
};
}
render() {
return (
<div class="btn-group btn-group-toggle flex-wrap mb-2">
<label
className={`btn btn-outline-secondary
${this.state.type_ == ListingType.Subscribed && 'active'}
${UserService.Instance.user == undefined ? 'disabled' : 'pointer'}
`}
>
<input
id={`${this.id}-subscribed`}
type="radio"
value={ListingType.Subscribed}
checked={this.state.type_ == ListingType.Subscribed}
onChange={linkEvent(this, this.handleTypeChange)}
disabled={UserService.Instance.user == undefined}
/>
{i18n.t('subscribed')}
</label>
{this.props.showLocal && (
<label
className={`pointer btn btn-outline-secondary ${
this.state.type_ == ListingType.Local && 'active'
}`}
>
<input
id={`${this.id}-local`}
type="radio"
value={ListingType.Local}
checked={this.state.type_ == ListingType.Local}
onChange={linkEvent(this, this.handleTypeChange)}
/>
{i18n.t('local')}
</label>
)}
<label
className={`pointer btn btn-outline-secondary ${
this.state.type_ == ListingType.All && 'active'
}`}
>
<input
id={`${this.id}-all`}
type="radio"
value={ListingType.All}
checked={this.state.type_ == ListingType.All}
onChange={linkEvent(this, this.handleTypeChange)}
/>
{i18n.t('all')}
</label>
</div>
);
}
handleTypeChange(i: ListingTypeSelect, event: any) {
i.props.onChange(event.target.value);
}
}

@ -1,485 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
LoginForm,
RegisterForm,
LoginResponse,
UserOperation,
PasswordResetForm,
GetSiteResponse,
GetCaptchaResponse,
WebSocketJsonResponse,
Site,
} from 'lemmy-js-client';
import { WebSocketService, UserService } from '../services';
import { wsJsonToRes, validEmail, toast } from '../utils';
import { i18n } from '../i18next';
interface State {
loginForm: LoginForm;
registerForm: RegisterForm;
loginLoading: boolean;
registerLoading: boolean;
captcha: GetCaptchaResponse;
captchaPlaying: boolean;
site: Site;
}
export class Login extends Component<any, State> {
private subscription: Subscription;
emptyState: State = {
loginForm: {
username_or_email: undefined,
password: undefined,
},
registerForm: {
username: undefined,
password: undefined,
password_verify: undefined,
admin: false,
show_nsfw: false,
captcha_uuid: undefined,
captcha_answer: undefined,
},
loginLoading: false,
registerLoading: false,
captcha: undefined,
captchaPlaying: false,
site: {
id: undefined,
name: undefined,
creator_id: undefined,
published: undefined,
creator_name: undefined,
number_of_users: undefined,
number_of_posts: undefined,
number_of_comments: undefined,
number_of_communities: undefined,
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
},
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
WebSocketService.Instance.getCaptcha();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.site.name) {
return `${i18n.t('login')} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<div class="row">
<div class="col-12 col-lg-6 mb-4">{this.loginForm()}</div>
<div class="col-12 col-lg-6">{this.registerForm()}</div>
</div>
</div>
);
}
loginForm() {
return (
<div>
<form onSubmit={linkEvent(this, this.handleLoginSubmit)}>
<h5>{i18n.t('login')}</h5>
<div class="form-group row">
<label
class="col-sm-2 col-form-label"
htmlFor="login-email-or-username"
>
{i18n.t('email_or_username')}
</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
id="login-email-or-username"
value={this.state.loginForm.username_or_email}
onInput={linkEvent(this, this.handleLoginUsernameChange)}
required
minLength={3}
/>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="login-password">
{i18n.t('password')}
</label>
<div class="col-sm-10">
<input
type="password"
id="login-password"
value={this.state.loginForm.password}
onInput={linkEvent(this, this.handleLoginPasswordChange)}
class="form-control"
required
/>
{validEmail(this.state.loginForm.username_or_email) && (
<button
type="button"
onClick={linkEvent(this, this.handlePasswordReset)}
className="btn p-0 btn-link d-inline-block float-right text-muted small font-weight-bold"
>
{i18n.t('forgot_password')}
</button>
)}
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-secondary">
{this.state.loginLoading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
i18n.t('login')
)}
</button>
</div>
</div>
</form>
</div>
);
}
registerForm() {
return (
<form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
<h5>{i18n.t('sign_up')}</h5>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="register-username">
{i18n.t('username')}
</label>
<div class="col-sm-10">
<input
type="text"
id="register-username"
class="form-control"
value={this.state.registerForm.username}
onInput={linkEvent(this, this.handleRegisterUsernameChange)}
required
minLength={3}
maxLength={20}
pattern="[a-zA-Z0-9_]+"
/>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="register-email">
{i18n.t('email')}
</label>
<div class="col-sm-10">
<input
type="email"
id="register-email"
class="form-control"
placeholder={i18n.t('optional')}
value={this.state.registerForm.email}
onInput={linkEvent(this, this.handleRegisterEmailChange)}
minLength={3}
/>
{!validEmail(this.state.registerForm.email) && (
<div class="mt-2 mb-0 alert alert-light" role="alert">
<svg class="icon icon-inline mr-2">
<use xlinkHref="#icon-alert-triangle"></use>
</svg>
{i18n.t('no_password_reset')}
</div>
)}
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="register-password">
{i18n.t('password')}
</label>
<div class="col-sm-10">
<input
type="password"
id="register-password"
value={this.state.registerForm.password}
autoComplete="new-password"
onInput={linkEvent(this, this.handleRegisterPasswordChange)}
class="form-control"
required
/>
</div>
</div>
<div class="form-group row">
<label
class="col-sm-2 col-form-label"
htmlFor="register-verify-password"
>
{i18n.t('verify_password')}
</label>
<div class="col-sm-10">
<input
type="password"
id="register-verify-password"
value={this.state.registerForm.password_verify}
autoComplete="new-password"
onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
class="form-control"
required
/>
</div>
</div>
{this.state.captcha && (
<div class="form-group row">
<label class="col-sm-2" htmlFor="register-captcha">
<span class="mr-2">{i18n.t('enter_code')}</span>
<button
type="button"
class="btn btn-secondary"
onClick={linkEvent(this, this.handleRegenCaptcha)}
>
<svg class="icon icon-refresh-cw">
<use xlinkHref="#icon-refresh-cw"></use>
</svg>
</button>
</label>
{this.showCaptcha()}
<div class="col-sm-6">
<input
type="text"
class="form-control"
id="register-captcha"
value={this.state.registerForm.captcha_answer}
onInput={linkEvent(
this,
this.handleRegisterCaptchaAnswerChange
)}
required
/>
</div>
</div>
)}
{this.state.site.enable_nsfw && (
<div class="form-group row">
<div class="col-sm-10">
<div class="form-check">
<input
class="form-check-input"
id="register-show-nsfw"
type="checkbox"
checked={this.state.registerForm.show_nsfw}
onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
/>
<label class="form-check-label" htmlFor="register-show-nsfw">
{i18n.t('show_nsfw')}
</label>
</div>
</div>
</div>
)}
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-secondary">
{this.state.registerLoading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
i18n.t('sign_up')
)}
</button>
</div>
</div>
</form>
);
}
showCaptcha() {
return (
<div class="col-sm-4">
{this.state.captcha.ok && (
<>
<img
class="rounded-top img-fluid"
src={this.captchaPngSrc()}
style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
/>
{this.state.captcha.ok.wav && (
<button
class="rounded-bottom btn btn-sm btn-secondary btn-block"
style="border-top-right-radius: 0; border-top-left-radius: 0;"
title={i18n.t('play_captcha_audio')}
onClick={linkEvent(this, this.handleCaptchaPlay)}
type="button"
disabled={this.state.captchaPlaying}
>
<svg class="icon icon-play">
<use xlinkHref="#icon-play"></use>
</svg>
</button>
)}
</>
)}
</div>
);
}
handleLoginSubmit(i: Login, event: any) {
event.preventDefault();
i.state.loginLoading = true;
i.setState(i.state);
WebSocketService.Instance.login(i.state.loginForm);
}
handleLoginUsernameChange(i: Login, event: any) {
i.state.loginForm.username_or_email = event.target.value;
i.setState(i.state);
}
handleLoginPasswordChange(i: Login, event: any) {
i.state.loginForm.password = event.target.value;
i.setState(i.state);
}
handleRegisterSubmit(i: Login, event: any) {
event.preventDefault();
i.state.registerLoading = true;
i.setState(i.state);
WebSocketService.Instance.register(i.state.registerForm);
}
handleRegisterUsernameChange(i: Login, event: any) {
i.state.registerForm.username = event.target.value;
i.setState(i.state);
}
handleRegisterEmailChange(i: Login, event: any) {
i.state.registerForm.email = event.target.value;
if (i.state.registerForm.email == '') {
i.state.registerForm.email = undefined;
}
i.setState(i.state);
}
handleRegisterPasswordChange(i: Login, event: any) {
i.state.registerForm.password = event.target.value;
i.setState(i.state);
}
handleRegisterPasswordVerifyChange(i: Login, event: any) {
i.state.registerForm.password_verify = event.target.value;
i.setState(i.state);
}
handleRegisterShowNsfwChange(i: Login, event: any) {
i.state.registerForm.show_nsfw = event.target.checked;
i.setState(i.state);
}
handleRegisterCaptchaAnswerChange(i: Login, event: any) {
i.state.registerForm.captcha_answer = event.target.value;
i.setState(i.state);
}
handleRegenCaptcha(_i: Login, _event: any) {
event.preventDefault();
WebSocketService.Instance.getCaptcha();
}
handlePasswordReset(i: Login) {
event.preventDefault();
let resetForm: PasswordResetForm = {
email: i.state.loginForm.username_or_email,
};
WebSocketService.Instance.passwordReset(resetForm);
}
handleCaptchaPlay(i: Login) {
event.preventDefault();
let snd = new Audio('data:audio/wav;base64,' + i.state.captcha.ok.wav);
snd.play();
i.state.captchaPlaying = true;
i.setState(i.state);
snd.addEventListener('ended', () => {
snd.currentTime = 0;
i.state.captchaPlaying = false;
i.setState(this.state);
});
}
captchaPngSrc() {
return `data:image/png;base64,${this.state.captcha.ok.png}`;
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.state = this.emptyState;
this.state.registerForm.captcha_answer = undefined;
// Refetch another captcha
WebSocketService.Instance.getCaptcha();
this.setState(this.state);
return;
} else {
if (res.op == UserOperation.Login) {
let data = res.data as LoginResponse;
this.state = this.emptyState;
this.setState(this.state);
UserService.Instance.login(data);
WebSocketService.Instance.userJoin();
toast(i18n.t('logged_in'));
this.props.history.push('/');
} else if (res.op == UserOperation.Register) {
let data = res.data as LoginResponse;
this.state = this.emptyState;
this.setState(this.state);
UserService.Instance.login(data);
WebSocketService.Instance.userJoin();
this.props.history.push('/communities');
} else if (res.op == UserOperation.GetCaptcha) {
let data = res.data as GetCaptchaResponse;
if (data.ok) {
this.state.captcha = data;
this.state.registerForm.captcha_uuid = data.ok.uuid;
this.setState(this.state);
}
} else if (res.op == UserOperation.PasswordReset) {
toast(i18n.t('reset_password_mail_sent'));
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}
}

@ -1,807 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Link } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
CommunityUser,
GetFollowedCommunitiesResponse,
ListCommunitiesForm,
ListCommunitiesResponse,
Community,
SortType,
GetSiteResponse,
ListingType,
SiteResponse,
GetPostsResponse,
PostResponse,
Post,
GetPostsForm,
Comment,
GetCommentsForm,
GetCommentsResponse,
CommentResponse,
AddAdminResponse,
BanUserResponse,
WebSocketJsonResponse,
} from 'lemmy-js-client';
import { DataType } from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { PostListings } from './post-listings';
import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select';
import { ListingTypeSelect } from './listing-type-select';
import { DataTypeSelect } from './data-type-select';
import { SiteForm } from './site-form';
import { UserListing } from './user-listing';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import {
wsJsonToRes,
repoUrl,
mdToHtml,
fetchLimit,
toast,
getListingTypeFromProps,
getPageFromProps,
getSortTypeFromProps,
getDataTypeFromProps,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
editPostFindRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
notifyPost,
} from '../utils';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
interface MainState {
subscribedCommunities: Array<CommunityUser>;
trendingCommunities: Array<Community>;
siteRes: GetSiteResponse;
showEditSite: boolean;
loading: boolean;
posts: Array<Post>;
comments: Array<Comment>;
listingType: ListingType;
dataType: DataType;
sort: SortType;
page: number;
}
interface MainProps {
listingType: ListingType;
dataType: DataType;
sort: SortType;
page: number;
}
interface UrlParams {
listingType?: ListingType;
dataType?: string;
sort?: SortType;
page?: number;
}
export class Main extends Component<any, MainState> {
private subscription: Subscription;
private emptyState: MainState = {
subscribedCommunities: [],
trendingCommunities: [],
siteRes: {
site: {
id: null,
name: null,
creator_id: null,
creator_name: null,
published: null,
number_of_users: null,
number_of_posts: null,
number_of_comments: null,
number_of_communities: null,
enable_downvotes: null,
open_registration: null,
enable_nsfw: null,
icon: null,
banner: null,
creator_preferred_username: null,
},
admins: [],
banned: [],
online: null,
version: null,
federated_instances: null,
},
showEditSite: false,
loading: true,
posts: [],
comments: [],
listingType: getListingTypeFromProps(this.props),
dataType: getDataTypeFromProps(this.props),
sort: getSortTypeFromProps(this.props),
page: getPageFromProps(this.props),
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleEditCancel = this.handleEditCancel.bind(this);
this.handleSortChange = this.handleSortChange.bind(this);
this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
if (UserService.Instance.user) {
WebSocketService.Instance.getFollowedCommunities();
}
let listCommunitiesForm: ListCommunitiesForm = {
sort: SortType.Hot,
limit: 6,
};
WebSocketService.Instance.listCommunities(listCommunitiesForm);
this.fetchData();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
static getDerivedStateFromProps(props: any): MainProps {
return {
listingType: getListingTypeFromProps(props),
dataType: getDataTypeFromProps(props),
sort: getSortTypeFromProps(props),
page: getPageFromProps(props),
};
}
componentDidUpdate(_: any, lastState: MainState) {
if (
lastState.listingType !== this.state.listingType ||
lastState.dataType !== this.state.dataType ||
lastState.sort !== this.state.sort ||
lastState.page !== this.state.page
) {
this.setState({ loading: true });
this.fetchData();
}
}
get documentTitle(): string {
if (this.state.siteRes.site.name) {
return `${this.state.siteRes.site.name}`;
} else {
return 'Lemmy';
}
}
get favIcon(): string {
return this.state.siteRes.site.icon
? this.state.siteRes.site.icon
: favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
<div class="row">
<main role="main" class="col-12 col-md-8">
{this.posts()}
</main>
<aside class="col-12 col-md-4">{this.mySidebar()}</aside>
</div>
</div>
);
}
mySidebar() {
return (
<div>
{!this.state.loading && (
<div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-header bg-transparent border-secondary">
<div class="mb-2">
{this.siteName()}
{this.adminButtons()}
</div>
<BannerIconHeader banner={this.state.siteRes.site.banner} />
</div>
<div class="card-body">
{this.trendingCommunities()}
{this.createCommunityButton()}
{/*
{this.subscribedCommunities()}
*/}
</div>
</div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-body">{this.sidebar()}</div>
</div>
<div class="card bg-transparent border-secondary">
<div class="card-body">{this.landing()}</div>
</div>
</div>
)}
</div>
);
}
createCommunityButton() {
return (
<Link class="btn btn-secondary btn-block" to="/create_community">
{i18n.t('create_a_community')}
</Link>
);
}
trendingCommunities() {
return (
<div>
<h5>
<T i18nKey="trending_communities">
#
<Link class="text-body" to="/communities">
#
</Link>
</T>
</h5>
<ul class="list-inline">
{this.state.trendingCommunities.map(community => (
<li class="list-inline-item">
<CommunityLink community={community} />
</li>
))}
</ul>
</div>
);
}
subscribedCommunities() {
return (
UserService.Instance.user &&
this.state.subscribedCommunities.length > 0 && (
<div>
<h5>
<T i18nKey="subscribed_to_communities">
#
<Link class="text-body" to="/communities">
#
</Link>
</T>
</h5>
<ul class="list-inline">
{this.state.subscribedCommunities.map(community => (
<li class="list-inline-item">
<CommunityLink
community={{
name: community.community_name,
id: community.community_id,
local: community.community_local,
actor_id: community.community_actor_id,
icon: community.community_icon,
}}
/>
</li>
))}
</ul>
</div>
)
);
}
sidebar() {
return (
<div>
{!this.state.showEditSite ? (
this.siteInfo()
) : (
<SiteForm
site={this.state.siteRes.site}
onCancel={this.handleEditCancel}
/>
)}
</div>
);
}
updateUrl(paramUpdates: UrlParams) {
const listingTypeStr = paramUpdates.listingType || this.state.listingType;
const dataTypeStr = paramUpdates.dataType || DataType[this.state.dataType];
const sortStr = paramUpdates.sort || this.state.sort;
const page = paramUpdates.page || this.state.page;
this.props.history.push(
`/home/data_type/${dataTypeStr}/listing_type/${listingTypeStr}/sort/${sortStr}/page/${page}`
);
}
siteInfo() {
return (
<div>
{this.state.siteRes.site.description && this.siteDescription()}
{this.badges()}
{this.admins()}
</div>
);
}
siteName() {
return <h5 class="mb-0">{`${this.state.siteRes.site.name}`}</h5>;
}
admins() {
return (
<ul class="mt-1 list-inline small mb-0">
<li class="list-inline-item">{i18n.t('admins')}:</li>
{this.state.siteRes.admins.map(admin => (
<li class="list-inline-item">
<UserListing
user={{
name: admin.name,
preferred_username: admin.preferred_username,
avatar: admin.avatar,
local: admin.local,
actor_id: admin.actor_id,
id: admin.id,
}}
/>
</li>
))}
</ul>
);
}
badges() {
return (
<ul class="my-2 list-inline">
<li className="list-inline-item badge badge-light">
{i18n.t('number_online', { count: this.state.siteRes.online })}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_users', {
count: this.state.siteRes.site.number_of_users,
})}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_communities', {
count: this.state.siteRes.site.number_of_communities,
})}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_posts', {
count: this.state.siteRes.site.number_of_posts,
})}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_comments', {
count: this.state.siteRes.site.number_of_comments,
})}
</li>
<li className="list-inline-item">
<Link className="badge badge-light" to="/modlog">
{i18n.t('modlog')}
</Link>
</li>
</ul>
);
}
adminButtons() {
return (
this.canAdmin && (
<ul class="list-inline mb-1 text-muted font-weight-bold">
<li className="list-inline-item-action">
<span
class="pointer"
onClick={linkEvent(this, this.handleEditClick)}
data-tippy-content={i18n.t('edit')}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-edit"></use>
</svg>
</span>
</li>
</ul>
)
);
}
siteDescription() {
return (
<div
className="md-div"
dangerouslySetInnerHTML={mdToHtml(this.state.siteRes.site.description)}
/>
);
}
landing() {
return (
<>
<h5>
{i18n.t('powered_by')}
<svg class="icon mx-2">
<use xlinkHref="#icon-mouse">#</use>
</svg>
<a href={repoUrl}>
Lemmy<sup>beta</sup>
</a>
</h5>
<p class="mb-0">
<T i18nKey="landing_0">
#
<a href="https://en.wikipedia.org/wiki/Social_network_aggregation">
#
</a>
<a href="https://en.wikipedia.org/wiki/Fediverse">#</a>
<br class="big"></br>
<code>#</code>
<br></br>
<b>#</b>
<br class="big"></br>
<a href={repoUrl}>#</a>
<br class="big"></br>
<a href="https://www.rust-lang.org">#</a>
<a href="https://actix.rs/">#</a>
<a href="https://infernojs.org">#</a>
<a href="https://www.typescriptlang.org/">#</a>
<br class="big"></br>
<a href="https://github.com/LemmyNet/lemmy/graphs/contributors?type=a">
#
</a>
</T>
</p>
</>
);
}
posts() {
return (
<div class="main-content-wrapper">
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div>
{this.selects()}
{this.listings()}
{this.paginator()}
</div>
)}
</div>
);
}
listings() {
return this.state.dataType == DataType.Post ? (
<PostListings
posts={this.state.posts}
showCommunity
removeDuplicates
sort={this.state.sort}
enableDownvotes={this.state.siteRes.site.enable_downvotes}
enableNsfw={this.state.siteRes.site.enable_nsfw}
/>
) : (
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
noIndent
showCommunity
sortType={this.state.sort}
showContext
enableDownvotes={this.state.siteRes.site.enable_downvotes}
/>
);
}
selects() {
return (
<div className="mb-3">
<span class="mr-3">
<DataTypeSelect
type_={this.state.dataType}
onChange={this.handleDataTypeChange}
/>
</span>
<span class="mr-3">
<ListingTypeSelect
type_={this.state.listingType}
showLocal={
this.state.siteRes.federated_instances &&
this.state.siteRes.federated_instances.length > 0
}
onChange={this.handleListingTypeChange}
/>
</span>
<span class="mr-2">
<SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
</span>
{this.state.listingType == ListingType.All && (
<a
href={`/feeds/all.xml?sort=${this.state.sort}`}
target="_blank"
rel="noopener"
title="RSS"
>
<svg class="icon text-muted small">
<use xlinkHref="#icon-rss">#</use>
</svg>
</a>
)}
{UserService.Instance.user &&
this.state.listingType == ListingType.Subscribed && (
<a
href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${this.state.sort}`}
target="_blank"
title="RSS"
rel="noopener"
>
<svg class="icon text-muted small">
<use xlinkHref="#icon-rss">#</use>
</svg>
</a>
)}
</div>
);
}
paginator() {
return (
<div class="my-2">
{this.state.page > 1 && (
<button
class="btn btn-secondary mr-1"
onClick={linkEvent(this, this.prevPage)}
>
{i18n.t('prev')}
</button>
)}
{this.state.posts.length > 0 && (
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.nextPage)}
>
{i18n.t('next')}
</button>
)}
</div>
);
}
get canAdmin(): boolean {
return (
UserService.Instance.user &&
this.state.siteRes.admins
.map(a => a.id)
.includes(UserService.Instance.user.id)
);
}
handleEditClick(i: Main) {
i.state.showEditSite = true;
i.setState(i.state);
}
handleEditCancel() {
this.state.showEditSite = false;
this.setState(this.state);
}
nextPage(i: Main) {
i.updateUrl({ page: i.state.page + 1 });
window.scrollTo(0, 0);
}
prevPage(i: Main) {
i.updateUrl({ page: i.state.page - 1 });
window.scrollTo(0, 0);
}
handleSortChange(val: SortType) {
this.updateUrl({ sort: val, page: 1 });
window.scrollTo(0, 0);
}
handleListingTypeChange(val: ListingType) {
this.updateUrl({ listingType: val, page: 1 });
window.scrollTo(0, 0);
}
handleDataTypeChange(val: DataType) {
this.updateUrl({ dataType: DataType[val], page: 1 });
window.scrollTo(0, 0);
}
fetchData() {
if (this.state.dataType == DataType.Post) {
let getPostsForm: GetPostsForm = {
page: this.state.page,
limit: fetchLimit,
sort: this.state.sort,
type_: this.state.listingType,
};
WebSocketService.Instance.getPosts(getPostsForm);
} else {
let getCommentsForm: GetCommentsForm = {
page: this.state.page,
limit: fetchLimit,
sort: this.state.sort,
type_: this.state.listingType,
};
WebSocketService.Instance.getComments(getCommentsForm);
}
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (msg.reconnect) {
this.fetchData();
} else if (res.op == UserOperation.GetFollowedCommunities) {
let data = res.data as GetFollowedCommunitiesResponse;
this.state.subscribedCommunities = data.communities;
this.setState(this.state);
} else if (res.op == UserOperation.ListCommunities) {
let data = res.data as ListCommunitiesResponse;
this.state.trendingCommunities = data.communities;
this.setState(this.state);
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
// This means it hasn't been set up yet
if (!data.site) {
this.context.router.history.push('/setup');
}
this.state.siteRes.admins = data.admins;
this.state.siteRes.site = data.site;
this.state.siteRes.banned = data.banned;
this.state.siteRes.online = data.online;
this.setState(this.state);
} else if (res.op == UserOperation.EditSite) {
let data = res.data as SiteResponse;
this.state.siteRes.site = data.site;
this.state.showEditSite = false;
this.setState(this.state);
toast(i18n.t('site_saved'));
} else if (res.op == UserOperation.GetPosts) {
let data = res.data as GetPostsResponse;
this.state.posts = data.posts;
this.state.loading = false;
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.CreatePost) {
let data = res.data as PostResponse;
// If you're on subscribed, only push it if you're subscribed.
if (this.state.listingType == ListingType.Subscribed) {
if (
this.state.subscribedCommunities
.map(c => c.community_id)
.includes(data.post.community_id)
) {
this.state.posts.unshift(data.post);
notifyPost(data.post, this.context.router);
}
} else {
// NSFW posts
let nsfw = data.post.nsfw || data.post.community_nsfw;
// Don't push the post if its nsfw, and don't have that setting on
if (
!nsfw ||
(nsfw &&
UserService.Instance.user &&
UserService.Instance.user.show_nsfw)
) {
this.state.posts.unshift(data.post);
notifyPost(data.post, this.context.router);
}
}
this.setState(this.state);
} else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse;
editPostFindRes(data, this.state.posts);
this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
createPostLikeFindRes(data, this.state.posts);
this.setState(this.state);
} else if (res.op == UserOperation.AddAdmin) {
let data = res.data as AddAdminResponse;
this.state.siteRes.admins = data.admins;
this.setState(this.state);
} else if (res.op == UserOperation.BanUser) {
let data = res.data as BanUserResponse;
let found = this.state.siteRes.banned.find(u => (u.id = data.user.id));
// Remove the banned if its found in the list, and the action is an unban
if (found && !data.banned) {
this.state.siteRes.banned = this.state.siteRes.banned.filter(
i => i.id !== data.user.id
);
} else {
this.state.siteRes.banned.push(data.user);
}
this.state.posts
.filter(p => p.creator_id == data.user.id)
.forEach(p => (p.banned = data.banned));
this.setState(this.state);
} else if (res.op == UserOperation.GetComments) {
let data = res.data as GetCommentsResponse;
this.state.comments = data.comments;
this.state.loading = false;
this.setState(this.state);
} else if (
res.op == UserOperation.EditComment ||
res.op == UserOperation.DeleteComment ||
res.op == UserOperation.RemoveComment
) {
let data = res.data as CommentResponse;
editCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
// Necessary since it might be a user reply
if (data.recipient_ids.length == 0) {
// If you're on subscribed, only push it if you're subscribed.
if (this.state.listingType == ListingType.Subscribed) {
if (
this.state.subscribedCommunities
.map(c => c.community_id)
.includes(data.comment.community_id)
) {
this.state.comments.unshift(data.comment);
}
} else {
this.state.comments.unshift(data.comment);
}
this.setState(this.state);
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
saveCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.comments);
this.setState(this.state);
}
}
}

@ -1,543 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Prompt } from 'inferno-router';
import {
mdToHtml,
randomStr,
markdownHelpUrl,
toast,
setupTribute,
pictrsDeleteToast,
setupTippy,
} from '../utils';
import { UserService } from '../services';
import autosize from 'autosize';
import Tribute from 'tributejs/src/Tribute.js';
import { i18n } from '../i18next';
interface MarkdownTextAreaProps {
initialContent: string;
finished?: boolean;
buttonTitle?: string;
replyType?: boolean;
focus?: boolean;
disabled?: boolean;
maxLength?: number;
onSubmit?(msg: { val: string; formId: string }): any;
onContentChange?(val: string): any;
onReplyCancel?(): any;
hideNavigationWarnings?: boolean;
}
interface MarkdownTextAreaState {
content: string;
previewMode: boolean;
loading: boolean;
imageLoading: boolean;
}
export class MarkdownTextArea extends Component<
MarkdownTextAreaProps,
MarkdownTextAreaState
> {
private id = `comment-textarea-${randomStr()}`;
private formId = `comment-form-${randomStr()}`;
private tribute: Tribute;
private emptyState: MarkdownTextAreaState = {
content: this.props.initialContent,
previewMode: false,
loading: false,
imageLoading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.tribute = setupTribute();
this.state = this.emptyState;
}
componentDidMount() {
let textarea: any = document.getElementById(this.id);
if (textarea) {
autosize(textarea);
this.tribute.attach(textarea);
textarea.addEventListener('tribute-replaced', () => {
this.state.content = textarea.value;
this.setState(this.state);
autosize.update(textarea);
});
this.quoteInsert();
if (this.props.focus) {
textarea.focus();
}
// TODO this is slow for some reason
setupTippy();
}
}
componentDidUpdate() {
if (!this.props.hideNavigationWarnings && this.state.content) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = undefined;
}
}
componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
if (nextProps.finished) {
this.state.previewMode = false;
this.state.loading = false;
this.state.content = '';
this.setState(this.state);
if (this.props.replyType) {
this.props.onReplyCancel();
}
let textarea: any = document.getElementById(this.id);
let form: any = document.getElementById(this.formId);
form.reset();
setTimeout(() => autosize.update(textarea), 10);
this.setState(this.state);
}
}
componentWillUnmount() {
window.onbeforeunload = null;
}
render() {
return (
<form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
<Prompt
when={!this.props.hideNavigationWarnings && this.state.content}
message={i18n.t('block_leaving')}
/>
<div class="form-group row">
<div className={`col-sm-12`}>
<textarea
id={this.id}
className={`form-control ${this.state.previewMode && 'd-none'}`}
value={this.state.content}
onInput={linkEvent(this, this.handleContentChange)}
onPaste={linkEvent(this, this.handleImageUploadPaste)}
required
disabled={this.props.disabled}
rows={2}
maxLength={this.props.maxLength || 10000}
/>
{this.state.previewMode && (
<div
className="card bg-transparent border-secondary card-body md-div"
dangerouslySetInnerHTML={mdToHtml(this.state.content)}
/>
)}
</div>
</div>
<div class="row">
<div class="col-sm-12 d-flex flex-wrap">
{this.props.buttonTitle && (
<button
type="submit"
class="btn btn-sm btn-secondary mr-2"
disabled={this.props.disabled || this.state.loading}
>
{this.state.loading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
<span>{this.props.buttonTitle}</span>
)}
</button>
)}
{this.props.replyType && (
<button
type="button"
class="btn btn-sm btn-secondary mr-2"
onClick={linkEvent(this, this.handleReplyCancel)}
>
{i18n.t('cancel')}
</button>
)}
{this.state.content && (
<button
className={`btn btn-sm btn-secondary mr-2 ${
this.state.previewMode && 'active'
}`}
onClick={linkEvent(this, this.handlePreviewToggle)}
>
{i18n.t('preview')}
</button>
)}
{/* A flex expander */}
<div class="flex-grow-1"></div>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('bold')}
onClick={linkEvent(this, this.handleInsertBold)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-bold"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('italic')}
onClick={linkEvent(this, this.handleInsertItalic)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-italic"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('link')}
onClick={linkEvent(this, this.handleInsertLink)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-link"></use>
</svg>
</button>
<form class="btn btn-sm text-muted font-weight-bold">
<label
htmlFor={`file-upload-${this.id}`}
className={`mb-0 ${UserService.Instance.user && 'pointer'}`}
data-tippy-content={i18n.t('upload_image')}
>
{this.state.imageLoading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
<svg class="icon icon-inline">
<use xlinkHref="#icon-image"></use>
</svg>
)}
</label>
<input
id={`file-upload-${this.id}`}
type="file"
accept="image/*,video/*"
name="file"
class="d-none"
disabled={!UserService.Instance.user}
onChange={linkEvent(this, this.handleImageUpload)}
/>
</form>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('header')}
onClick={linkEvent(this, this.handleInsertHeader)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-header"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('strikethrough')}
onClick={linkEvent(this, this.handleInsertStrikethrough)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-strikethrough"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('quote')}
onClick={linkEvent(this, this.handleInsertQuote)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-format_quote"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('list')}
onClick={linkEvent(this, this.handleInsertList)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-list"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('code')}
onClick={linkEvent(this, this.handleInsertCode)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-code"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('subscript')}
onClick={linkEvent(this, this.handleInsertSubscript)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-subscript"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('superscript')}
onClick={linkEvent(this, this.handleInsertSuperscript)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-superscript"></use>
</svg>
</button>
<button
class="btn btn-sm text-muted"
data-tippy-content={i18n.t('spoiler')}
onClick={linkEvent(this, this.handleInsertSpoiler)}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-alert-triangle"></use>
</svg>
</button>
<a
href={markdownHelpUrl}
target="_blank"
class="btn btn-sm text-muted font-weight-bold"
title={i18n.t('formatting_help')}
rel="noopener"
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-help-circle"></use>
</svg>
</a>
</div>
</div>
</form>
);
}
handleImageUploadPaste(i: MarkdownTextArea, event: any) {
let image = event.clipboardData.files[0];
if (image) {
i.handleImageUpload(i, image);
}
}
handleImageUpload(i: MarkdownTextArea, event: any) {
let file: any;
if (event.target) {
event.preventDefault();
file = event.target.files[0];
} else {
file = event;
}
const imageUploadUrl = `/pictrs/image`;
const formData = new FormData();
formData.append('images[]', file);
i.state.imageLoading = true;
i.setState(i.state);
fetch(imageUploadUrl, {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(res => {
console.log('pictrs upload:');
console.log(res);
if (res.msg == 'ok') {
let hash = res.files[0].file;
let url = `${window.location.origin}/pictrs/image/${hash}`;
let deleteToken = res.files[0].delete_token;
let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
let imageMarkdown = `![](${url})`;
let content = i.state.content;
content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
i.state.content = content;
i.state.imageLoading = false;
i.setState(i.state);
let textarea: any = document.getElementById(i.id);
autosize.update(textarea);
pictrsDeleteToast(
i18n.t('click_to_delete_picture'),
i18n.t('picture_deleted'),
deleteUrl
);
} else {
i.state.imageLoading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
}
})
.catch(error => {
i.state.imageLoading = false;
i.setState(i.state);
toast(error, 'danger');
});
}
handleContentChange(i: MarkdownTextArea, event: any) {
i.state.content = event.target.value;
i.setState(i.state);
if (i.props.onContentChange) {
i.props.onContentChange(i.state.content);
}
}
handlePreviewToggle(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.state.previewMode = !i.state.previewMode;
i.setState(i.state);
}
handleSubmit(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.state.loading = true;
i.setState(i.state);
let msg = { val: i.state.content, formId: i.formId };
i.props.onSubmit(msg);
}
handleReplyCancel(i: MarkdownTextArea) {
i.props.onReplyCancel();
}
handleInsertLink(i: MarkdownTextArea, event: any) {
event.preventDefault();
if (!i.state.content) {
i.state.content = '';
}
let textarea: any = document.getElementById(i.id);
let start: number = textarea.selectionStart;
let end: number = textarea.selectionEnd;
if (start !== end) {
let selectedText = i.state.content.substring(start, end);
i.state.content = `${i.state.content.substring(
0,
start
)} [${selectedText}]() ${i.state.content.substring(end)}`;
textarea.focus();
setTimeout(() => (textarea.selectionEnd = end + 4), 10);
} else {
i.state.content += '[]()';
textarea.focus();
setTimeout(() => (textarea.selectionEnd -= 1), 10);
}
i.setState(i.state);
}
simpleSurround(chars: string) {
this.simpleSurroundBeforeAfter(chars, chars);
}
simpleSurroundBeforeAfter(beforeChars: string, afterChars: string) {
if (!this.state.content) {
this.state.content = '';
}
let textarea: any = document.getElementById(this.id);
let start: number = textarea.selectionStart;
let end: number = textarea.selectionEnd;
if (start !== end) {
let selectedText = this.state.content.substring(start, end);
this.state.content = `${this.state.content.substring(
0,
start - 1
)} ${beforeChars}${selectedText}${afterChars} ${this.state.content.substring(
end + 1
)}`;
} else {
this.state.content += `${beforeChars}___${afterChars}`;
}
this.setState(this.state);
setTimeout(() => {
autosize.update(textarea);
}, 10);
}
handleInsertBold(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleSurround('**');
}
handleInsertItalic(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleSurround('*');
}
handleInsertCode(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleSurround('`');
}
handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleSurround('~~');
}
handleInsertList(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleInsert('-');
}
handleInsertQuote(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleInsert('>');
}
handleInsertHeader(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleInsert('#');
}
handleInsertSubscript(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleSurround('~');
}
handleInsertSuperscript(i: MarkdownTextArea, event: any) {
event.preventDefault();
i.simpleSurround('^');
}
simpleInsert(chars: string) {
if (!this.state.content) {
this.state.content = `${chars} `;
} else {
this.state.content += `\n${chars} `;
}
let textarea: any = document.getElementById(this.id);
textarea.focus();
setTimeout(() => {
autosize.update(textarea);
}, 10);
this.setState(this.state);
}
handleInsertSpoiler(i: MarkdownTextArea, event: any) {
event.preventDefault();
let beforeChars = `\n::: spoiler ${i18n.t('spoiler')}\n`;
let afterChars = '\n:::\n';
i.simpleSurroundBeforeAfter(beforeChars, afterChars);
}
quoteInsert() {
let textarea: any = document.getElementById(this.id);
let selectedText = window.getSelection().toString();
if (selectedText) {
let quotedText =
selectedText
.split('\n')
.map(t => `> ${t}`)
.join('\n') + '\n\n';
this.state.content = quotedText;
this.setState(this.state);
// Not sure why this needs a delay
setTimeout(() => autosize.update(textarea), 10);
}
}
}

@ -1,454 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Link } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
GetModlogForm,
GetModlogResponse,
ModRemovePost,
ModLockPost,
ModStickyPost,
ModRemoveComment,
ModRemoveCommunity,
ModBanFromCommunity,
ModBan,
ModAddCommunity,
ModAdd,
WebSocketJsonResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { wsJsonToRes, addTypeInfo, fetchLimit, toast } from '../utils';
import { MomentTime } from './moment-time';
import moment from 'moment';
import { i18n } from '../i18next';
interface ModlogState {
combined: Array<{
type_: string;
data:
| ModRemovePost
| ModLockPost
| ModStickyPost
| ModRemoveCommunity
| ModAdd
| ModBan;
}>;
communityId?: number;
communityName?: string;
page: number;
site: Site;
loading: boolean;
}
export class Modlog extends Component<any, ModlogState> {
private subscription: Subscription;
private emptyState: ModlogState = {
combined: [],
page: 1,
loading: true,
site: undefined,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.state.communityId = this.props.match.params.community_id
? Number(this.props.match.params.community_id)
: undefined;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
this.refetch();
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
setCombined(res: GetModlogResponse) {
let removed_posts = addTypeInfo(res.removed_posts, 'removed_posts');
let locked_posts = addTypeInfo(res.locked_posts, 'locked_posts');
let stickied_posts = addTypeInfo(res.stickied_posts, 'stickied_posts');
let removed_comments = addTypeInfo(
res.removed_comments,
'removed_comments'
);
let removed_communities = addTypeInfo(
res.removed_communities,
'removed_communities'
);
let banned_from_community = addTypeInfo(
res.banned_from_community,
'banned_from_community'
);
let added_to_community = addTypeInfo(
res.added_to_community,
'added_to_community'
);
let added = addTypeInfo(res.added, 'added');
let banned = addTypeInfo(res.banned, 'banned');
this.state.combined = [];
this.state.combined.push(...removed_posts);
this.state.combined.push(...locked_posts);
this.state.combined.push(...stickied_posts);
this.state.combined.push(...removed_comments);
this.state.combined.push(...removed_communities);
this.state.combined.push(...banned_from_community);
this.state.combined.push(...added_to_community);
this.state.combined.push(...added);
this.state.combined.push(...banned);
if (this.state.communityId && this.state.combined.length > 0) {
this.state.communityName = (this.state.combined[0]
.data as ModRemovePost).community_name;
}
// Sort them by time
this.state.combined.sort((a, b) =>
b.data.when_.localeCompare(a.data.when_)
);
this.setState(this.state);
}
combined() {
return (
<tbody>
{this.state.combined.map(i => (
<tr>
<td>
<MomentTime data={i.data} />
</td>
<td>
<Link to={`/u/${i.data.mod_user_name}`}>
{i.data.mod_user_name}
</Link>
</td>
<td>
{i.type_ == 'removed_posts' && (
<>
{(i.data as ModRemovePost).removed ? 'Removed' : 'Restored'}
<span>
{' '}
Post{' '}
<Link to={`/post/${(i.data as ModRemovePost).post_id}`}>
{(i.data as ModRemovePost).post_name}
</Link>
</span>
<div>
{(i.data as ModRemovePost).reason &&
` reason: ${(i.data as ModRemovePost).reason}`}
</div>
</>
)}
{i.type_ == 'locked_posts' && (
<>
{(i.data as ModLockPost).locked ? 'Locked' : 'Unlocked'}
<span>
{' '}
Post{' '}
<Link to={`/post/${(i.data as ModLockPost).post_id}`}>
{(i.data as ModLockPost).post_name}
</Link>
</span>
</>
)}
{i.type_ == 'stickied_posts' && (
<>
{(i.data as ModStickyPost).stickied
? 'Stickied'
: 'Unstickied'}
<span>
{' '}
Post{' '}
<Link to={`/post/${(i.data as ModStickyPost).post_id}`}>
{(i.data as ModStickyPost).post_name}
</Link>
</span>
</>
)}
{i.type_ == 'removed_comments' && (
<>
{(i.data as ModRemoveComment).removed
? 'Removed'
: 'Restored'}
<span>
{' '}
Comment{' '}
<Link
to={`/post/${
(i.data as ModRemoveComment).post_id
}/comment/${(i.data as ModRemoveComment).comment_id}`}
>
{(i.data as ModRemoveComment).comment_content}
</Link>
</span>
<span>
{' '}
by{' '}
<Link
to={`/u/${
(i.data as ModRemoveComment).comment_user_name
}`}
>
{(i.data as ModRemoveComment).comment_user_name}
</Link>
</span>
<div>
{(i.data as ModRemoveComment).reason &&
` reason: ${(i.data as ModRemoveComment).reason}`}
</div>
</>
)}
{i.type_ == 'removed_communities' && (
<>
{(i.data as ModRemoveCommunity).removed
? 'Removed'
: 'Restored'}
<span>
{' '}
Community{' '}
<Link
to={`/c/${(i.data as ModRemoveCommunity).community_name}`}
>
{(i.data as ModRemoveCommunity).community_name}
</Link>
</span>
<div>
{(i.data as ModRemoveCommunity).reason &&
` reason: ${(i.data as ModRemoveCommunity).reason}`}
</div>
<div>
{(i.data as ModRemoveCommunity).expires &&
` expires: ${moment
.utc((i.data as ModRemoveCommunity).expires)
.fromNow()}`}
</div>
</>
)}
{i.type_ == 'banned_from_community' && (
<>
<span>
{(i.data as ModBanFromCommunity).banned
? 'Banned '
: 'Unbanned '}{' '}
</span>
<span>
<Link
to={`/u/${
(i.data as ModBanFromCommunity).other_user_name
}`}
>
{(i.data as ModBanFromCommunity).other_user_name}
</Link>
</span>
<span> from the community </span>
<span>
<Link
to={`/c/${
(i.data as ModBanFromCommunity).community_name
}`}
>
{(i.data as ModBanFromCommunity).community_name}
</Link>
</span>
<div>
{(i.data as ModBanFromCommunity).reason &&
` reason: ${(i.data as ModBanFromCommunity).reason}`}
</div>
<div>
{(i.data as ModBanFromCommunity).expires &&
` expires: ${moment
.utc((i.data as ModBanFromCommunity).expires)
.fromNow()}`}
</div>
</>
)}
{i.type_ == 'added_to_community' && (
<>
<span>
{(i.data as ModAddCommunity).removed
? 'Removed '
: 'Appointed '}{' '}
</span>
<span>
<Link
to={`/u/${(i.data as ModAddCommunity).other_user_name}`}
>
{(i.data as ModAddCommunity).other_user_name}
</Link>
</span>
<span> as a mod to the community </span>
<span>
<Link
to={`/c/${(i.data as ModAddCommunity).community_name}`}
>
{(i.data as ModAddCommunity).community_name}
</Link>
</span>
</>
)}
{i.type_ == 'banned' && (
<>
<span>
{(i.data as ModBan).banned ? 'Banned ' : 'Unbanned '}{' '}
</span>
<span>
<Link to={`/u/${(i.data as ModBan).other_user_name}`}>
{(i.data as ModBan).other_user_name}
</Link>
</span>
<div>
{(i.data as ModBan).reason &&
` reason: ${(i.data as ModBan).reason}`}
</div>
<div>
{(i.data as ModBan).expires &&
` expires: ${moment
.utc((i.data as ModBan).expires)
.fromNow()}`}
</div>
</>
)}
{i.type_ == 'added' && (
<>
<span>
{(i.data as ModAdd).removed ? 'Removed ' : 'Appointed '}{' '}
</span>
<span>
<Link to={`/u/${(i.data as ModAdd).other_user_name}`}>
{(i.data as ModAdd).other_user_name}
</Link>
</span>
<span> as an admin </span>
</>
)}
</td>
</tr>
))}
</tbody>
);
}
get documentTitle(): string {
if (this.state.site) {
return `Modlog - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
{this.state.loading ? (
<h5 class="">
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div>
<h5>
{this.state.communityName && (
<Link
className="text-body"
to={`/c/${this.state.communityName}`}
>
/c/{this.state.communityName}{' '}
</Link>
)}
<span>{i18n.t('modlog')}</span>
</h5>
<div class="table-responsive">
<table id="modlog_table" class="table table-sm table-hover">
<thead class="pointer">
<tr>
<th> {i18n.t('time')}</th>
<th>{i18n.t('mod')}</th>
<th>{i18n.t('action')}</th>
</tr>
</thead>
{this.combined()}
</table>
{this.paginator()}
</div>
</div>
)}
</div>
);
}
paginator() {
return (
<div class="mt-2">
{this.state.page > 1 && (
<button
class="btn btn-secondary mr-1"
onClick={linkEvent(this, this.prevPage)}
>
{i18n.t('prev')}
</button>
)}
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.nextPage)}
>
{i18n.t('next')}
</button>
</div>
);
}
nextPage(i: Modlog) {
i.state.page++;
i.setState(i.state);
i.refetch();
}
prevPage(i: Modlog) {
i.state.page--;
i.setState(i.state);
i.refetch();
}
refetch() {
let modlogForm: GetModlogForm = {
community_id: this.state.communityId,
page: this.state.page,
limit: fetchLimit,
};
WebSocketService.Instance.getModlog(modlogForm);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op == UserOperation.GetModlog) {
let data = res.data as GetModlogResponse;
this.state.loading = false;
window.scrollTo(0, 0);
this.setCombined(data);
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

@ -1,58 +0,0 @@
import { Component } from 'inferno';
import moment from 'moment';
import { getMomentLanguage, capitalizeFirstLetter } from '../utils';
import { i18n } from '../i18next';
interface MomentTimeProps {
data: {
published?: string;
when_?: string;
updated?: string;
};
showAgo?: boolean;
}
export class MomentTime extends Component<MomentTimeProps, any> {
constructor(props: any, context: any) {
super(props, context);
let lang = getMomentLanguage();
moment.locale(lang);
}
render() {
if (this.props.data.updated) {
return (
<span
data-tippy-content={`${capitalizeFirstLetter(
i18n.t('modified')
)} ${this.format(this.props.data.updated)}`}
className="font-italics pointer unselectable"
>
<svg class="icon icon-inline mr-1">
<use xlinkHref="#icon-edit-2"></use>
</svg>
{moment.utc(this.props.data.updated).fromNow(!this.props.showAgo)}
</span>
);
} else {
let str = this.props.data.published || this.props.data.when_;
return (
<span
className="pointer unselectable"
data-tippy-content={this.format(str)}
>
{moment.utc(str).fromNow(!this.props.showAgo)}
</span>
);
}
}
format(input: string): string {
return moment
.utc(input)
.local()
.format('LLLL');
}
}

@ -1,544 +0,0 @@
import { Component, linkEvent, createRef, RefObject } from 'inferno';
import { Link } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import { WebSocketService, UserService } from '../services';
import {
UserOperation,
GetRepliesForm,
GetRepliesResponse,
GetUserMentionsForm,
GetUserMentionsResponse,
GetPrivateMessagesForm,
PrivateMessagesResponse,
SortType,
GetSiteResponse,
Comment,
CommentResponse,
PrivateMessage,
PrivateMessageResponse,
WebSocketJsonResponse,
} from 'lemmy-js-client';
import {
wsJsonToRes,
pictrsAvatarThumbnail,
showAvatars,
fetchLimit,
toast,
setTheme,
getLanguage,
notifyComment,
notifyPrivateMessage,
} from '../utils';
import { i18n } from '../i18next';
interface NavbarState {
isLoggedIn: boolean;
expanded: boolean;
replies: Array<Comment>;
mentions: Array<Comment>;
messages: Array<PrivateMessage>;
unreadCount: number;
searchParam: string;
toggleSearch: boolean;
siteLoading: boolean;
siteRes: GetSiteResponse;
onSiteBanner?(url: string): any;
}
export class Navbar extends Component<any, NavbarState> {
private wsSub: Subscription;
private userSub: Subscription;
private unreadCountSub: Subscription;
private searchTextField: RefObject<HTMLInputElement>;
emptyState: NavbarState = {
isLoggedIn: false,
unreadCount: 0,
replies: [],
mentions: [],
messages: [],
expanded: false,
siteRes: {
site: {
id: null,
name: null,
creator_id: null,
creator_name: null,
published: null,
number_of_users: null,
number_of_posts: null,
number_of_comments: null,
number_of_communities: null,
enable_downvotes: null,
open_registration: null,
enable_nsfw: null,
icon: null,
banner: null,
creator_preferred_username: null,
},
my_user: null,
admins: [],
banned: [],
online: null,
version: null,
federated_instances: null,
},
searchParam: '',
toggleSearch: false,
siteLoading: true,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.wsSub = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
this.searchTextField = createRef();
}
componentDidMount() {
// Subscribe to jwt changes
this.userSub = UserService.Instance.jwtSub.subscribe(res => {
// A login
if (res !== undefined) {
this.requestNotificationPermission();
} else {
this.state.isLoggedIn = false;
}
WebSocketService.Instance.getSite();
this.setState(this.state);
});
// Subscribe to unread count changes
this.unreadCountSub = UserService.Instance.unreadCountSub.subscribe(res => {
this.setState({ unreadCount: res });
});
}
handleSearchParam(i: Navbar, event: any) {
i.state.searchParam = event.target.value;
i.setState(i.state);
}
updateUrl() {
const searchParam = this.state.searchParam;
this.setState({ searchParam: '' });
this.setState({ toggleSearch: false });
if (searchParam === '') {
this.context.router.history.push(`/search/`);
} else {
this.context.router.history.push(
`/search/q/${searchParam}/type/All/sort/TopAll/page/1`
);
}
}
handleSearchSubmit(i: Navbar, event: any) {
event.preventDefault();
i.updateUrl();
}
handleSearchBtn(i: Navbar, event: any) {
event.preventDefault();
i.setState({ toggleSearch: true });
i.searchTextField.current.focus();
const offsetWidth = i.searchTextField.current.offsetWidth;
if (i.state.searchParam && offsetWidth > 100) {
i.updateUrl();
}
}
handleSearchBlur(i: Navbar, event: any) {
if (!(event.relatedTarget && event.relatedTarget.name !== 'search-btn')) {
i.state.toggleSearch = false;
i.setState(i.state);
}
}
render() {
return this.navbar();
}
componentWillUnmount() {
this.wsSub.unsubscribe();
this.userSub.unsubscribe();
this.unreadCountSub.unsubscribe();
}
// TODO class active corresponding to current page
navbar() {
let user = UserService.Instance.user;
return (
<nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
<div class="container">
{!this.state.siteLoading ? (
<Link
title={this.state.siteRes.version}
class="d-flex align-items-center navbar-brand mr-md-3"
to="/"
>
{this.state.siteRes.site.icon && showAvatars() && (
<img
src={pictrsAvatarThumbnail(this.state.siteRes.site.icon)}
height="32"
width="32"
class="rounded-circle mr-2"
/>
)}
{this.state.siteRes.site.name}
</Link>
) : (
<div class="navbar-item">
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</div>
)}
{this.state.isLoggedIn && (
<Link
class="ml-auto p-0 navbar-toggler nav-link border-0"
to="/inbox"
title={i18n.t('inbox')}
>
<svg class="icon">
<use xlinkHref="#icon-bell"></use>
</svg>
{this.state.unreadCount > 0 && (
<span class="mx-1 badge badge-light">
{this.state.unreadCount}
</span>
)}
</Link>
)}
<button
class="navbar-toggler border-0 p-1"
type="button"
aria-label="menu"
onClick={linkEvent(this, this.expandNavbar)}
data-tippy-content={i18n.t('expand_here')}
>
<span class="navbar-toggler-icon"></span>
</button>
{!this.state.siteLoading && (
<div
className={`${
!this.state.expanded && 'collapse'
} navbar-collapse`}
>
<ul class="navbar-nav my-2 mr-auto">
<li class="nav-item">
<Link
class="nav-link"
to="/communities"
title={i18n.t('communities')}
>
{i18n.t('communities')}
</Link>
</li>
<li class="nav-item">
<Link
class="nav-link"
to={{
pathname: '/create_post',
state: { prevPath: this.currentLocation },
}}
title={i18n.t('create_post')}
>
{i18n.t('create_post')}
</Link>
</li>
<li class="nav-item">
<Link
class="nav-link"
to="/create_community"
title={i18n.t('create_community')}
>
{i18n.t('create_community')}
</Link>
</li>
<li className="nav-item">
<Link
class="nav-link"
to="/sponsors"
title={i18n.t('donate_to_lemmy')}
>
<svg class="icon">
<use xlinkHref="#icon-coffee"></use>
</svg>
</Link>
</li>
</ul>
<ul class="navbar-nav my-2">
{this.canAdmin && (
<li className="nav-item">
<Link
class="nav-link"
to={`/admin`}
title={i18n.t('admin_settings')}
>
<svg class="icon">
<use xlinkHref="#icon-settings"></use>
</svg>
</Link>
</li>
)}
</ul>
{!this.context.router.history.location.pathname.match(
/^\/search/
) && (
<form
class="form-inline"
onSubmit={linkEvent(this, this.handleSearchSubmit)}
>
<input
class={`form-control mr-0 search-input ${
this.state.toggleSearch ? 'show-input' : 'hide-input'
}`}
onInput={linkEvent(this, this.handleSearchParam)}
value={this.state.searchParam}
ref={this.searchTextField}
type="text"
placeholder={i18n.t('search')}
onBlur={linkEvent(this, this.handleSearchBlur)}
></input>
<button
name="search-btn"
onClick={linkEvent(this, this.handleSearchBtn)}
class="px-1 btn btn-link"
style="color: var(--gray)"
>
<svg class="icon">
<use xlinkHref="#icon-search"></use>
</svg>
</button>
</form>
)}
{this.state.isLoggedIn ? (
<>
<ul class="navbar-nav my-2">
<li className="nav-item">
<Link
class="nav-link"
to="/inbox"
title={i18n.t('inbox')}
>
<svg class="icon">
<use xlinkHref="#icon-bell"></use>
</svg>
{this.state.unreadCount > 0 && (
<span class="ml-1 badge badge-light">
{this.state.unreadCount}
</span>
)}
</Link>
</li>
</ul>
<ul class="navbar-nav">
<li className="nav-item">
<Link
class="nav-link"
to={`/u/${user.name}`}
title={i18n.t('settings')}
>
<span>
{user.avatar && showAvatars() && (
<img
src={pictrsAvatarThumbnail(user.avatar)}
height="32"
width="32"
class="rounded-circle mr-2"
/>
)}
{user.preferred_username
? user.preferred_username
: user.name}
</span>
</Link>
</li>
</ul>
</>
) : (
<ul class="navbar-nav my-2">
<li className="ml-2 nav-item">
<Link
class="btn btn-success"
to="/login"
title={i18n.t('login_sign_up')}
>
{i18n.t('login_sign_up')}
</Link>
</li>
</ul>
)}
</div>
)}
</div>
</nav>
);
}
expandNavbar(i: Navbar) {
i.state.expanded = !i.state.expanded;
i.setState(i.state);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (msg.error) {
if (msg.error == 'not_logged_in') {
UserService.Instance.logout();
location.reload();
}
return;
} else if (msg.reconnect) {
this.fetchUnreads();
} else if (res.op == UserOperation.GetReplies) {
let data = res.data as GetRepliesResponse;
let unreadReplies = data.replies.filter(r => !r.read);
this.state.replies = unreadReplies;
this.state.unreadCount = this.calculateUnreadCount();
this.setState(this.state);
this.sendUnreadCount();
} else if (res.op == UserOperation.GetUserMentions) {
let data = res.data as GetUserMentionsResponse;
let unreadMentions = data.mentions.filter(r => !r.read);
this.state.mentions = unreadMentions;
this.state.unreadCount = this.calculateUnreadCount();
this.setState(this.state);
this.sendUnreadCount();
} else if (res.op == UserOperation.GetPrivateMessages) {
let data = res.data as PrivateMessagesResponse;
let unreadMessages = data.messages.filter(r => !r.read);
this.state.messages = unreadMessages;
this.state.unreadCount = this.calculateUnreadCount();
this.setState(this.state);
this.sendUnreadCount();
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
if (this.state.isLoggedIn) {
if (data.recipient_ids.includes(UserService.Instance.user.id)) {
this.state.replies.push(data.comment);
this.state.unreadCount++;
this.setState(this.state);
this.sendUnreadCount();
notifyComment(data.comment, this.context.router);
}
}
} else if (res.op == UserOperation.CreatePrivateMessage) {
let data = res.data as PrivateMessageResponse;
if (this.state.isLoggedIn) {
if (data.message.recipient_id == UserService.Instance.user.id) {
this.state.messages.push(data.message);
this.state.unreadCount++;
this.setState(this.state);
this.sendUnreadCount();
notifyPrivateMessage(data.message, this.context.router);
}
}
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.siteRes = data;
// The login
if (data.my_user) {
UserService.Instance.user = data.my_user;
WebSocketService.Instance.userJoin();
// On the first load, check the unreads
if (this.state.isLoggedIn == false) {
this.requestNotificationPermission();
this.fetchUnreads();
setTheme(data.my_user.theme, true);
i18n.changeLanguage(getLanguage());
}
this.state.isLoggedIn = true;
}
this.state.siteLoading = false;
this.setState(this.state);
}
}
fetchUnreads() {
console.log('Fetching unreads...');
let repliesForm: GetRepliesForm = {
sort: SortType.New,
unread_only: true,
page: 1,
limit: fetchLimit,
};
let userMentionsForm: GetUserMentionsForm = {
sort: SortType.New,
unread_only: true,
page: 1,
limit: fetchLimit,
};
let privateMessagesForm: GetPrivateMessagesForm = {
unread_only: true,
page: 1,
limit: fetchLimit,
};
if (this.currentLocation !== '/inbox') {
WebSocketService.Instance.getReplies(repliesForm);
WebSocketService.Instance.getUserMentions(userMentionsForm);
WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
}
}
get currentLocation() {
return this.context.router.history.location.pathname;
}
sendUnreadCount() {
UserService.Instance.unreadCountSub.next(this.state.unreadCount);
}
calculateUnreadCount(): number {
return (
this.state.replies.filter(r => !r.read).length +
this.state.mentions.filter(r => !r.read).length +
this.state.messages.filter(r => !r.read).length
);
}
get canAdmin(): boolean {
return (
UserService.Instance.user &&
this.state.siteRes.admins
.map(a => a.id)
.includes(UserService.Instance.user.id)
);
}
requestNotificationPermission() {
if (UserService.Instance.user) {
document.addEventListener('DOMContentLoaded', function () {
if (!Notification) {
toast(i18n.t('notifications_error'), 'danger');
return;
}
if (Notification.permission !== 'granted')
Notification.requestPermission();
});
}
}
}

@ -1,162 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
LoginResponse,
PasswordChangeForm,
WebSocketJsonResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { WebSocketService, UserService } from '../services';
import { wsJsonToRes, capitalizeFirstLetter, toast } from '../utils';
import { i18n } from '../i18next';
interface State {
passwordChangeForm: PasswordChangeForm;
loading: boolean;
site: Site;
}
export class PasswordChange extends Component<any, State> {
private subscription: Subscription;
emptyState: State = {
passwordChangeForm: {
token: this.props.match.params.token,
password: undefined,
password_verify: undefined,
},
loading: false,
site: undefined,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.site) {
return `${i18n.t('password_change')} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<div class="row">
<div class="col-12 col-lg-6 offset-lg-3 mb-4">
<h5>{i18n.t('password_change')}</h5>
{this.passwordChangeForm()}
</div>
</div>
</div>
);
}
passwordChangeForm() {
return (
<form onSubmit={linkEvent(this, this.handlePasswordChangeSubmit)}>
<div class="form-group row">
<label class="col-sm-2 col-form-label">
{i18n.t('new_password')}
</label>
<div class="col-sm-10">
<input
type="password"
value={this.state.passwordChangeForm.password}
onInput={linkEvent(this, this.handlePasswordChange)}
class="form-control"
required
/>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">
{i18n.t('verify_password')}
</label>
<div class="col-sm-10">
<input
type="password"
value={this.state.passwordChangeForm.password_verify}
onInput={linkEvent(this, this.handleVerifyPasswordChange)}
class="form-control"
required
/>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-secondary">
{this.state.loading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
capitalizeFirstLetter(i18n.t('save'))
)}
</button>
</div>
</div>
</form>
);
}
handlePasswordChange(i: PasswordChange, event: any) {
i.state.passwordChangeForm.password = event.target.value;
i.setState(i.state);
}
handleVerifyPasswordChange(i: PasswordChange, event: any) {
i.state.passwordChangeForm.password_verify = event.target.value;
i.setState(i.state);
}
handlePasswordChangeSubmit(i: PasswordChange, event: any) {
event.preventDefault();
i.state.loading = true;
i.setState(i.state);
WebSocketService.Instance.passwordChange(i.state.passwordChangeForm);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.state.loading = false;
this.setState(this.state);
return;
} else if (res.op == UserOperation.PasswordChange) {
let data = res.data as LoginResponse;
this.state = this.emptyState;
this.setState(this.state);
UserService.Instance.login(data);
this.props.history.push('/');
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

@ -1,622 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Prompt } from 'inferno-router';
import { PostListings } from './post-listings';
import { MarkdownTextArea } from './markdown-textarea';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
PostForm as PostFormI,
PostFormParams,
Post,
PostResponse,
UserOperation,
Community,
ListCommunitiesResponse,
ListCommunitiesForm,
SortType,
SearchForm,
SearchType,
SearchResponse,
WebSocketJsonResponse,
} from 'lemmy-js-client';
import { WebSocketService, UserService } from '../services';
import {
wsJsonToRes,
getPageTitle,
validURL,
capitalizeFirstLetter,
archiveUrl,
debounce,
isImage,
toast,
randomStr,
setupTippy,
hostname,
pictrsDeleteToast,
validTitle,
} from '../utils';
import Choices from 'choices.js';
import { i18n } from '../i18next';
const MAX_POST_TITLE_LENGTH = 200;
interface PostFormProps {
post?: Post; // If a post is given, that means this is an edit
params?: PostFormParams;
onCancel?(): any;
onCreate?(id: number): any;
onEdit?(post: Post): any;
enableNsfw: boolean;
enableDownvotes: boolean;
}
interface PostFormState {
postForm: PostFormI;
communities: Array<Community>;
loading: boolean;
imageLoading: boolean;
previewMode: boolean;
suggestedTitle: string;
suggestedPosts: Array<Post>;
crossPosts: Array<Post>;
}
export class PostForm extends Component<PostFormProps, PostFormState> {
private id = `post-form-${randomStr()}`;
private subscription: Subscription;
private choices: Choices;
private emptyState: PostFormState = {
postForm: {
name: null,
nsfw: false,
auth: null,
community_id: null,
},
communities: [],
loading: false,
imageLoading: false,
previewMode: false,
suggestedTitle: undefined,
suggestedPosts: [],
crossPosts: [],
};
constructor(props: any, context: any) {
super(props, context);
this.fetchSimilarPosts = debounce(this.fetchSimilarPosts).bind(this);
this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
this.state = this.emptyState;
if (this.props.post) {
this.state.postForm = {
body: this.props.post.body,
// NOTE: debouncing breaks both these for some reason, unless you use defaultValue
name: this.props.post.name,
community_id: this.props.post.community_id,
edit_id: this.props.post.id,
url: this.props.post.url,
nsfw: this.props.post.nsfw,
auth: null,
};
}
if (this.props.params) {
this.state.postForm.name = this.props.params.name;
if (this.props.params.url) {
this.state.postForm.url = this.props.params.url;
}
if (this.props.params.body) {
this.state.postForm.body = this.props.params.body;
}
}
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
let listCommunitiesForm: ListCommunitiesForm = {
sort: SortType.TopAll,
limit: 9999,
};
WebSocketService.Instance.listCommunities(listCommunitiesForm);
}
componentDidMount() {
setupTippy();
}
componentDidUpdate() {
if (
!this.state.loading &&
(this.state.postForm.name ||
this.state.postForm.url ||
this.state.postForm.body)
) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = undefined;
}
}
componentWillUnmount() {
this.subscription.unsubscribe();
/* this.choices && this.choices.destroy(); */
window.onbeforeunload = null;
}
render() {
return (
<div>
<Prompt
when={
!this.state.loading &&
(this.state.postForm.name ||
this.state.postForm.url ||
this.state.postForm.body)
}
message={i18n.t('block_leaving')}
/>
<form onSubmit={linkEvent(this, this.handlePostSubmit)}>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="post-url">
{i18n.t('url')}
</label>
<div class="col-sm-10">
<input
type="url"
id="post-url"
class="form-control"
value={this.state.postForm.url}
onInput={linkEvent(this, this.handlePostUrlChange)}
onPaste={linkEvent(this, this.handleImageUploadPaste)}
/>
{this.state.suggestedTitle && (
<div
class="mt-1 text-muted small font-weight-bold pointer"
onClick={linkEvent(this, this.copySuggestedTitle)}
>
{i18n.t('copy_suggested_title', {
title: this.state.suggestedTitle,
})}
</div>
)}
<form>
<label
htmlFor="file-upload"
className={`${
UserService.Instance.user && 'pointer'
} d-inline-block float-right text-muted font-weight-bold`}
data-tippy-content={i18n.t('upload_image')}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-image"></use>
</svg>
</label>
<input
id="file-upload"
type="file"
accept="image/*,video/*"
name="file"
class="d-none"
disabled={!UserService.Instance.user}
onChange={linkEvent(this, this.handleImageUpload)}
/>
</form>
{validURL(this.state.postForm.url) && (
<a
href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
this.state.postForm.url
)}`}
target="_blank"
class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
rel="noopener"
>
{i18n.t('archive_link')}
</a>
)}
{this.state.imageLoading && (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
)}
{isImage(this.state.postForm.url) && (
<img src={this.state.postForm.url} class="img-fluid" />
)}
{this.state.crossPosts.length > 0 && (
<>
<div class="my-1 text-muted small font-weight-bold">
{i18n.t('cross_posts')}
</div>
<PostListings
showCommunity
posts={this.state.crossPosts}
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
/>
</>
)}
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="post-title">
{i18n.t('title')}
</label>
<div class="col-sm-10">
<textarea
value={this.state.postForm.name}
id="post-title"
onInput={linkEvent(this, this.handlePostNameChange)}
class={`form-control ${
!validTitle(this.state.postForm.name) && 'is-invalid'
}`}
required
rows={2}
minLength={3}
maxLength={MAX_POST_TITLE_LENGTH}
/>
{!validTitle(this.state.postForm.name) && (
<div class="invalid-feedback">
{i18n.t('invalid_post_title')}
</div>
)}
{this.state.suggestedPosts.length > 0 && (
<>
<div class="my-1 text-muted small font-weight-bold">
{i18n.t('related_posts')}
</div>
<PostListings
posts={this.state.suggestedPosts}
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
/>
</>
)}
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor={this.id}>
{i18n.t('body')}
</label>
<div class="col-sm-10">
<MarkdownTextArea
initialContent={this.state.postForm.body}
onContentChange={this.handlePostBodyChange}
/>
</div>
</div>
{!this.props.post && (
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="post-community">
{i18n.t('community')}
</label>
<div class="col-sm-10">
<select
class="form-control"
id="post-community"
value={this.state.postForm.community_id}
onInput={linkEvent(this, this.handlePostCommunityChange)}
>
<option>{i18n.t('select_a_community')}</option>
{this.state.communities.map(community => (
<option value={community.id}>
{community.local
? community.name
: `${hostname(community.actor_id)}/${community.name}`}
</option>
))}
</select>
</div>
</div>
)}
{this.props.enableNsfw && (
<div class="form-group row">
<div class="col-sm-10">
<div class="form-check">
<input
class="form-check-input"
id="post-nsfw"
type="checkbox"
checked={this.state.postForm.nsfw}
onChange={linkEvent(this, this.handlePostNsfwChange)}
/>
<label class="form-check-label" htmlFor="post-nsfw">
{i18n.t('nsfw')}
</label>
</div>
</div>
</div>
)}
<div class="form-group row">
<div class="col-sm-10">
<button
disabled={
!this.state.postForm.community_id || this.state.loading
}
type="submit"
class="btn btn-secondary mr-2"
>
{this.state.loading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : this.props.post ? (
capitalizeFirstLetter(i18n.t('save'))
) : (
capitalizeFirstLetter(i18n.t('create'))
)}
</button>
{this.props.post && (
<button
type="button"
class="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)}
>
{i18n.t('cancel')}
</button>
)}
</div>
</div>
</form>
</div>
);
}
handlePostSubmit(i: PostForm, event: any) {
event.preventDefault();
// Coerce empty url string to undefined
if (i.state.postForm.url && i.state.postForm.url === '') {
i.state.postForm.url = undefined;
}
if (i.props.post) {
WebSocketService.Instance.editPost(i.state.postForm);
} else {
WebSocketService.Instance.createPost(i.state.postForm);
}
i.state.loading = true;
i.setState(i.state);
}
copySuggestedTitle(i: PostForm) {
i.state.postForm.name = i.state.suggestedTitle.substring(
0,
MAX_POST_TITLE_LENGTH
);
i.state.suggestedTitle = undefined;
i.setState(i.state);
}
handlePostUrlChange(i: PostForm, event: any) {
i.state.postForm.url = event.target.value;
i.setState(i.state);
i.fetchPageTitle();
}
fetchPageTitle() {
if (validURL(this.state.postForm.url)) {
let form: SearchForm = {
q: this.state.postForm.url,
type_: SearchType.Url,
sort: SortType.TopAll,
page: 1,
limit: 6,
};
WebSocketService.Instance.search(form);
// Fetch the page title
getPageTitle(this.state.postForm.url).then(d => {
this.state.suggestedTitle = d;
this.setState(this.state);
});
} else {
this.state.suggestedTitle = undefined;
this.state.crossPosts = [];
}
}
handlePostNameChange(i: PostForm, event: any) {
i.state.postForm.name = event.target.value;
i.setState(i.state);
i.fetchSimilarPosts();
}
fetchSimilarPosts() {
let form: SearchForm = {
q: this.state.postForm.name,
type_: SearchType.Posts,
sort: SortType.TopAll,
community_id: this.state.postForm.community_id,
page: 1,
limit: 6,
};
if (this.state.postForm.name !== '') {
WebSocketService.Instance.search(form);
} else {
this.state.suggestedPosts = [];
}
this.setState(this.state);
}
handlePostBodyChange(val: string) {
this.state.postForm.body = val;
this.setState(this.state);
}
handlePostCommunityChange(i: PostForm, event: any) {
i.state.postForm.community_id = Number(event.target.value);
i.setState(i.state);
}
handlePostNsfwChange(i: PostForm, event: any) {
i.state.postForm.nsfw = event.target.checked;
i.setState(i.state);
}
handleCancel(i: PostForm) {
i.props.onCancel();
}
handlePreviewToggle(i: PostForm, event: any) {
event.preventDefault();
i.state.previewMode = !i.state.previewMode;
i.setState(i.state);
}
handleImageUploadPaste(i: PostForm, event: any) {
let image = event.clipboardData.files[0];
if (image) {
i.handleImageUpload(i, image);
}
}
handleImageUpload(i: PostForm, event: any) {
let file: any;
if (event.target) {
event.preventDefault();
file = event.target.files[0];
} else {
file = event;
}
const imageUploadUrl = `/pictrs/image`;
const formData = new FormData();
formData.append('images[]', file);
i.state.imageLoading = true;
i.setState(i.state);
fetch(imageUploadUrl, {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(res => {
console.log('pictrs upload:');
console.log(res);
if (res.msg == 'ok') {
let hash = res.files[0].file;
let url = `${window.location.origin}/pictrs/image/${hash}`;
let deleteToken = res.files[0].delete_token;
let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
i.state.postForm.url = url;
i.state.imageLoading = false;
i.setState(i.state);
pictrsDeleteToast(
i18n.t('click_to_delete_picture'),
i18n.t('picture_deleted'),
deleteUrl
);
} else {
i.state.imageLoading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
}
})
.catch(error => {
i.state.imageLoading = false;
i.setState(i.state);
toast(error, 'danger');
});
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.state.loading = false;
this.setState(this.state);
return;
} else if (res.op == UserOperation.ListCommunities) {
let data = res.data as ListCommunitiesResponse;
this.state.communities = data.communities;
if (this.props.post) {
this.state.postForm.community_id = this.props.post.community_id;
} else if (this.props.params && this.props.params.community) {
let foundCommunityId = data.communities.find(
r => r.name == this.props.params.community
).id;
this.state.postForm.community_id = foundCommunityId;
} else {
// By default, the null valued 'Select a Community'
}
this.setState(this.state);
// Set up select searching
let selectId: any = document.getElementById('post-community');
if (selectId) {
this.choices = new Choices(selectId, {
shouldSort: false,
classNames: {
containerOuter: 'choices',
containerInner: 'choices__inner bg-secondary border-0',
input: 'form-control',
inputCloned: 'choices__input--cloned',
list: 'choices__list',
listItems: 'choices__list--multiple',
listSingle: 'choices__list--single',
listDropdown: 'choices__list--dropdown',
item: 'choices__item bg-secondary',
itemSelectable: 'choices__item--selectable',
itemDisabled: 'choices__item--disabled',
itemChoice: 'choices__item--choice',
placeholder: 'choices__placeholder',
group: 'choices__group',
groupHeading: 'choices__heading',
button: 'choices__button',
activeState: 'is-active',
focusState: 'is-focused',
openState: 'is-open',
disabledState: 'is-disabled',
highlightedState: 'text-info',
selectedState: 'text-info',
flippedState: 'is-flipped',
loadingState: 'is-loading',
noResults: 'has-no-results',
noChoices: 'has-no-choices',
},
});
this.choices.passedElement.element.addEventListener(
'choice',
(e: any) => {
this.state.postForm.community_id = Number(e.detail.choice.value);
this.setState(this.state);
},
false
);
}
} else if (res.op == UserOperation.CreatePost) {
let data = res.data as PostResponse;
if (data.post.creator_id == UserService.Instance.user.id) {
this.state.loading = false;
this.props.onCreate(data.post.id);
}
} else if (res.op == UserOperation.EditPost) {
let data = res.data as PostResponse;
if (data.post.creator_id == UserService.Instance.user.id) {
this.state.loading = false;
this.props.onEdit(data.post);
}
} else if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;
if (data.type_ == SearchType[SearchType.Posts]) {
this.state.suggestedPosts = data.posts;
} else if (data.type_ == SearchType[SearchType.Url]) {
this.state.crossPosts = data.posts;
}
this.setState(this.state);
}
}
}

File diff suppressed because it is too large Load Diff

@ -1,115 +0,0 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { Post, SortType } from 'lemmy-js-client';
import { postSort } from '../utils';
import { PostListing } from './post-listing';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
interface PostListingsProps {
posts: Array<Post>;
showCommunity?: boolean;
removeDuplicates?: boolean;
sort?: SortType;
enableDownvotes: boolean;
enableNsfw: boolean;
}
export class PostListings extends Component<PostListingsProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<div>
{this.props.posts.length > 0 ? (
this.outer().map(post => (
<>
<PostListing
post={post}
showCommunity={this.props.showCommunity}
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
/>
<hr class="my-3" />
</>
))
) : (
<>
<div>{i18n.t('no_posts')}</div>
{this.props.showCommunity !== undefined && (
<T i18nKey="subscribe_to_communities">
#<Link to="/communities">#</Link>
</T>
)}
</>
)}
</div>
);
}
outer(): Array<Post> {
let out = this.props.posts;
if (this.props.removeDuplicates) {
out = this.removeDuplicates(out);
}
if (this.props.sort !== undefined) {
postSort(out, this.props.sort, this.props.showCommunity == undefined);
}
return out;
}
removeDuplicates(posts: Array<Post>): Array<Post> {
// A map from post url to list of posts (dupes)
let urlMap = new Map<string, Array<Post>>();
// Loop over the posts, find ones with same urls
for (let post of posts) {
if (
post.url &&
!post.deleted &&
!post.removed &&
!post.community_deleted &&
!post.community_removed
) {
if (!urlMap.get(post.url)) {
urlMap.set(post.url, [post]);
} else {
urlMap.get(post.url).push(post);
}
}
}
// Sort by oldest
// Remove the ones that have no length
for (let e of urlMap.entries()) {
if (e[1].length == 1) {
urlMap.delete(e[0]);
} else {
e[1].sort((a, b) => a.published.localeCompare(b.published));
}
}
for (let i = 0; i < posts.length; i++) {
let post = posts[i];
if (post.url) {
let found = urlMap.get(post.url);
if (found) {
// If its the oldest, add
if (post.id == found[0].id) {
post.duplicates = found.slice(1);
}
// Otherwise, delete it
else {
posts.splice(i--, 1);
}
}
}
}
return posts;
}
}

@ -1,561 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
Community,
Post as PostI,
GetPostResponse,
PostResponse,
Comment,
MarkCommentAsReadForm,
CommentResponse,
CommunityUser,
CommunityResponse,
CommentNode as CommentNodeI,
BanFromCommunityResponse,
BanUserResponse,
AddModToCommunityResponse,
AddAdminResponse,
SearchType,
SortType,
SearchForm,
GetPostForm,
SearchResponse,
GetSiteResponse,
GetCommunityResponse,
WebSocketJsonResponse,
} from 'lemmy-js-client';
import { CommentSortType, CommentViewType } from '../interfaces';
import { WebSocketService, UserService } from '../services';
import {
wsJsonToRes,
toast,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
} from '../utils';
import { PostListing } from './post-listing';
import { Sidebar } from './sidebar';
import { CommentForm } from './comment-form';
import { CommentNodes } from './comment-nodes';
import autosize from 'autosize';
import { i18n } from '../i18next';
interface PostState {
post: PostI;
comments: Array<Comment>;
commentSort: CommentSortType;
commentViewType: CommentViewType;
community: Community;
moderators: Array<CommunityUser>;
online: number;
scrolled?: boolean;
scrolled_comment_id?: number;
loading: boolean;
crossPosts: Array<PostI>;
siteRes: GetSiteResponse;
}
export class Post extends Component<any, PostState> {
private subscription: Subscription;
private emptyState: PostState = {
post: null,
comments: [],
commentSort: CommentSortType.Hot,
commentViewType: CommentViewType.Tree,
community: null,
moderators: [],
online: null,
scrolled: false,
loading: true,
crossPosts: [],
siteRes: {
admins: [],
banned: [],
site: {
id: undefined,
name: undefined,
creator_id: undefined,
published: undefined,
creator_name: undefined,
number_of_users: undefined,
number_of_posts: undefined,
number_of_comments: undefined,
number_of_communities: undefined,
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
icon: undefined,
banner: undefined,
},
online: null,
version: null,
federated_instances: undefined,
},
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
let postId = Number(this.props.match.params.id);
if (this.props.match.params.comment_id) {
this.state.scrolled_comment_id = this.props.match.params.comment_id;
}
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
let form: GetPostForm = {
id: postId,
};
WebSocketService.Instance.getPost(form);
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
componentDidMount() {
autosize(document.querySelectorAll('textarea'));
}
componentDidUpdate(_lastProps: any, lastState: PostState, _snapshot: any) {
if (
this.state.scrolled_comment_id &&
!this.state.scrolled &&
lastState.comments.length > 0
) {
var elmnt = document.getElementById(
`comment-${this.state.scrolled_comment_id}`
);
elmnt.scrollIntoView();
elmnt.classList.add('mark');
this.state.scrolled = true;
this.markScrolledAsRead(this.state.scrolled_comment_id);
}
// Necessary if you are on a post and you click another post (same route)
if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
// Couldnt get a refresh working. This does for now.
location.reload();
// let currentId = this.props.match.params.id;
// WebSocketService.Instance.getPost(currentId);
// this.context.router.history.push('/sponsors');
// this.context.refresh();
// this.context.router.history.push(_lastProps.location.pathname);
}
}
markScrolledAsRead(commentId: number) {
let found = this.state.comments.find(c => c.id == commentId);
let parent = this.state.comments.find(c => found.parent_id == c.id);
let parent_user_id = parent
? parent.creator_id
: this.state.post.creator_id;
if (
UserService.Instance.user &&
UserService.Instance.user.id == parent_user_id
) {
let form: MarkCommentAsReadForm = {
edit_id: found.id,
read: true,
auth: null,
};
WebSocketService.Instance.markCommentAsRead(form);
UserService.Instance.unreadCountSub.next(
UserService.Instance.unreadCountSub.value - 1
);
}
}
get documentTitle(): string {
if (this.state.post) {
return `${this.state.post.name} - ${this.state.siteRes.site.name}`;
} else {
return 'Lemmy';
}
}
get favIcon(): string {
return this.state.siteRes.site.icon
? this.state.siteRes.site.icon
: favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div class="row">
<div class="col-12 col-md-8 mb-3">
<PostListing
post={this.state.post}
showBody
showCommunity
moderators={this.state.moderators}
admins={this.state.siteRes.admins}
enableDownvotes={this.state.siteRes.site.enable_downvotes}
enableNsfw={this.state.siteRes.site.enable_nsfw}
/>
<div className="mb-2" />
<CommentForm
postId={this.state.post.id}
disabled={this.state.post.locked}
/>
{this.state.comments.length > 0 && this.sortRadios()}
{this.state.commentViewType == CommentViewType.Tree &&
this.commentsTree()}
{this.state.commentViewType == CommentViewType.Chat &&
this.commentsFlat()}
</div>
<div class="col-12 col-sm-12 col-md-4">{this.sidebar()}</div>
</div>
)}
</div>
);
}
sortRadios() {
return (
<>
<div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
<label
className={`btn btn-outline-secondary pointer ${
this.state.commentSort === CommentSortType.Hot && 'active'
}`}
>
{i18n.t('hot')}
<input
type="radio"
value={CommentSortType.Hot}
checked={this.state.commentSort === CommentSortType.Hot}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
</label>
<label
className={`btn btn-outline-secondary pointer ${
this.state.commentSort === CommentSortType.Top && 'active'
}`}
>
{i18n.t('top')}
<input
type="radio"
value={CommentSortType.Top}
checked={this.state.commentSort === CommentSortType.Top}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
</label>
<label
className={`btn btn-outline-secondary pointer ${
this.state.commentSort === CommentSortType.New && 'active'
}`}
>
{i18n.t('new')}
<input
type="radio"
value={CommentSortType.New}
checked={this.state.commentSort === CommentSortType.New}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
</label>
<label
className={`btn btn-outline-secondary pointer ${
this.state.commentSort === CommentSortType.Old && 'active'
}`}
>
{i18n.t('old')}
<input
type="radio"
value={CommentSortType.Old}
checked={this.state.commentSort === CommentSortType.Old}
onChange={linkEvent(this, this.handleCommentSortChange)}
/>
</label>
</div>
<div class="btn-group btn-group-toggle flex-wrap mb-2">
<label
className={`btn btn-outline-secondary pointer ${
this.state.commentViewType === CommentViewType.Chat && 'active'
}`}
>
{i18n.t('chat')}
<input
type="radio"
value={CommentViewType.Chat}
checked={this.state.commentViewType === CommentViewType.Chat}
onChange={linkEvent(this, this.handleCommentViewTypeChange)}
/>
</label>
</div>
</>
);
}
commentsFlat() {
return (
<div>
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
noIndent
locked={this.state.post.locked}
moderators={this.state.moderators}
admins={this.state.siteRes.admins}
postCreatorId={this.state.post.creator_id}
showContext
enableDownvotes={this.state.siteRes.site.enable_downvotes}
sort={this.state.commentSort}
/>
</div>
);
}
sidebar() {
return (
<div class="mb-3">
<Sidebar
community={this.state.community}
moderators={this.state.moderators}
admins={this.state.siteRes.admins}
online={this.state.online}
enableNsfw={this.state.siteRes.site.enable_nsfw}
showIcon
/>
</div>
);
}
handleCommentSortChange(i: Post, event: any) {
i.state.commentSort = Number(event.target.value);
i.state.commentViewType = CommentViewType.Tree;
i.setState(i.state);
}
handleCommentViewTypeChange(i: Post, event: any) {
i.state.commentViewType = Number(event.target.value);
i.state.commentSort = CommentSortType.New;
i.setState(i.state);
}
buildCommentsTree(): Array<CommentNodeI> {
let map = new Map<number, CommentNodeI>();
for (let comment of this.state.comments) {
let node: CommentNodeI = {
comment: comment,
children: [],
};
map.set(comment.id, { ...node });
}
let tree: Array<CommentNodeI> = [];
for (let comment of this.state.comments) {
let child = map.get(comment.id);
if (comment.parent_id) {
let parent_ = map.get(comment.parent_id);
parent_.children.push(child);
} else {
tree.push(child);
}
this.setDepth(child);
}
return tree;
}
setDepth(node: CommentNodeI, i: number = 0): void {
for (let child of node.children) {
child.comment.depth = i;
this.setDepth(child, i + 1);
}
}
commentsTree() {
let nodes = this.buildCommentsTree();
return (
<div>
<CommentNodes
nodes={nodes}
locked={this.state.post.locked}
moderators={this.state.moderators}
admins={this.state.siteRes.admins}
postCreatorId={this.state.post.creator_id}
sort={this.state.commentSort}
enableDownvotes={this.state.siteRes.site.enable_downvotes}
/>
</div>
);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (msg.reconnect) {
WebSocketService.Instance.getPost({
id: Number(this.props.match.params.id),
});
} else if (res.op == UserOperation.GetPost) {
let data = res.data as GetPostResponse;
this.state.post = data.post;
this.state.comments = data.comments;
this.state.community = data.community;
this.state.moderators = data.moderators;
this.state.online = data.online;
this.state.loading = false;
// Get cross-posts
if (this.state.post.url) {
let form: SearchForm = {
q: this.state.post.url,
type_: SearchType.Url,
sort: SortType.TopAll,
page: 1,
limit: 6,
};
WebSocketService.Instance.search(form);
}
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
// Necessary since it might be a user reply
if (data.recipient_ids.length == 0) {
this.state.comments.unshift(data.comment);
this.setState(this.state);
}
} else if (
res.op == UserOperation.EditComment ||
res.op == UserOperation.DeleteComment ||
res.op == UserOperation.RemoveComment
) {
let data = res.data as CommentResponse;
editCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
saveCommentRes(data, this.state.comments);
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
createPostLikeRes(data, this.state.post);
this.setState(this.state);
} else if (
res.op == UserOperation.EditPost ||
res.op == UserOperation.DeletePost ||
res.op == UserOperation.RemovePost ||
res.op == UserOperation.LockPost ||
res.op == UserOperation.StickyPost
) {
let data = res.data as PostResponse;
this.state.post = data.post;
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.SavePost) {
let data = res.data as PostResponse;
this.state.post = data.post;
this.setState(this.state);
setupTippy();
} else if (
res.op == UserOperation.EditCommunity ||
res.op == UserOperation.DeleteCommunity ||
res.op == UserOperation.RemoveCommunity
) {
let data = res.data as CommunityResponse;
this.state.community = data.community;
this.state.post.community_id = data.community.id;
this.state.post.community_name = data.community.name;
this.setState(this.state);
} else if (res.op == UserOperation.FollowCommunity) {
let data = res.data as CommunityResponse;
this.state.community.subscribed = data.community.subscribed;
this.state.community.number_of_subscribers =
data.community.number_of_subscribers;
this.setState(this.state);
} else if (res.op == UserOperation.BanFromCommunity) {
let data = res.data as BanFromCommunityResponse;
this.state.comments
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned_from_community = data.banned));
if (this.state.post.creator_id == data.user.id) {
this.state.post.banned_from_community = data.banned;
}
this.setState(this.state);
} else if (res.op == UserOperation.AddModToCommunity) {
let data = res.data as AddModToCommunityResponse;
this.state.moderators = data.moderators;
this.setState(this.state);
} else if (res.op == UserOperation.BanUser) {
let data = res.data as BanUserResponse;
this.state.comments
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned = data.banned));
if (this.state.post.creator_id == data.user.id) {
this.state.post.banned = data.banned;
}
this.setState(this.state);
} else if (res.op == UserOperation.AddAdmin) {
let data = res.data as AddAdminResponse;
this.state.siteRes.admins = data.admins;
this.setState(this.state);
} else if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;
this.state.crossPosts = data.posts.filter(
p => p.id != Number(this.props.match.params.id)
);
if (this.state.crossPosts.length) {
this.state.post.duplicates = this.state.crossPosts;
}
this.setState(this.state);
} else if (
res.op == UserOperation.TransferSite ||
res.op == UserOperation.GetSite
) {
let data = res.data as GetSiteResponse;
this.state.siteRes = data;
this.setState(this.state);
} else if (res.op == UserOperation.TransferCommunity) {
let data = res.data as GetCommunityResponse;
this.state.community = data.community;
this.state.moderators = data.moderators;
this.setState(this.state);
}
}
}

@ -1,288 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Prompt } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
PrivateMessageForm as PrivateMessageFormI,
EditPrivateMessageForm,
PrivateMessageFormParams,
PrivateMessage,
PrivateMessageResponse,
UserView,
UserOperation,
UserDetailsResponse,
GetUserDetailsForm,
SortType,
WebSocketJsonResponse,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import {
capitalizeFirstLetter,
wsJsonToRes,
toast,
setupTippy,
} from '../utils';
import { UserListing } from './user-listing';
import { MarkdownTextArea } from './markdown-textarea';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
interface PrivateMessageFormProps {
privateMessage?: PrivateMessage; // If a pm is given, that means this is an edit
params?: PrivateMessageFormParams;
onCancel?(): any;
onCreate?(message: PrivateMessage): any;
onEdit?(message: PrivateMessage): any;
}
interface PrivateMessageFormState {
privateMessageForm: PrivateMessageFormI;
recipient: UserView;
loading: boolean;
previewMode: boolean;
showDisclaimer: boolean;
}
export class PrivateMessageForm extends Component<
PrivateMessageFormProps,
PrivateMessageFormState
> {
private subscription: Subscription;
private emptyState: PrivateMessageFormState = {
privateMessageForm: {
content: null,
recipient_id: null,
},
recipient: null,
loading: false,
previewMode: false,
showDisclaimer: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleContentChange = this.handleContentChange.bind(this);
if (this.props.privateMessage) {
this.state.privateMessageForm = {
content: this.props.privateMessage.content,
recipient_id: this.props.privateMessage.recipient_id,
};
}
if (this.props.params) {
this.state.privateMessageForm.recipient_id = this.props.params.recipient_id;
let form: GetUserDetailsForm = {
user_id: this.state.privateMessageForm.recipient_id,
sort: SortType.New,
saved_only: false,
};
WebSocketService.Instance.getUserDetails(form);
}
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
}
componentDidMount() {
setupTippy();
}
componentDidUpdate() {
if (!this.state.loading && this.state.privateMessageForm.content) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = undefined;
}
}
componentWillUnmount() {
this.subscription.unsubscribe();
window.onbeforeunload = null;
}
render() {
return (
<div>
<Prompt
when={!this.state.loading && this.state.privateMessageForm.content}
message={i18n.t('block_leaving')}
/>
<form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}>
{!this.props.privateMessage && (
<div class="form-group row">
<label class="col-sm-2 col-form-label">
{capitalizeFirstLetter(i18n.t('to'))}
</label>
{this.state.recipient && (
<div class="col-sm-10 form-control-plaintext">
<UserListing
user={{
name: this.state.recipient.name,
preferred_username: this.state.recipient
.preferred_username,
avatar: this.state.recipient.avatar,
id: this.state.recipient.id,
local: this.state.recipient.local,
actor_id: this.state.recipient.actor_id,
}}
/>
</div>
)}
</div>
)}
<div class="form-group row">
<label class="col-sm-2 col-form-label">
{i18n.t('message')}
<span
onClick={linkEvent(this, this.handleShowDisclaimer)}
class="ml-2 pointer text-danger"
data-tippy-content={i18n.t('disclaimer')}
>
<svg class={`icon icon-inline`}>
<use xlinkHref="#icon-alert-triangle"></use>
</svg>
</span>
</label>
<div class="col-sm-10">
<MarkdownTextArea
initialContent={this.state.privateMessageForm.content}
onContentChange={this.handleContentChange}
/>
</div>
</div>
{this.state.showDisclaimer && (
<div class="form-group row">
<div class="offset-sm-2 col-sm-10">
<div class="alert alert-danger" role="alert">
<T i18nKey="private_message_disclaimer">
#
<a
class="alert-link"
target="_blank"
rel="noopener"
href="https://element.io/get-started"
>
#
</a>
</T>
</div>
</div>
</div>
)}
<div class="form-group row">
<div class="offset-sm-2 col-sm-10">
<button
type="submit"
class="btn btn-secondary mr-2"
disabled={this.state.loading}
>
{this.state.loading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : this.props.privateMessage ? (
capitalizeFirstLetter(i18n.t('save'))
) : (
capitalizeFirstLetter(i18n.t('send_message'))
)}
</button>
{this.props.privateMessage && (
<button
type="button"
class="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)}
>
{i18n.t('cancel')}
</button>
)}
<ul class="d-inline-block float-right list-inline mb-1 text-muted font-weight-bold">
<li class="list-inline-item"></li>
</ul>
</div>
</div>
</form>
</div>
);
}
handlePrivateMessageSubmit(i: PrivateMessageForm, event: any) {
event.preventDefault();
if (i.props.privateMessage) {
let editForm: EditPrivateMessageForm = {
edit_id: i.props.privateMessage.id,
content: i.state.privateMessageForm.content,
};
WebSocketService.Instance.editPrivateMessage(editForm);
} else {
WebSocketService.Instance.createPrivateMessage(
i.state.privateMessageForm
);
}
i.state.loading = true;
i.setState(i.state);
}
handleRecipientChange(i: PrivateMessageForm, event: any) {
i.state.recipient = event.target.value;
i.setState(i.state);
}
handleContentChange(val: string) {
this.state.privateMessageForm.content = val;
this.setState(this.state);
}
handleCancel(i: PrivateMessageForm) {
i.props.onCancel();
}
handlePreviewToggle(i: PrivateMessageForm, event: any) {
event.preventDefault();
i.state.previewMode = !i.state.previewMode;
i.setState(i.state);
}
handleShowDisclaimer(i: PrivateMessageForm) {
i.state.showDisclaimer = !i.state.showDisclaimer;
i.setState(i.state);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.state.loading = false;
this.setState(this.state);
return;
} else if (
res.op == UserOperation.EditPrivateMessage ||
res.op == UserOperation.DeletePrivateMessage ||
res.op == UserOperation.MarkPrivateMessageAsRead
) {
let data = res.data as PrivateMessageResponse;
this.state.loading = false;
this.props.onEdit(data.message);
} else if (res.op == UserOperation.GetUserDetails) {
let data = res.data as UserDetailsResponse;
this.state.recipient = data.user;
this.state.privateMessageForm.recipient_id = data.user.id;
this.setState(this.state);
} else if (res.op == UserOperation.CreatePrivateMessage) {
let data = res.data as PrivateMessageResponse;
this.state.loading = false;
this.props.onCreate(data.message);
this.setState(this.state);
}
}
}

@ -1,292 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Link } from 'inferno-router';
import {
PrivateMessage as PrivateMessageI,
DeletePrivateMessageForm,
MarkPrivateMessageAsReadForm,
} from 'lemmy-js-client';
import { WebSocketService, UserService } from '../services';
import { mdToHtml, pictrsAvatarThumbnail, showAvatars, toast } from '../utils';
import { MomentTime } from './moment-time';
import { PrivateMessageForm } from './private-message-form';
import { UserListing, UserOther } from './user-listing';
import { i18n } from '../i18next';
interface PrivateMessageState {
showReply: boolean;
showEdit: boolean;
collapsed: boolean;
viewSource: boolean;
}
interface PrivateMessageProps {
privateMessage: PrivateMessageI;
}
export class PrivateMessage extends Component<
PrivateMessageProps,
PrivateMessageState
> {
private emptyState: PrivateMessageState = {
showReply: false,
showEdit: false,
collapsed: false,
viewSource: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleReplyCancel = this.handleReplyCancel.bind(this);
this.handlePrivateMessageCreate = this.handlePrivateMessageCreate.bind(
this
);
this.handlePrivateMessageEdit = this.handlePrivateMessageEdit.bind(this);
}
get mine(): boolean {
return (
UserService.Instance.user &&
UserService.Instance.user.id == this.props.privateMessage.creator_id
);
}
render() {
let message = this.props.privateMessage;
let userOther: UserOther = this.mine
? {
name: message.recipient_name,
preferred_username: message.recipient_preferred_username,
id: message.id,
avatar: message.recipient_avatar,
local: message.recipient_local,
actor_id: message.recipient_actor_id,
published: message.published,
}
: {
name: message.creator_name,
preferred_username: message.creator_preferred_username,
id: message.id,
avatar: message.creator_avatar,
local: message.creator_local,
actor_id: message.creator_actor_id,
published: message.published,
};
return (
<div class="border-top border-light">
<div>
<ul class="list-inline mb-0 text-muted small">
{/* TODO refactor this */}
<li className="list-inline-item">
{this.mine ? i18n.t('to') : i18n.t('from')}
</li>
<li className="list-inline-item">
<UserListing user={userOther} />
</li>
<li className="list-inline-item">
<span>
<MomentTime data={message} />
</span>
</li>
<li className="list-inline-item">
<div
className="pointer text-monospace"
onClick={linkEvent(this, this.handleMessageCollapse)}
>
{this.state.collapsed ? (
<svg class="icon icon-inline">
<use xlinkHref="#icon-plus-square"></use>
</svg>
) : (
<svg class="icon icon-inline">
<use xlinkHref="#icon-minus-square"></use>
</svg>
)}
</div>
</li>
</ul>
{this.state.showEdit && (
<PrivateMessageForm
privateMessage={message}
onEdit={this.handlePrivateMessageEdit}
onCreate={this.handlePrivateMessageCreate}
onCancel={this.handleReplyCancel}
/>
)}
{!this.state.showEdit && !this.state.collapsed && (
<div>
{this.state.viewSource ? (
<pre>{this.messageUnlessRemoved}</pre>
) : (
<div
className="md-div"
dangerouslySetInnerHTML={mdToHtml(this.messageUnlessRemoved)}
/>
)}
<ul class="list-inline mb-0 text-muted font-weight-bold">
{!this.mine && (
<>
<li className="list-inline-item">
<button
class="btn btn-link btn-animate text-muted"
onClick={linkEvent(this, this.handleMarkRead)}
data-tippy-content={
message.read
? i18n.t('mark_as_unread')
: i18n.t('mark_as_read')
}
>
<svg
class={`icon icon-inline ${
message.read && 'text-success'
}`}
>
<use xlinkHref="#icon-check"></use>
</svg>
</button>
</li>
<li className="list-inline-item">
<button
class="btn btn-link btn-animate text-muted"
onClick={linkEvent(this, this.handleReplyClick)}
data-tippy-content={i18n.t('reply')}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-reply1"></use>
</svg>
</button>
</li>
</>
)}
{this.mine && (
<>
<li className="list-inline-item">
<button
class="btn btn-link btn-animate text-muted"
onClick={linkEvent(this, this.handleEditClick)}
data-tippy-content={i18n.t('edit')}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-edit"></use>
</svg>
</button>
</li>
<li className="list-inline-item">
<button
class="btn btn-link btn-animate text-muted"
onClick={linkEvent(this, this.handleDeleteClick)}
data-tippy-content={
!message.deleted
? i18n.t('delete')
: i18n.t('restore')
}
>
<svg
class={`icon icon-inline ${
message.deleted && 'text-danger'
}`}
>
<use xlinkHref="#icon-trash"></use>
</svg>
</button>
</li>
</>
)}
<li className="list-inline-item">
<button
class="btn btn-link btn-animate text-muted"
onClick={linkEvent(this, this.handleViewSource)}
data-tippy-content={i18n.t('view_source')}
>
<svg
class={`icon icon-inline ${
this.state.viewSource && 'text-success'
}`}
>
<use xlinkHref="#icon-file-text"></use>
</svg>
</button>
</li>
</ul>
</div>
)}
</div>
{this.state.showReply && (
<PrivateMessageForm
params={{
recipient_id: this.props.privateMessage.creator_id,
}}
onCreate={this.handlePrivateMessageCreate}
/>
)}
{/* A collapsed clearfix */}
{this.state.collapsed && <div class="row col-12"></div>}
</div>
);
}
get messageUnlessRemoved(): string {
let message = this.props.privateMessage;
return message.deleted ? `*${i18n.t('deleted')}*` : message.content;
}
handleReplyClick(i: PrivateMessage) {
i.state.showReply = true;
i.setState(i.state);
}
handleEditClick(i: PrivateMessage) {
i.state.showEdit = true;
i.setState(i.state);
}
handleDeleteClick(i: PrivateMessage) {
let form: DeletePrivateMessageForm = {
edit_id: i.props.privateMessage.id,
deleted: !i.props.privateMessage.deleted,
};
WebSocketService.Instance.deletePrivateMessage(form);
}
handleReplyCancel() {
this.state.showReply = false;
this.state.showEdit = false;
this.setState(this.state);
}
handleMarkRead(i: PrivateMessage) {
let form: MarkPrivateMessageAsReadForm = {
edit_id: i.props.privateMessage.id,
read: !i.props.privateMessage.read,
};
WebSocketService.Instance.markPrivateMessageAsRead(form);
}
handleMessageCollapse(i: PrivateMessage) {
i.state.collapsed = !i.state.collapsed;
i.setState(i.state);
}
handleViewSource(i: PrivateMessage) {
i.state.viewSource = !i.state.viewSource;
i.setState(i.state);
}
handlePrivateMessageEdit() {
this.state.showEdit = false;
this.setState(this.state);
}
handlePrivateMessageCreate(message: PrivateMessageI) {
if (
UserService.Instance.user &&
message.creator_id == UserService.Instance.user.id
) {
this.state.showReply = false;
this.setState(this.state);
toast(i18n.t('message_sent'));
}
}
}

@ -1,535 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
Post,
Comment,
Community,
UserView,
SortType,
SearchForm,
SearchResponse,
SearchType,
PostResponse,
CommentResponse,
WebSocketJsonResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import {
wsJsonToRes,
fetchLimit,
routeSearchTypeToEnum,
routeSortTypeToEnum,
toast,
createCommentLikeRes,
createPostLikeFindRes,
commentsToFlatNodes,
getPageFromProps,
} from '../utils';
import { PostListing } from './post-listing';
import { UserListing } from './user-listing';
import { CommunityLink } from './community-link';
import { SortSelect } from './sort-select';
import { CommentNodes } from './comment-nodes';
import { i18n } from '../i18next';
interface SearchState {
q: string;
type_: SearchType;
sort: SortType;
page: number;
searchResponse: SearchResponse;
loading: boolean;
site: Site;
searchText: string;
}
interface SearchProps {
q: string;
type_: SearchType;
sort: SortType;
page: number;
}
interface UrlParams {
q?: string;
type_?: SearchType;
sort?: SortType;
page?: number;
}
export class Search extends Component<any, SearchState> {
private subscription: Subscription;
private emptyState: SearchState = {
q: Search.getSearchQueryFromProps(this.props),
type_: Search.getSearchTypeFromProps(this.props),
sort: Search.getSortTypeFromProps(this.props),
page: getPageFromProps(this.props),
searchText: Search.getSearchQueryFromProps(this.props),
searchResponse: {
type_: null,
posts: [],
comments: [],
communities: [],
users: [],
},
loading: false,
site: {
id: undefined,
name: undefined,
creator_id: undefined,
published: undefined,
creator_name: undefined,
number_of_users: undefined,
number_of_posts: undefined,
number_of_comments: undefined,
number_of_communities: undefined,
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
},
};
static getSearchQueryFromProps(props: any): string {
return props.match.params.q ? props.match.params.q : '';
}
static getSearchTypeFromProps(props: any): SearchType {
return props.match.params.type
? routeSearchTypeToEnum(props.match.params.type)
: SearchType.All;
}
static getSortTypeFromProps(props: any): SortType {
return props.match.params.sort
? routeSortTypeToEnum(props.match.params.sort)
: SortType.TopAll;
}
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleSortChange = this.handleSortChange.bind(this);
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
if (this.state.q) {
this.search();
}
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
static getDerivedStateFromProps(props: any): SearchProps {
return {
q: Search.getSearchQueryFromProps(props),
type_: Search.getSearchTypeFromProps(props),
sort: Search.getSortTypeFromProps(props),
page: getPageFromProps(props),
};
}
componentDidUpdate(_: any, lastState: SearchState) {
if (
lastState.q !== this.state.q ||
lastState.type_ !== this.state.type_ ||
lastState.sort !== this.state.sort ||
lastState.page !== this.state.page
) {
this.setState({ loading: true, searchText: this.state.q });
this.search();
}
}
get documentTitle(): string {
if (this.state.site.name) {
if (this.state.q) {
return `${i18n.t('search')} - ${this.state.q} - ${
this.state.site.name
}`;
} else {
return `${i18n.t('search')} - ${this.state.site.name}`;
}
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<h5>{i18n.t('search')}</h5>
{this.selects()}
{this.searchForm()}
{this.state.type_ == SearchType.All && this.all()}
{this.state.type_ == SearchType.Comments && this.comments()}
{this.state.type_ == SearchType.Posts && this.posts()}
{this.state.type_ == SearchType.Communities && this.communities()}
{this.state.type_ == SearchType.Users && this.users()}
{this.resultsCount() == 0 && <span>{i18n.t('no_results')}</span>}
{this.paginator()}
</div>
);
}
searchForm() {
return (
<form
class="form-inline"
onSubmit={linkEvent(this, this.handleSearchSubmit)}
>
<input
type="text"
class="form-control mr-2 mb-2"
value={this.state.searchText}
placeholder={`${i18n.t('search')}...`}
onInput={linkEvent(this, this.handleQChange)}
required
minLength={3}
/>
<button type="submit" class="btn btn-secondary mr-2 mb-2">
{this.state.loading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
<span>{i18n.t('search')}</span>
)}
</button>
</form>
);
}
selects() {
return (
<div className="mb-2">
<select
value={this.state.type_}
onChange={linkEvent(this, this.handleTypeChange)}
class="custom-select w-auto mb-2"
>
<option disabled>{i18n.t('type')}</option>
<option value={SearchType.All}>{i18n.t('all')}</option>
<option value={SearchType.Comments}>{i18n.t('comments')}</option>
<option value={SearchType.Posts}>{i18n.t('posts')}</option>
<option value={SearchType.Communities}>
{i18n.t('communities')}
</option>
<option value={SearchType.Users}>{i18n.t('users')}</option>
</select>
<span class="ml-2">
<SortSelect
sort={this.state.sort}
onChange={this.handleSortChange}
hideHot
/>
</span>
</div>
);
}
all() {
let combined: Array<{
type_: string;
data: Comment | Post | Community | UserView;
}> = [];
let comments = this.state.searchResponse.comments.map(e => {
return { type_: 'comments', data: e };
});
let posts = this.state.searchResponse.posts.map(e => {
return { type_: 'posts', data: e };
});
let communities = this.state.searchResponse.communities.map(e => {
return { type_: 'communities', data: e };
});
let users = this.state.searchResponse.users.map(e => {
return { type_: 'users', data: e };
});
combined.push(...comments);
combined.push(...posts);
combined.push(...communities);
combined.push(...users);
// Sort it
if (this.state.sort == SortType.New) {
combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
} else {
combined.sort(
(a, b) =>
((b.data as Comment | Post).score |
(b.data as Community).number_of_subscribers |
(b.data as UserView).comment_score) -
((a.data as Comment | Post).score |
(a.data as Community).number_of_subscribers |
(a.data as UserView).comment_score)
);
}
return (
<div>
{combined.map(i => (
<div class="row">
<div class="col-12">
{i.type_ == 'posts' && (
<PostListing
key={(i.data as Post).id}
post={i.data as Post}
showCommunity
enableDownvotes={this.state.site.enable_downvotes}
enableNsfw={this.state.site.enable_nsfw}
/>
)}
{i.type_ == 'comments' && (
<CommentNodes
key={(i.data as Comment).id}
nodes={[{ comment: i.data as Comment }]}
locked
noIndent
enableDownvotes={this.state.site.enable_downvotes}
/>
)}
{i.type_ == 'communities' && (
<div>{this.communityListing(i.data as Community)}</div>
)}
{i.type_ == 'users' && (
<div>
<span>
<UserListing
user={{
name: (i.data as UserView).name,
preferred_username: (i.data as UserView)
.preferred_username,
avatar: (i.data as UserView).avatar,
}}
/>
</span>
<span>{` - ${i18n.t('number_of_comments', {
count: (i.data as UserView).number_of_comments,
})}`}</span>
</div>
)}
</div>
</div>
))}
</div>
);
}
comments() {
return (
<CommentNodes
nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
locked
noIndent
enableDownvotes={this.state.site.enable_downvotes}
/>
);
}
posts() {
return (
<>
{this.state.searchResponse.posts.map(post => (
<div class="row">
<div class="col-12">
<PostListing
post={post}
showCommunity
enableDownvotes={this.state.site.enable_downvotes}
enableNsfw={this.state.site.enable_nsfw}
/>
</div>
</div>
))}
</>
);
}
// Todo possibly create UserListing and CommunityListing
communities() {
return (
<>
{this.state.searchResponse.communities.map(community => (
<div class="row">
<div class="col-12">{this.communityListing(community)}</div>
</div>
))}
</>
);
}
communityListing(community: Community) {
return (
<>
<span>
<CommunityLink community={community} />
</span>
<span>{` - ${community.title} -
${i18n.t('number_of_subscribers', {
count: community.number_of_subscribers,
})}
`}</span>
</>
);
}
users() {
return (
<>
{this.state.searchResponse.users.map(user => (
<div class="row">
<div class="col-12">
<span>
<UserListing
user={{
name: user.name,
avatar: user.avatar,
preferred_username: user.preferred_username,
}}
/>
</span>
<span>{` - ${i18n.t('number_of_comments', {
count: user.number_of_comments,
})}`}</span>
</div>
</div>
))}
</>
);
}
paginator() {
return (
<div class="mt-2">
{this.state.page > 1 && (
<button
class="btn btn-secondary mr-1"
onClick={linkEvent(this, this.prevPage)}
>
{i18n.t('prev')}
</button>
)}
{this.resultsCount() > 0 && (
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.nextPage)}
>
{i18n.t('next')}
</button>
)}
</div>
);
}
resultsCount(): number {
let res = this.state.searchResponse;
return (
res.posts.length +
res.comments.length +
res.communities.length +
res.users.length
);
}
nextPage(i: Search) {
i.updateUrl({ page: i.state.page + 1 });
}
prevPage(i: Search) {
i.updateUrl({ page: i.state.page - 1 });
}
search() {
let form: SearchForm = {
q: this.state.q,
type_: this.state.type_,
sort: this.state.sort,
page: this.state.page,
limit: fetchLimit,
};
if (this.state.q != '') {
WebSocketService.Instance.search(form);
}
}
handleSortChange(val: SortType) {
this.updateUrl({ sort: val, page: 1 });
}
handleTypeChange(i: Search, event: any) {
i.updateUrl({
type_: SearchType[event.target.value],
page: 1,
});
}
handleSearchSubmit(i: Search, event: any) {
event.preventDefault();
i.updateUrl({
q: i.state.searchText,
type_: i.state.type_,
sort: i.state.sort,
page: i.state.page,
});
}
handleQChange(i: Search, event: any) {
i.setState({ searchText: event.target.value });
}
updateUrl(paramUpdates: UrlParams) {
const qStr = paramUpdates.q || this.state.q;
const typeStr = paramUpdates.type_ || this.state.type_;
const sortStr = paramUpdates.sort || this.state.sort;
const page = paramUpdates.page || this.state.page;
this.props.history.push(
`/search/q/${qStr}/type/${typeStr}/sort/${sortStr}/page/${page}`
);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op == UserOperation.Search) {
let data = res.data as SearchResponse;
this.state.searchResponse = data;
this.state.loading = false;
window.scrollTo(0, 0);
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.searchResponse.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
createPostLikeFindRes(data, this.state.searchResponse.posts);
this.setState(this.state);
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

@ -1,211 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
RegisterForm,
LoginResponse,
UserOperation,
WebSocketJsonResponse,
} from 'lemmy-js-client';
import { WebSocketService, UserService } from '../services';
import { wsJsonToRes, toast } from '../utils';
import { SiteForm } from './site-form';
import { i18n } from '../i18next';
interface State {
userForm: RegisterForm;
doneRegisteringUser: boolean;
userLoading: boolean;
}
export class Setup extends Component<any, State> {
private subscription: Subscription;
private emptyState: State = {
userForm: {
username: undefined,
password: undefined,
password_verify: undefined,
admin: true,
show_nsfw: true,
// The first admin signup doesn't need a captcha
captcha_uuid: '',
captcha_answer: '',
},
doneRegisteringUser: false,
userLoading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
return `${i18n.t('setup')} - Lemmy`;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<div class="row">
<div class="col-12 offset-lg-3 col-lg-6">
<h3>{i18n.t('lemmy_instance_setup')}</h3>
{!this.state.doneRegisteringUser ? (
this.registerUser()
) : (
<SiteForm />
)}
</div>
</div>
</div>
);
}
registerUser() {
return (
<form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
<h5>{i18n.t('setup_admin')}</h5>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="username">
{i18n.t('username')}
</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
id="username"
value={this.state.userForm.username}
onInput={linkEvent(this, this.handleRegisterUsernameChange)}
required
minLength={3}
maxLength={20}
pattern="[a-zA-Z0-9_]+"
/>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="email">
{i18n.t('email')}
</label>
<div class="col-sm-10">
<input
type="email"
id="email"
class="form-control"
placeholder={i18n.t('optional')}
value={this.state.userForm.email}
onInput={linkEvent(this, this.handleRegisterEmailChange)}
minLength={3}
/>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="password">
{i18n.t('password')}
</label>
<div class="col-sm-10">
<input
type="password"
id="password"
value={this.state.userForm.password}
onInput={linkEvent(this, this.handleRegisterPasswordChange)}
class="form-control"
required
/>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" htmlFor="verify-password">
{i18n.t('verify_password')}
</label>
<div class="col-sm-10">
<input
type="password"
id="verify-password"
value={this.state.userForm.password_verify}
onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
class="form-control"
required
/>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-secondary">
{this.state.userLoading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
i18n.t('sign_up')
)}
</button>
</div>
</div>
</form>
);
}
handleRegisterSubmit(i: Setup, event: any) {
event.preventDefault();
i.state.userLoading = true;
i.setState(i.state);
event.preventDefault();
WebSocketService.Instance.register(i.state.userForm);
}
handleRegisterUsernameChange(i: Setup, event: any) {
i.state.userForm.username = event.target.value;
i.setState(i.state);
}
handleRegisterEmailChange(i: Setup, event: any) {
i.state.userForm.email = event.target.value;
i.setState(i.state);
}
handleRegisterPasswordChange(i: Setup, event: any) {
i.state.userForm.password = event.target.value;
i.setState(i.state);
}
handleRegisterPasswordVerifyChange(i: Setup, event: any) {
i.state.userForm.password_verify = event.target.value;
i.setState(i.state);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.state.userLoading = false;
this.setState(this.state);
return;
} else if (res.op == UserOperation.Register) {
let data = res.data as LoginResponse;
this.state.userLoading = false;
this.state.doneRegisteringUser = true;
UserService.Instance.login(data);
this.setState(this.state);
} else if (res.op == UserOperation.CreateSite) {
this.props.history.push('/');
}
}
}

@ -1,477 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Link } from 'inferno-router';
import {
Community,
CommunityUser,
FollowCommunityForm,
DeleteCommunityForm,
RemoveCommunityForm,
UserView,
AddModToCommunityForm,
} from 'lemmy-js-client';
import { WebSocketService, UserService } from '../services';
import { mdToHtml, getUnixTime } from '../utils';
import { CommunityForm } from './community-form';
import { UserListing } from './user-listing';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import { i18n } from '../i18next';
interface SidebarProps {
community: Community;
moderators: Array<CommunityUser>;
admins: Array<UserView>;
online: number;
enableNsfw: boolean;
showIcon?: boolean;
}
interface SidebarState {
showEdit: boolean;
showRemoveDialog: boolean;
removeReason: string;
removeExpires: string;
showConfirmLeaveModTeam: boolean;
}
export class Sidebar extends Component<SidebarProps, SidebarState> {
private emptyState: SidebarState = {
showEdit: false,
showRemoveDialog: false,
removeReason: null,
removeExpires: null,
showConfirmLeaveModTeam: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleEditCommunity = this.handleEditCommunity.bind(this);
this.handleEditCancel = this.handleEditCancel.bind(this);
}
render() {
return (
<div>
{!this.state.showEdit ? (
this.sidebar()
) : (
<CommunityForm
community={this.props.community}
onEdit={this.handleEditCommunity}
onCancel={this.handleEditCancel}
enableNsfw={this.props.enableNsfw}
/>
)}
</div>
);
}
sidebar() {
return (
<div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-header bg-transparent border-secondary">
{this.communityTitle()}
{this.adminButtons()}
</div>
<div class="card-body">{this.subscribes()}</div>
</div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-body">
{this.description()}
{this.badges()}
{this.mods()}
</div>
</div>
</div>
);
}
communityTitle() {
let community = this.props.community;
return (
<div>
<h5 className="mb-0">
{this.props.showIcon && (
<BannerIconHeader icon={community.icon} banner={community.banner} />
)}
<span>{community.title}</span>
{community.removed && (
<small className="ml-2 text-muted font-italic">
{i18n.t('removed')}
</small>
)}
{community.deleted && (
<small className="ml-2 text-muted font-italic">
{i18n.t('deleted')}
</small>
)}
{community.nsfw && (
<small className="ml-2 text-muted font-italic">
{i18n.t('nsfw')}
</small>
)}
</h5>
<CommunityLink
community={community}
realLink
useApubName
muted
hideAvatar
/>
</div>
);
}
badges() {
let community = this.props.community;
return (
<ul class="my-1 list-inline">
<li className="list-inline-item badge badge-light">
{i18n.t('number_online', { count: this.props.online })}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_subscribers', {
count: community.number_of_subscribers,
})}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_posts', {
count: community.number_of_posts,
})}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_comments', {
count: community.number_of_comments,
})}
</li>
<li className="list-inline-item">
<Link className="badge badge-light" to="/communities">
{community.category_name}
</Link>
</li>
<li className="list-inline-item">
<Link
className="badge badge-light"
to={`/modlog/community/${this.props.community.id}`}
>
{i18n.t('modlog')}
</Link>
</li>
<li className="list-inline-item badge badge-light">
<CommunityLink community={community} realLink />
</li>
</ul>
);
}
mods() {
return (
<ul class="list-inline small">
<li class="list-inline-item">{i18n.t('mods')}: </li>
{this.props.moderators.map(mod => (
<li class="list-inline-item">
<UserListing
user={{
name: mod.user_name,
preferred_username: mod.user_preferred_username,
avatar: mod.avatar,
id: mod.user_id,
local: mod.user_local,
actor_id: mod.user_actor_id,
}}
/>
</li>
))}
</ul>
);
}
subscribes() {
let community = this.props.community;
return (
<div class="d-flex flex-wrap">
<Link
class={`btn btn-secondary flex-fill mr-2 mb-2 ${
community.deleted || community.removed ? 'no-click' : ''
}`}
to={`/create_post?community=${community.name}`}
>
{i18n.t('create_a_post')}
</Link>
{community.subscribed ? (
<a
class="btn btn-secondary flex-fill mb-2"
href="#"
onClick={linkEvent(community.id, this.handleUnsubscribe)}
>
{i18n.t('unsubscribe')}
</a>
) : (
<a
class="btn btn-secondary flex-fill mb-2"
href="#"
onClick={linkEvent(community.id, this.handleSubscribe)}
>
{i18n.t('subscribe')}
</a>
)}
</div>
);
}
description() {
let community = this.props.community;
return (
community.description && (
<div
className="md-div"
dangerouslySetInnerHTML={mdToHtml(community.description)}
/>
)
);
}
adminButtons() {
let community = this.props.community;
return (
<>
<ul class="list-inline mb-1 text-muted font-weight-bold">
{this.canMod && (
<>
<li className="list-inline-item-action">
<span
class="pointer"
onClick={linkEvent(this, this.handleEditClick)}
data-tippy-content={i18n.t('edit')}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-edit"></use>
</svg>
</span>
</li>
{!this.amCreator &&
(!this.state.showConfirmLeaveModTeam ? (
<li className="list-inline-item-action">
<span
class="pointer"
onClick={linkEvent(
this,
this.handleShowConfirmLeaveModTeamClick
)}
>
{i18n.t('leave_mod_team')}
</span>
</li>
) : (
<>
<li className="list-inline-item-action">
{i18n.t('are_you_sure')}
</li>
<li className="list-inline-item-action">
<span
class="pointer"
onClick={linkEvent(this, this.handleLeaveModTeamClick)}
>
{i18n.t('yes')}
</span>
</li>
<li className="list-inline-item-action">
<span
class="pointer"
onClick={linkEvent(
this,
this.handleCancelLeaveModTeamClick
)}
>
{i18n.t('no')}
</span>
</li>
</>
))}
{this.amCreator && (
<li className="list-inline-item-action">
<span
class="pointer"
onClick={linkEvent(this, this.handleDeleteClick)}
data-tippy-content={
!community.deleted ? i18n.t('delete') : i18n.t('restore')
}
>
<svg
class={`icon icon-inline ${
community.deleted && 'text-danger'
}`}
>
<use xlinkHref="#icon-trash"></use>
</svg>
</span>
</li>
)}
</>
)}
{this.canAdmin && (
<li className="list-inline-item">
{!this.props.community.removed ? (
<span
class="pointer"
onClick={linkEvent(this, this.handleModRemoveShow)}
>
{i18n.t('remove')}
</span>
) : (
<span
class="pointer"
onClick={linkEvent(this, this.handleModRemoveSubmit)}
>
{i18n.t('restore')}
</span>
)}
</li>
)}
</ul>
{this.state.showRemoveDialog && (
<form onSubmit={linkEvent(this, this.handleModRemoveSubmit)}>
<div class="form-group row">
<label class="col-form-label" htmlFor="remove-reason">
{i18n.t('reason')}
</label>
<input
type="text"
id="remove-reason"
class="form-control mr-2"
placeholder={i18n.t('optional')}
value={this.state.removeReason}
onInput={linkEvent(this, this.handleModRemoveReasonChange)}
/>
</div>
{/* TODO hold off on expires for now */}
{/* <div class="form-group row"> */}
{/* <label class="col-form-label">Expires</label> */}
{/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.removeExpires} onInput={linkEvent(this, this.handleModRemoveExpiresChange)} /> */}
{/* </div> */}
<div class="form-group row">
<button type="submit" class="btn btn-secondary">
{i18n.t('remove_community')}
</button>
</div>
</form>
)}
</>
);
}
handleEditClick(i: Sidebar) {
i.state.showEdit = true;
i.setState(i.state);
}
handleEditCommunity() {
this.state.showEdit = false;
this.setState(this.state);
}
handleEditCancel() {
this.state.showEdit = false;
this.setState(this.state);
}
handleDeleteClick(i: Sidebar) {
event.preventDefault();
let deleteForm: DeleteCommunityForm = {
edit_id: i.props.community.id,
deleted: !i.props.community.deleted,
};
WebSocketService.Instance.deleteCommunity(deleteForm);
}
handleShowConfirmLeaveModTeamClick(i: Sidebar) {
i.state.showConfirmLeaveModTeam = true;
i.setState(i.state);
}
handleLeaveModTeamClick(i: Sidebar) {
let form: AddModToCommunityForm = {
user_id: UserService.Instance.user.id,
community_id: i.props.community.id,
added: false,
};
WebSocketService.Instance.addModToCommunity(form);
i.state.showConfirmLeaveModTeam = false;
i.setState(i.state);
}
handleCancelLeaveModTeamClick(i: Sidebar) {
i.state.showConfirmLeaveModTeam = false;
i.setState(i.state);
}
handleUnsubscribe(communityId: number) {
event.preventDefault();
let form: FollowCommunityForm = {
community_id: communityId,
follow: false,
};
WebSocketService.Instance.followCommunity(form);
}
handleSubscribe(communityId: number) {
event.preventDefault();
let form: FollowCommunityForm = {
community_id: communityId,
follow: true,
};
WebSocketService.Instance.followCommunity(form);
}
private get amCreator(): boolean {
return this.props.community.creator_id == UserService.Instance.user.id;
}
get canMod(): boolean {
return (
UserService.Instance.user &&
this.props.moderators
.map(m => m.user_id)
.includes(UserService.Instance.user.id)
);
}
get canAdmin(): boolean {
return (
UserService.Instance.user &&
this.props.admins.map(a => a.id).includes(UserService.Instance.user.id)
);
}
handleModRemoveShow(i: Sidebar) {
i.state.showRemoveDialog = true;
i.setState(i.state);
}
handleModRemoveReasonChange(i: Sidebar, event: any) {
i.state.removeReason = event.target.value;
i.setState(i.state);
}
handleModRemoveExpiresChange(i: Sidebar, event: any) {
console.log(event.target.value);
i.state.removeExpires = event.target.value;
i.setState(i.state);
}
handleModRemoveSubmit(i: Sidebar) {
event.preventDefault();
let removeForm: RemoveCommunityForm = {
edit_id: i.props.community.id,
removed: !i.props.community.removed,
reason: i.state.removeReason,
expires: getUnixTime(i.state.removeExpires),
};
WebSocketService.Instance.removeCommunity(removeForm);
i.state.showRemoveDialog = false;
i.setState(i.state);
}
}

@ -1,300 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { Prompt } from 'inferno-router';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
import { Site, SiteForm as SiteFormI } from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { capitalizeFirstLetter, randomStr } from '../utils';
import { i18n } from '../i18next';
interface SiteFormProps {
site?: Site; // If a site is given, that means this is an edit
onCancel?(): any;
}
interface SiteFormState {
siteForm: SiteFormI;
loading: boolean;
}
export class SiteForm extends Component<SiteFormProps, SiteFormState> {
private id = `site-form-${randomStr()}`;
private emptyState: SiteFormState = {
siteForm: {
enable_downvotes: true,
open_registration: true,
enable_nsfw: true,
name: null,
icon: null,
banner: null,
},
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleSiteDescriptionChange = this.handleSiteDescriptionChange.bind(
this
);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
if (this.props.site) {
this.state.siteForm = {
name: this.props.site.name,
description: this.props.site.description,
enable_downvotes: this.props.site.enable_downvotes,
open_registration: this.props.site.open_registration,
enable_nsfw: this.props.site.enable_nsfw,
icon: this.props.site.icon,
banner: this.props.site.banner,
};
}
}
// Necessary to stop the loading
componentWillReceiveProps() {
this.state.loading = false;
this.setState(this.state);
}
componentDidUpdate() {
if (
!this.state.loading &&
!this.props.site &&
(this.state.siteForm.name || this.state.siteForm.description)
) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = undefined;
}
}
componentWillUnmount() {
window.onbeforeunload = null;
}
render() {
return (
<>
<Prompt
when={
!this.state.loading &&
!this.props.site &&
(this.state.siteForm.name || this.state.siteForm.description)
}
message={i18n.t('block_leaving')}
/>
<form onSubmit={linkEvent(this, this.handleCreateSiteSubmit)}>
<h5>{`${
this.props.site
? capitalizeFirstLetter(i18n.t('save'))
: capitalizeFirstLetter(i18n.t('name'))
} ${i18n.t('your_site')}`}</h5>
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor="create-site-name">
{i18n.t('name')}
</label>
<div class="col-12">
<input
type="text"
id="create-site-name"
class="form-control"
value={this.state.siteForm.name}
onInput={linkEvent(this, this.handleSiteNameChange)}
required
minLength={3}
maxLength={20}
/>
</div>
</div>
<div class="form-group">
<label>{i18n.t('icon')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_icon')}
imageSrc={this.state.siteForm.icon}
onUpload={this.handleIconUpload}
onRemove={this.handleIconRemove}
rounded
/>
</div>
<div class="form-group">
<label>{i18n.t('banner')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_banner')}
imageSrc={this.state.siteForm.banner}
onUpload={this.handleBannerUpload}
onRemove={this.handleBannerRemove}
/>
</div>
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor={this.id}>
{i18n.t('sidebar')}
</label>
<div class="col-12">
<MarkdownTextArea
initialContent={this.state.siteForm.description}
onContentChange={this.handleSiteDescriptionChange}
hideNavigationWarnings
/>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<div class="form-check">
<input
class="form-check-input"
id="create-site-downvotes"
type="checkbox"
checked={this.state.siteForm.enable_downvotes}
onChange={linkEvent(
this,
this.handleSiteEnableDownvotesChange
)}
/>
<label class="form-check-label" htmlFor="create-site-downvotes">
{i18n.t('enable_downvotes')}
</label>
</div>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<div class="form-check">
<input
class="form-check-input"
id="create-site-enable-nsfw"
type="checkbox"
checked={this.state.siteForm.enable_nsfw}
onChange={linkEvent(this, this.handleSiteEnableNsfwChange)}
/>
<label
class="form-check-label"
htmlFor="create-site-enable-nsfw"
>
{i18n.t('enable_nsfw')}
</label>
</div>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<div class="form-check">
<input
class="form-check-input"
id="create-site-open-registration"
type="checkbox"
checked={this.state.siteForm.open_registration}
onChange={linkEvent(
this,
this.handleSiteOpenRegistrationChange
)}
/>
<label
class="form-check-label"
htmlFor="create-site-open-registration"
>
{i18n.t('open_registration')}
</label>
</div>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<button
type="submit"
class="btn btn-secondary mr-2"
disabled={this.state.loading}
>
{this.state.loading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : this.props.site ? (
capitalizeFirstLetter(i18n.t('save'))
) : (
capitalizeFirstLetter(i18n.t('create'))
)}
</button>
{this.props.site && (
<button
type="button"
class="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)}
>
{i18n.t('cancel')}
</button>
)}
</div>
</div>
</form>
</>
);
}
handleCreateSiteSubmit(i: SiteForm, event: any) {
event.preventDefault();
i.state.loading = true;
if (i.props.site) {
WebSocketService.Instance.editSite(i.state.siteForm);
} else {
WebSocketService.Instance.createSite(i.state.siteForm);
}
i.setState(i.state);
}
handleSiteNameChange(i: SiteForm, event: any) {
i.state.siteForm.name = event.target.value;
i.setState(i.state);
}
handleSiteDescriptionChange(val: string) {
this.state.siteForm.description = val;
this.setState(this.state);
}
handleSiteEnableNsfwChange(i: SiteForm, event: any) {
i.state.siteForm.enable_nsfw = event.target.checked;
i.setState(i.state);
}
handleSiteOpenRegistrationChange(i: SiteForm, event: any) {
i.state.siteForm.open_registration = event.target.checked;
i.setState(i.state);
}
handleSiteEnableDownvotesChange(i: SiteForm, event: any) {
i.state.siteForm.enable_downvotes = event.target.checked;
i.setState(i.state);
}
handleCancel(i: SiteForm) {
i.props.onCancel();
}
handleIconUpload(url: string) {
this.state.siteForm.icon = url;
this.setState(this.state);
}
handleIconRemove() {
this.state.siteForm.icon = '';
this.setState(this.state);
}
handleBannerUpload(url: string) {
this.state.siteForm.banner = url;
this.setState(this.state);
}
handleBannerRemove() {
this.state.siteForm.banner = '';
this.setState(this.state);
}
}

@ -1,76 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { SortType } from 'lemmy-js-client';
import { sortingHelpUrl, randomStr } from '../utils';
import { i18n } from '../i18next';
interface SortSelectProps {
sort: SortType;
onChange?(val: SortType): any;
hideHot?: boolean;
}
interface SortSelectState {
sort: SortType;
}
export class SortSelect extends Component<SortSelectProps, SortSelectState> {
private id = `sort-select-${randomStr()}`;
private emptyState: SortSelectState = {
sort: this.props.sort,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
static getDerivedStateFromProps(props: any): SortSelectState {
return {
sort: props.sort,
};
}
render() {
return (
<>
<select
id={this.id}
name={this.id}
value={this.state.sort}
onChange={linkEvent(this, this.handleSortChange)}
class="custom-select w-auto mr-2 mb-2"
>
<option disabled>{i18n.t('sort_type')}</option>
{!this.props.hideHot && (
<>
<option value={SortType.Active}>{i18n.t('active')}</option>
<option value={SortType.Hot}>{i18n.t('hot')}</option>
</>
)}
<option value={SortType.New}>{i18n.t('new')}</option>
<option disabled></option>
<option value={SortType.TopDay}>{i18n.t('top_day')}</option>
<option value={SortType.TopWeek}>{i18n.t('week')}</option>
<option value={SortType.TopMonth}>{i18n.t('month')}</option>
<option value={SortType.TopYear}>{i18n.t('year')}</option>
<option value={SortType.TopAll}>{i18n.t('all')}</option>
</select>
<a
className="text-muted"
href={sortingHelpUrl}
target="_blank"
rel="noopener"
title={i18n.t('sorting_help')}
>
<svg class={`icon icon-inline`}>
<use xlinkHref="#icon-help-circle"></use>
</svg>
</a>
</>
);
}
handleSortChange(i: SortSelect, event: any) {
i.props.onChange(event.target.value);
}
}

@ -1,211 +0,0 @@
import { Component } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import { WebSocketService } from '../services';
import {
GetSiteResponse,
Site,
WebSocketJsonResponse,
UserOperation,
} from 'lemmy-js-client';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
import { repoUrl, wsJsonToRes, toast } from '../utils';
interface SilverUser {
name: string;
link?: string;
}
let general = [
'Brendan',
'mexicanhalloween',
'William Moore',
'Rachel Schmitz',
'comradeda',
'ybaumy',
'dude in phx',
'twilight loki',
'Andrew Plaza',
'Jonathan Cremin',
'Arthur Nieuwland',
'Ernest Wiśniewski',
'HN',
'Forrest Weghorst',
'Andre Vallestero',
'NotTooHighToHack',
];
let highlighted = ['DQW', 'DiscountFuneral', 'Oskenso Kashi', 'Alex Benishek'];
let silver: Array<SilverUser> = [
{
name: 'Redjoker',
link: 'https://iww.org',
},
];
// let gold = [];
// let latinum = [];
interface SponsorsState {
site: Site;
}
export class Sponsors extends Component<any, SponsorsState> {
private subscription: Subscription;
private emptyState: SponsorsState = {
site: undefined,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
}
componentDidMount() {
window.scrollTo(0, 0);
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
get documentTitle(): string {
if (this.state.site) {
return `${i18n.t('sponsors')} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container text-center">
<Helmet title={this.documentTitle} />
{this.topMessage()}
<hr />
{this.sponsors()}
<hr />
{this.bitcoin()}
</div>
);
}
topMessage() {
return (
<div>
<h5>{i18n.t('donate_to_lemmy')}</h5>
<p>
<T i18nKey="sponsor_message">
#<a href={repoUrl}>#</a>
</T>
</p>
<a class="btn btn-secondary" href="https://liberapay.com/Lemmy/">
{i18n.t('support_on_liberapay')}
</a>
<a
class="btn btn-secondary ml-2"
href="https://www.patreon.com/dessalines"
>
{i18n.t('support_on_patreon')}
</a>
<a
class="btn btn-secondary ml-2"
href="https://opencollective.com/lemmy"
>
{i18n.t('support_on_open_collective')}
</a>
</div>
);
}
sponsors() {
return (
<div class="container">
<h5>{i18n.t('sponsors')}</h5>
<p>{i18n.t('silver_sponsors')}</p>
<div class="row justify-content-md-center card-columns">
{silver.map(s => (
<div class="card col-12 col-md-2">
<div>
{s.link ? (
<a href={s.link} target="_blank" rel="noopener">
💎 {s.name}
</a>
) : (
<div>💎 {s.name}</div>
)}
</div>
</div>
))}
</div>
<p>{i18n.t('general_sponsors')}</p>
<div class="row justify-content-md-center card-columns">
{highlighted.map(s => (
<div class="card bg-primary col-12 col-md-2 font-weight-bold">
<div>{s}</div>
</div>
))}
{general.map(s => (
<div class="card col-12 col-md-2">
<div>{s}</div>
</div>
))}
</div>
</div>
);
}
bitcoin() {
return (
<div>
<h5>{i18n.t('crypto')}</h5>
<div class="table-responsive">
<table class="table table-hover text-center">
<tbody>
<tr>
<td>{i18n.t('bitcoin')}</td>
<td>
<code>1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK</code>
</td>
</tr>
<tr>
<td>{i18n.t('ethereum')}</td>
<td>
<code>0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01</code>
</td>
</tr>
<tr>
<td>{i18n.t('monero')}</td>
<td>
<code>
41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV
</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

File diff suppressed because one or more lines are too long

@ -1,315 +0,0 @@
import { Component, linkEvent } from 'inferno';
import { WebSocketService, UserService } from '../services';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import { i18n } from '../i18next';
import {
UserOperation,
Post,
Comment,
CommunityUser,
SortType,
UserDetailsResponse,
UserView,
WebSocketJsonResponse,
CommentResponse,
BanUserResponse,
PostResponse,
} from 'lemmy-js-client';
import { UserDetailsView } from '../interfaces';
import {
wsJsonToRes,
toast,
commentsToFlatNodes,
setupTippy,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
} from '../utils';
import { PostListing } from './post-listing';
import { CommentNodes } from './comment-nodes';
interface UserDetailsProps {
username?: string;
user_id?: number;
page: number;
limit: number;
sort: SortType;
enableDownvotes: boolean;
enableNsfw: boolean;
view: UserDetailsView;
onPageChange(page: number): number | any;
admins: Array<UserView>;
}
interface UserDetailsState {
follows: Array<CommunityUser>;
moderates: Array<CommunityUser>;
comments: Array<Comment>;
posts: Array<Post>;
saved?: Array<Post>;
}
export class UserDetails extends Component<UserDetailsProps, UserDetailsState> {
private subscription: Subscription;
constructor(props: any, context: any) {
super(props, context);
this.state = {
follows: [],
moderates: [],
comments: [],
posts: [],
saved: [],
};
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
componentDidMount() {
this.fetchUserData();
setupTippy();
}
componentDidUpdate(lastProps: UserDetailsProps) {
for (const key of Object.keys(lastProps)) {
if (lastProps[key] !== this.props[key]) {
this.fetchUserData();
break;
}
}
}
fetchUserData() {
WebSocketService.Instance.getUserDetails({
user_id: this.props.user_id,
username: this.props.username,
sort: this.props.sort,
saved_only: this.props.view === UserDetailsView.Saved,
page: this.props.page,
limit: this.props.limit,
});
}
render() {
return (
<div>
{this.viewSelector(this.props.view)}
{this.paginator()}
</div>
);
}
viewSelector(view: UserDetailsView) {
if (view === UserDetailsView.Overview || view === UserDetailsView.Saved) {
return this.overview();
}
if (view === UserDetailsView.Comments) {
return this.comments();
}
if (view === UserDetailsView.Posts) {
return this.posts();
}
}
overview() {
const comments = this.state.comments.map((c: Comment) => {
return { type: 'comments', data: c };
});
const posts = this.state.posts.map((p: Post) => {
return { type: 'posts', data: p };
});
const combined: Array<{ type: string; data: Comment | Post }> = [
...comments,
...posts,
];
// Sort it
if (this.props.sort === SortType.New) {
combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
} else {
combined.sort((a, b) => b.data.score - a.data.score);
}
return (
<div>
{combined.map(i => (
<>
<div>
{i.type === 'posts' ? (
<PostListing
key={(i.data as Post).id}
post={i.data as Post}
admins={this.props.admins}
showCommunity
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
/>
) : (
<CommentNodes
key={(i.data as Comment).id}
nodes={[{ comment: i.data as Comment }]}
admins={this.props.admins}
noBorder
noIndent
showCommunity
showContext
enableDownvotes={this.props.enableDownvotes}
/>
)}
</div>
<hr class="my-3" />
</>
))}
</div>
);
}
comments() {
return (
<div>
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
admins={this.props.admins}
noIndent
showCommunity
showContext
enableDownvotes={this.props.enableDownvotes}
/>
</div>
);
}
posts() {
return (
<div>
{this.state.posts.map(post => (
<>
<PostListing
post={post}
admins={this.props.admins}
showCommunity
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
/>
<hr class="my-3" />
</>
))}
</div>
);
}
paginator() {
return (
<div class="my-2">
{this.props.page > 1 && (
<button
class="btn btn-secondary mr-1"
onClick={linkEvent(this, this.prevPage)}
>
{i18n.t('prev')}
</button>
)}
{this.state.comments.length + this.state.posts.length > 0 && (
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.nextPage)}
>
{i18n.t('next')}
</button>
)}
</div>
);
}
nextPage(i: UserDetails) {
i.props.onPageChange(i.props.page + 1);
}
prevPage(i: UserDetails) {
i.props.onPageChange(i.props.page - 1);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
const res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
if (msg.error == 'couldnt_find_that_username_or_email') {
this.context.router.history.push('/');
}
return;
} else if (msg.reconnect) {
this.fetchUserData();
} else if (res.op == UserOperation.GetUserDetails) {
const data = res.data as UserDetailsResponse;
this.setState({
comments: data.comments,
follows: data.follows,
moderates: data.moderates,
posts: data.posts,
});
} else if (res.op == UserOperation.CreateCommentLike) {
const data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.comments);
this.setState({
comments: this.state.comments,
});
} else if (
res.op == UserOperation.EditComment ||
res.op == UserOperation.DeleteComment ||
res.op == UserOperation.RemoveComment
) {
const data = res.data as CommentResponse;
editCommentRes(data, this.state.comments);
this.setState({
comments: this.state.comments,
});
} else if (res.op == UserOperation.CreateComment) {
const data = res.data as CommentResponse;
if (
UserService.Instance.user &&
data.comment.creator_id == UserService.Instance.user.id
) {
toast(i18n.t('reply_sent'));
}
} else if (res.op == UserOperation.SaveComment) {
const data = res.data as CommentResponse;
saveCommentRes(data, this.state.comments);
this.setState({
comments: this.state.comments,
});
} else if (res.op == UserOperation.CreatePostLike) {
const data = res.data as PostResponse;
createPostLikeFindRes(data, this.state.posts);
this.setState({
posts: this.state.posts,
});
} else if (res.op == UserOperation.BanUser) {
const data = res.data as BanUserResponse;
this.state.comments
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned = data.banned));
this.state.posts
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned = data.banned));
this.setState({
posts: this.state.posts,
comments: this.state.comments,
});
}
}
}

@ -1,75 +0,0 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { UserView } from 'lemmy-js-client';
import {
pictrsAvatarThumbnail,
showAvatars,
hostname,
isCakeDay,
} from '../utils';
import { CakeDay } from './cake-day';
export interface UserOther {
name: string;
preferred_username?: string;
id?: number; // Necessary if its federated
avatar?: string;
local?: boolean;
actor_id?: string;
published?: string;
}
interface UserListingProps {
user: UserView | UserOther;
realLink?: boolean;
useApubName?: boolean;
muted?: boolean;
hideAvatar?: boolean;
}
export class UserListing extends Component<UserListingProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
let user = this.props.user;
let local = user.local == null ? true : user.local;
let apubName: string, link: string;
if (local) {
apubName = `@${user.name}`;
link = `/u/${user.name}`;
} else {
apubName = `@${user.name}@${hostname(user.actor_id)}`;
link = !this.props.realLink ? `/user/${user.id}` : user.actor_id;
}
let displayName = this.props.useApubName
? apubName
: user.preferred_username
? user.preferred_username
: apubName;
return (
<>
<Link
title={apubName}
className={this.props.muted ? 'text-muted' : 'text-info'}
to={link}
>
{!this.props.hideAvatar && user.avatar && showAvatars() && (
<img
style="width: 2rem; height: 2rem;"
src={pictrsAvatarThumbnail(user.avatar)}
class="rounded-circle mr-2"
/>
)}
<span>{displayName}</span>
</Link>
{isCakeDay(user.published) && <CakeDay creatorName={apubName} />}
</>
);
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save