A first pass at locally working isomorphic integration.
15
docker/dev/Dockerfile
vendored
|
@ -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
|
||||
|
|
8
docker/dev/docker-compose.yml
vendored
|
@ -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,
|
||||
});
|
BIN
ui/assets/apple-touch-icon.png
vendored
Before Width: | Height: | Size: 2.1 KiB |
1
ui/assets/css/choices.min.css
vendored
304
ui/assets/css/main.css
vendored
|
@ -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;
|
104
ui/assets/css/themes/_variables.darkly.scss
vendored
|
@ -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;
|
40
ui/assets/css/themes/_variables.i386.scss
vendored
|
@ -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;
|
33
ui/assets/css/themes/_variables.litely.scss
vendored
|
@ -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;
|
83
ui/assets/css/themes/cyborg.min.css
vendored
1
ui/assets/css/themes/darkly.min.css
vendored
1
ui/assets/css/themes/i386.min.css
vendored
12
ui/assets/css/themes/journal.min.css
vendored
1
ui/assets/css/themes/litely.min.css
vendored
12
ui/assets/css/themes/litera.min.css
vendored
12
ui/assets/css/themes/materia.min.css
vendored
12
ui/assets/css/themes/minty.min.css
vendored
12
ui/assets/css/themes/sketchy.min.css
vendored
68
ui/assets/css/themes/solar.min.css
vendored
12
ui/assets/css/themes/united.min.css
vendored
1
ui/assets/css/themes/vaporwave-dark.min.css
vendored
1
ui/assets/css/themes/vaporwave.min.css
vendored
1
ui/assets/css/tippy.css
vendored
|
@ -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}
|
78
ui/assets/css/toastify.css
vendored
|
@ -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;
|
||||
}
|
||||
}
|
31
ui/assets/css/tribute.css
vendored
|
@ -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; }
|
118
ui/assets/favicon.svg
vendored
|
@ -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 |
BIN
ui/assets/icons/icon-128x128.png
vendored
Before Width: | Height: | Size: 4.8 KiB |
BIN
ui/assets/icons/icon-144x144.png
vendored
Before Width: | Height: | Size: 5.5 KiB |
BIN
ui/assets/icons/icon-152x152.png
vendored
Before Width: | Height: | Size: 5.8 KiB |
BIN
ui/assets/icons/icon-192x192.png
vendored
Before Width: | Height: | Size: 7.7 KiB |
BIN
ui/assets/icons/icon-384x384.png
vendored
Before Width: | Height: | Size: 18 KiB |
BIN
ui/assets/icons/icon-512x512.png
vendored
Before Width: | Height: | Size: 30 KiB |
BIN
ui/assets/icons/icon-72x72.png
vendored
Before Width: | Height: | Size: 2.5 KiB |
BIN
ui/assets/icons/icon-96x96.png
vendored
Before Width: | Height: | Size: 3.5 KiB |
2
ui/assets/libs/sortable/sortable.min.js
vendored
|
@ -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);
|
49
ui/assets/manifest.webmanifest
vendored
|
@ -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();
|
||||
});
|
25
ui/generate_translations.js
vendored
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
367
ui/src/api_tests/comment.spec.ts
vendored
|
@ -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);
|
||||
});
|
95
ui/src/api_tests/community.spec.ts
vendored
|
@ -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');
|
||||
});
|
43
ui/src/api_tests/follow.spec.ts
vendored
|
@ -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);
|
||||
});
|
305
ui/src/api_tests/post.spec.ts
vendored
|
@ -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();
|
||||
});
|
80
ui/src/api_tests/private_message.spec.ts
vendored
|
@ -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);
|
||||
});
|
544
ui/src/api_tests/shared.ts
vendored
|
@ -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;
|
||||
}
|
34
ui/src/api_tests/user.spec.ts
vendored
|
@ -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);
|
||||
});
|
260
ui/src/components/admin-settings.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
30
ui/src/components/banner-icon-header.tsx
vendored
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
25
ui/src/components/cake-day.tsx
vendored
|
@ -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 });
|
||||
}
|
||||
}
|
154
ui/src/components/comment-form.tsx
vendored
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1208
ui/src/components/comment-node.tsx
vendored
74
ui/src/components/comment-nodes.tsx
vendored
|
@ -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;
|
||||
}
|
||||
}
|
258
ui/src/components/communities.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
364
ui/src/components/community-form.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
60
ui/src/components/community-link.tsx
vendored
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
480
ui/src/components/community.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
105
ui/src/components/create-community.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
132
ui/src/components/create-post.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
109
ui/src/components/create-private-message.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
70
ui/src/components/data-type-select.tsx
vendored
|
@ -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));
|
||||
}
|
||||
}
|
87
ui/src/components/footer.tsx
vendored
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
105
ui/src/components/iframely-card.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
114
ui/src/components/image-upload-form.tsx
vendored
|
@ -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();
|
||||
}
|
||||
}
|
607
ui/src/components/inbox.tsx
vendored
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
98
ui/src/components/instances.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
95
ui/src/components/listing-type-select.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
485
ui/src/components/login.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
807
ui/src/components/main.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
543
ui/src/components/markdown-textarea.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
454
ui/src/components/modlog.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
58
ui/src/components/moment-time.tsx
vendored
|
@ -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');
|
||||
}
|
||||
}
|
544
ui/src/components/navbar.tsx
vendored
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
162
ui/src/components/password_change.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
622
ui/src/components/post-form.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
1458
ui/src/components/post-listing.tsx
vendored
115
ui/src/components/post-listings.tsx
vendored
|
@ -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;
|
||||
}
|
||||
}
|
561
ui/src/components/post.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
288
ui/src/components/private-message-form.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
292
ui/src/components/private-message.tsx
vendored
|
@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
535
ui/src/components/search.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
211
ui/src/components/setup.tsx
vendored
|
@ -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('/');
|
||||
}
|
||||
}
|
||||
}
|
477
ui/src/components/sidebar.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
300
ui/src/components/site-form.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
76
ui/src/components/sort-select.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
211
ui/src/components/sponsors.tsx
vendored
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
214
ui/src/components/symbols.tsx
vendored
315
ui/src/components/user-details.tsx
vendored
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
75
ui/src/components/user-listing.tsx
vendored
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|