mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2024-12-22 19:01:26 +00:00
Partly functioning fuse-box, but moving te webpack now.
This commit is contained in:
parent
3125477c7b
commit
2eee936026
66 changed files with 19040 additions and 365 deletions
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
fuse.ts
|
||||
generate_translations.js
|
||||
src/api_tests
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -25,3 +25,5 @@ test/data/result.json
|
|||
package-lock.json
|
||||
*.orig
|
||||
|
||||
src/shared/translations
|
||||
|
||||
|
|
158
fuse.ts
158
fuse.ts
|
@ -1,76 +1,82 @@
|
|||
import { CSSPlugin, FuseBox, FuseBoxOptions, Sparky } from "fuse-box";
|
||||
import path = require("path");
|
||||
import TsTransformClasscat from "ts-transform-classcat";
|
||||
import TsTransformInferno from "ts-transform-inferno";
|
||||
/**
|
||||
* Some of FuseBoxOptions overrides by ts config (module, target, etc)
|
||||
* https://fuse-box.org/page/working-with-targets
|
||||
*/
|
||||
let fuse: FuseBox;
|
||||
const fuseOptions: FuseBoxOptions = {
|
||||
homeDir: "./src",
|
||||
output: "dist/$name.js",
|
||||
sourceMaps: { inline: false, vendor: false },
|
||||
/**
|
||||
* Custom TypeScript Transformers (compile Inferno tsx to ts)
|
||||
*/
|
||||
transformers: {
|
||||
before: [TsTransformClasscat(), TsTransformInferno()]
|
||||
}
|
||||
};
|
||||
const fuseClientOptions: FuseBoxOptions = {
|
||||
...fuseOptions,
|
||||
plugins: [
|
||||
/**
|
||||
* https://fuse-box.org/page/css-resource-plugin
|
||||
* Compile Sass {SassPlugin()}
|
||||
* Make .css files modules-like (allow import them like modules) {CSSModules}
|
||||
* Make .css files modules like and allow import it from node_modules too {CSSResourcePlugin}
|
||||
* Use them all and bundle with {CSSPlugin}
|
||||
* */
|
||||
CSSPlugin()
|
||||
]
|
||||
};
|
||||
const fuseServerOptions: FuseBoxOptions = {
|
||||
...fuseOptions
|
||||
};
|
||||
Sparky.task("clean", () => {
|
||||
/**Clean distribute (dist) folder */
|
||||
Sparky.src("dist")
|
||||
.clean("dist")
|
||||
.exec();
|
||||
});
|
||||
Sparky.task("config", () => {
|
||||
fuse = FuseBox.init(fuseOptions);
|
||||
fuse.dev();
|
||||
});
|
||||
Sparky.task("test", ["&clean", "&config"], () => {
|
||||
fuse.bundle("client/bundle").test("[**/**.test.tsx]", null);
|
||||
});
|
||||
Sparky.task("client", () => {
|
||||
fuse.opts = fuseClientOptions;
|
||||
fuse
|
||||
.bundle("client/bundle")
|
||||
.target("browser@esnext")
|
||||
.watch("client/**")
|
||||
.hmr()
|
||||
.instructions("> client/index.tsx");
|
||||
});
|
||||
Sparky.task("server", () => {
|
||||
/**Workaround. Should be fixed */
|
||||
fuse.opts = fuseServerOptions;
|
||||
fuse
|
||||
.bundle("server/bundle")
|
||||
.watch("**")
|
||||
.target("server@esnext")
|
||||
.instructions("> [server/index.tsx]")
|
||||
.completed(proc => {
|
||||
proc.require({
|
||||
// tslint:disable-next-line:no-shadowed-variable
|
||||
close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown()
|
||||
});
|
||||
});
|
||||
});
|
||||
Sparky.task("dev", ["&clean", "&config", "&client", "&server"], () => {
|
||||
fuse.run();
|
||||
});
|
||||
import { CSSPlugin, FuseBox, FuseBoxOptions, Sparky } from 'fuse-box';
|
||||
import path = require('path');
|
||||
import TsTransformClasscat from 'ts-transform-classcat';
|
||||
import TsTransformInferno from 'ts-transform-inferno';
|
||||
/**
|
||||
* Some of FuseBoxOptions overrides by ts config (module, target, etc)
|
||||
* https://fuse-box.org/page/working-with-targets
|
||||
*/
|
||||
let fuse: FuseBox;
|
||||
const fuseOptions: FuseBoxOptions = {
|
||||
homeDir: './src',
|
||||
output: 'dist/$name.js',
|
||||
sourceMaps: { inline: false, vendor: false },
|
||||
/**
|
||||
* Custom TypeScript Transformers (compile Inferno tsx to ts)
|
||||
*/
|
||||
transformers: {
|
||||
before: [TsTransformClasscat(), TsTransformInferno()],
|
||||
},
|
||||
};
|
||||
const fuseClientOptions: FuseBoxOptions = {
|
||||
...fuseOptions,
|
||||
plugins: [
|
||||
/**
|
||||
* https://fuse-box.org/page/css-resource-plugin
|
||||
* Compile Sass {SassPlugin()}
|
||||
* Make .css files modules-like (allow import them like modules) {CSSModules}
|
||||
* Make .css files modules like and allow import it from node_modules too {CSSResourcePlugin}
|
||||
* Use them all and bundle with {CSSPlugin}
|
||||
* */
|
||||
CSSPlugin(),
|
||||
],
|
||||
};
|
||||
const fuseServerOptions: FuseBoxOptions = {
|
||||
...fuseOptions,
|
||||
};
|
||||
|
||||
Sparky.task('clean', () => {
|
||||
/**Clean distribute (dist) folder */
|
||||
Sparky.src('dist/').clean('dist/');
|
||||
});
|
||||
Sparky.task('config', () => {
|
||||
fuse = FuseBox.init(fuseOptions);
|
||||
fuse.dev();
|
||||
});
|
||||
Sparky.task('test', ['&clean', '&config'], () => {
|
||||
fuse.bundle('client/bundle').test('[**/**.test.tsx]', null);
|
||||
});
|
||||
Sparky.task('client', () => {
|
||||
fuse.opts = fuseClientOptions;
|
||||
fuse
|
||||
.bundle('client/bundle')
|
||||
.target('browser@esnext')
|
||||
.watch('client/**')
|
||||
.hmr()
|
||||
.instructions('> client/index.tsx');
|
||||
});
|
||||
Sparky.task('copy-assets', () =>
|
||||
Sparky.src('**/**.*', { base: 'src/assets' }).dest('dist/assets')
|
||||
);
|
||||
Sparky.task('server', () => {
|
||||
/**Workaround. Should be fixed */
|
||||
fuse.opts = fuseServerOptions;
|
||||
fuse
|
||||
.bundle('server/bundle')
|
||||
.watch('**')
|
||||
.target('server@esnext')
|
||||
.instructions('> [server/index.tsx]')
|
||||
.completed(proc => {
|
||||
proc.require({
|
||||
// tslint:disable-next-line:no-shadowed-variable
|
||||
close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown(),
|
||||
});
|
||||
});
|
||||
});
|
||||
Sparky.task(
|
||||
'dev',
|
||||
['&clean', '&config', '&client', '&server', '©-assets'],
|
||||
() => {
|
||||
fuse.run();
|
||||
}
|
||||
);
|
||||
|
|
27
generate_translations.js
Normal file
27
generate_translations.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
fs = require('fs');
|
||||
|
||||
let translationDir = 'translations/';
|
||||
let outDir = 'src/shared/translations/';
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.readdir(translationDir, (err, files) => {
|
||||
files.forEach(filename => {
|
||||
const lang = filename.split('.')[0];
|
||||
try {
|
||||
const json = JSON.parse(
|
||||
fs.readFileSync(translationDir + 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 = outDir + lang + '.ts';
|
||||
fs.writeFileSync(target, data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
});
|
39
package.json
39
package.json
|
@ -4,20 +4,48 @@
|
|||
"author": "Dessalines <tyhou13@gmx.com>",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "set NODE_ENV=development && node -r ts-node/register --inspect fuse.ts dev",
|
||||
"lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src",
|
||||
"prebuild": "node generate_translations.js",
|
||||
"prestart": "node generate_translations.js",
|
||||
"start": "set NODE_ENV=development && node -r ts-node/register --inspect fuse.ts dev",
|
||||
"test": "node -r ts-node/register --inspect fuse.ts test"
|
||||
},
|
||||
"repository": "https://github.com/LemmyNet/lemmy-isomorphic-ui",
|
||||
"dependencies": {
|
||||
"@types/autosize": "^3.0.6",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"autosize": "^4.0.2",
|
||||
"choices.js": "^9.0.1",
|
||||
"cookie-parser": "^1.4.3",
|
||||
"emoji-short-name": "^1.0.0",
|
||||
"express": "~4.17.1",
|
||||
"i18next": "^19.4.1",
|
||||
"inferno": "^7.4.3",
|
||||
"inferno-create-element": "^7.4.3",
|
||||
"inferno-helmet": "^5.2.1",
|
||||
"inferno-hydrate": "^7.4.3",
|
||||
"inferno-i18next": "github:nimbusec-oss/inferno-i18next#semver:^7.4.2",
|
||||
"inferno-router": "^7.4.3",
|
||||
"inferno-server": "^7.4.3",
|
||||
"serialize-javascript": "^4.0.0"
|
||||
"isomorphic-cookie": "^1.2.4",
|
||||
"isomorphic-ws": "^4.0.1",
|
||||
"js-cookie": "^2.2.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"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",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"rxjs": "^6.5.5",
|
||||
"serialize-javascript": "^4.0.0",
|
||||
"terser": "^4.6.11",
|
||||
"tippy.js": "^6.1.1",
|
||||
"toastify-js": "^1.7.0",
|
||||
"tributejs": "^5.1.3",
|
||||
"ws": "^7.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.1",
|
||||
|
@ -26,6 +54,7 @@
|
|||
"@types/jest": "^26.0.10",
|
||||
"@types/node": "^14.6.0",
|
||||
"@types/serialize-javascript": "^4.0.0",
|
||||
"classcat": "^4.1.0",
|
||||
"enzyme": "^3.3.0",
|
||||
"enzyme-adapter-inferno": "^1.3.0",
|
||||
"eslint": "^7.5.0",
|
||||
|
@ -38,15 +67,19 @@
|
|||
"jest": "^26.4.2",
|
||||
"jsdom": "16.4.0",
|
||||
"jsdom-global": "3.0.2",
|
||||
"lemmy-js-client": "^1.0.8",
|
||||
"lint-staged": "^10.1.3",
|
||||
"prettier": "^2.0.4",
|
||||
"sortpack": "^2.1.4",
|
||||
"ts-node": "^9.0.0",
|
||||
"ts-transform-classcat": "^1.0.0",
|
||||
"ts-transform-inferno": "^4.0.3",
|
||||
"tslint-react-recommended": "^1.0.15",
|
||||
"typescript": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.9.0"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
.text {
|
||||
color: brown;
|
||||
font-size: 25pt;
|
||||
}
|
||||
.count {
|
||||
color: blue;
|
||||
}
|
||||
.button {
|
||||
color: red;
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import 'jsdom-global/register';
|
||||
import { configure, mount, render, shallow } from 'enzyme';
|
||||
import InfernoEnzymeAdapter = require('enzyme-adapter-inferno');
|
||||
import { should } from 'fuse-test-runner';
|
||||
import { Component } from 'inferno';
|
||||
import { renderToSnapshot } from 'inferno-test-utils';
|
||||
import About from './About';
|
||||
configure({ adapter: new InfernoEnzymeAdapter() });
|
||||
|
||||
export class AboutTest {
|
||||
public 'Should be okay'() {
|
||||
const wrapper = mount(<About />);
|
||||
wrapper.find('.button').simulate('click');
|
||||
const countText = wrapper.find('.count').text();
|
||||
should(countText).beString().equal('1');
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import { Component } from 'inferno';
|
||||
import './About.css';
|
||||
interface IState {
|
||||
clickCount: number;
|
||||
}
|
||||
interface IProps {}
|
||||
export default class About extends Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
clickCount: 0,
|
||||
};
|
||||
this.increment = this.increment.bind(this);
|
||||
}
|
||||
protected increment() {
|
||||
this.setState({
|
||||
clickCount: this.state.clickCount + 1,
|
||||
});
|
||||
}
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
Simple Inferno SSR template
|
||||
<p className="text">Hello, world!</p>
|
||||
<button onClick={this.increment} className="button">
|
||||
Increment
|
||||
</button>
|
||||
<p className="count">{this.state.clickCount}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
import { Component, render } from 'inferno';
|
||||
import { Link, Route, StaticRouter, Switch } from 'inferno-router';
|
||||
import About from '../About/About';
|
||||
import Home from '../Home/Home';
|
||||
interface IState {}
|
||||
interface IProps {
|
||||
name: string;
|
||||
}
|
||||
export default class App extends Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h3>{this.props.name}</h3>
|
||||
<div>
|
||||
<Link to="/Home">
|
||||
<p>Home</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link to="/About" className="link">
|
||||
<p>About</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Switch>
|
||||
<Route exact path="/Home" component={Home} />
|
||||
<Route exact path="/About" component={About} />
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
import { Component } from 'inferno';
|
||||
interface IState {}
|
||||
interface IProps {}
|
||||
export default class Home extends Component<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
protected click() {
|
||||
/**
|
||||
* Try to debug next line
|
||||
*/
|
||||
console.log('hi');
|
||||
}
|
||||
public render() {
|
||||
return (
|
||||
<div>
|
||||
Home page
|
||||
<button onClick={this.click}>Click me</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import { Component } from 'inferno';
|
||||
import { hydrate } from 'inferno-hydrate';
|
||||
import { BrowserRouter } from 'inferno-router';
|
||||
import App from './components/App/App';
|
||||
import { initDevTools } from 'inferno-devtools';
|
||||
import { App } from '../shared/components/app';
|
||||
/* import { initDevTools } from 'inferno-devtools'; */
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
@ -14,8 +14,8 @@ declare global {
|
|||
|
||||
const wrapper = (
|
||||
<BrowserRouter>
|
||||
<App name={window.isoData.name} />
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
initDevTools();
|
||||
/* initDevTools(); */
|
||||
hydrate(wrapper, document.getElementById('root'));
|
||||
|
|
|
@ -1,28 +1,35 @@
|
|||
import cookieParser = require('cookie-parser');
|
||||
import * as serialize from 'serialize-javascript';
|
||||
import * as express from 'express';
|
||||
import serialize from 'serialize-javascript';
|
||||
import express from 'express';
|
||||
import { StaticRouter } from 'inferno-router';
|
||||
import { renderToString } from 'inferno-server';
|
||||
import { matchPath } from 'inferno-router';
|
||||
import path = require('path');
|
||||
import App from '../client/components/App/App';
|
||||
import { App } from '../shared/components/app';
|
||||
import { routes } from '../shared/routes';
|
||||
import IsomorphicCookie from 'isomorphic-cookie';
|
||||
const server = express();
|
||||
const port = 1234;
|
||||
|
||||
server.use(express.json());
|
||||
server.use(express.urlencoded({ extended: false }));
|
||||
server.use('/assets', express.static(path.resolve('./dist/assets')));
|
||||
server.use('/static', express.static(path.resolve('./dist/client')));
|
||||
|
||||
server.use(cookieParser());
|
||||
|
||||
server.get('/*', (req, res) => {
|
||||
const activeRoute = routes.find(route => matchPath(req.url, route)) || {};
|
||||
console.log(activeRoute);
|
||||
const context = {} as any;
|
||||
const isoData = {
|
||||
name: 'fishing sux',
|
||||
};
|
||||
let auth: string = IsomorphicCookie.load('jwt', req);
|
||||
|
||||
const wrapper = (
|
||||
<StaticRouter location={req.url} context={context}>
|
||||
<App name={isoData.name} />
|
||||
<App />
|
||||
</StaticRouter>
|
||||
);
|
||||
if (context.url) {
|
||||
|
@ -30,17 +37,38 @@ server.get('/*', (req, res) => {
|
|||
}
|
||||
|
||||
res.send(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>My Universal App</title>
|
||||
<script>window.isoData = ${serialize(isoData)}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id='root'>${renderToString(wrapper)}</div>
|
||||
<script src='./static/bundle.js'></script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>window.isoData = ${serialize(isoData)}</script>
|
||||
|
||||
<!-- Required meta tags -->
|
||||
<meta name="Description" content="Lemmy">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="shortcut icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/tribute.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/toastify.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/choices.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/tippy.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/themes/litely.min.css" id="default-light" media="(prefers-color-scheme: light)" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/themes/darkly.min.css" id="default-dark" media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/main.css" />
|
||||
|
||||
<!-- Scripts -->
|
||||
<script async src="/assets/libs/sortable/sortable.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id='root'>${renderToString(wrapper)}</div>
|
||||
<script src='./static/bundle.js'></script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
let Server = server.listen(port, () => {
|
||||
|
|
260
src/shared/components/admin-settings.tsx
Normal file
260
src/shared/components/admin-settings.tsx
Normal file
|
@ -0,0 +1,260 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
42
src/shared/components/app.tsx
Normal file
42
src/shared/components/app.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Component } from 'inferno';
|
||||
import { Route, Switch } from 'inferno-router';
|
||||
/* import { Provider } from 'inferno-i18next'; */
|
||||
/* import { i18n } from './i18next'; */
|
||||
import { routes } from '../../shared/routes';
|
||||
import { Navbar } from '../../shared/components/navbar';
|
||||
import { Footer } from '../../shared/components/footer';
|
||||
import { Symbols } from '../../shared/components/symbols';
|
||||
|
||||
export class App extends Component<any, any> {
|
||||
constructor(props: any, context: any) {
|
||||
super(props, context);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<h1>Hi there!</h1>
|
||||
{/* <Provider i18next={i18n}> */}
|
||||
<div>
|
||||
<Navbar />
|
||||
<div class="mt-4 p-0 fl-1">
|
||||
<Switch>
|
||||
{routes.map(({ path, exact, component: C, ...rest }) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
exact={exact}
|
||||
render={props => <C {...props} {...rest} />}
|
||||
/>
|
||||
))}
|
||||
{/* <Route render={(props) => <NoMatch {...props} />} /> */}
|
||||
</Switch>
|
||||
<Symbols />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
{/* </Provider> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
30
src/shared/components/banner-icon-header.tsx
Normal file
30
src/shared/components/banner-icon-header.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
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
src/shared/components/cake-day.tsx
Normal file
25
src/shared/components/cake-day.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
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
src/shared/components/comment-form.tsx
Normal file
154
src/shared/components/comment-form.tsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
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
src/shared/components/comment-node.tsx
Normal file
1208
src/shared/components/comment-node.tsx
Normal file
File diff suppressed because it is too large
Load diff
74
src/shared/components/comment-nodes.tsx
Normal file
74
src/shared/components/comment-nodes.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
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: CommentNodeI[];
|
||||
moderators?: CommunityUser[];
|
||||
admins?: 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(): 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
src/shared/components/communities.tsx
Normal file
258
src/shared/components/communities.tsx
Normal file
|
@ -0,0 +1,258 @@
|
|||
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: 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
src/shared/components/community-form.tsx
Normal file
364
src/shared/components/community-form.tsx
Normal file
|
@ -0,0 +1,364 @@
|
|||
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: 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
src/shared/components/community-link.tsx
Normal file
60
src/shared/components/community-link.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
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
src/shared/components/community.tsx
Normal file
480
src/shared/components/community.tsx
Normal file
|
@ -0,0 +1,480 @@
|
|||
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: CommunityUser[];
|
||||
admins: UserView[];
|
||||
online: number;
|
||||
loading: boolean;
|
||||
posts: Post[];
|
||||
comments: 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
src/shared/components/create-community.tsx
Normal file
105
src/shared/components/create-community.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
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
src/shared/components/create-post.tsx
Normal file
132
src/shared/components/create-post.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
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
src/shared/components/create-private-message.tsx
Normal file
109
src/shared/components/create-private-message.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
71
src/shared/components/data-type-select.tsx
Normal file
71
src/shared/components/data-type-select.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
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;
|
||||
console.log(this.state);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
89
src/shared/components/footer.tsx
Normal file
89
src/shared/components/footer.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
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, isBrowser } 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;
|
||||
|
||||
if (isBrowser()) {
|
||||
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
src/shared/components/iframely-card.tsx
Normal file
105
src/shared/components/iframely-card.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
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
src/shared/components/image-upload-form.tsx
Normal file
114
src/shared/components/image-upload-form.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
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
src/shared/components/inbox.tsx
Normal file
607
src/shared/components/inbox.tsx
Normal file
|
@ -0,0 +1,607 @@
|
|||
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: Comment[];
|
||||
mentions: Comment[];
|
||||
messages: 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(): 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
|
||||
);
|
||||
}
|
||||
}
|
101
src/shared/components/instances.tsx
Normal file
101
src/shared/components/instances.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
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, isBrowser } 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;
|
||||
|
||||
if (isBrowser()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
77
src/shared/components/listing-type-select.tsx
Normal file
77
src/shared/components/listing-type-select.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
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;
|
||||
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_,
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
<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
src/shared/components/login.tsx
Normal file
485
src/shared/components/login.tsx
Normal file
|
@ -0,0 +1,485 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
803
src/shared/components/main.tsx
Normal file
803
src/shared/components/main.tsx
Normal file
|
@ -0,0 +1,803 @@
|
|||
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,
|
||||
isBrowser,
|
||||
} from '../utils';
|
||||
import { i18n } from '../i18next';
|
||||
import { T } from 'inferno-i18next';
|
||||
|
||||
interface MainState {
|
||||
subscribedCommunities: CommunityUser[];
|
||||
trendingCommunities: Community[];
|
||||
siteRes: GetSiteResponse;
|
||||
showEditSite: boolean;
|
||||
loading: boolean;
|
||||
posts: Post[];
|
||||
comments: 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);
|
||||
|
||||
if (isBrowser()) {
|
||||
// TODO
|
||||
/* 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">
|
||||
<h1 className={`text-warning`}>u stink main</h1>
|
||||
<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}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
544
src/shared/components/markdown-textarea.tsx
Normal file
544
src/shared/components/markdown-textarea.tsx
Normal file
|
@ -0,0 +1,544 @@
|
|||
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);
|
||||
|
||||
// TODO
|
||||
/* 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
src/shared/components/modlog.tsx
Normal file
454
src/shared/components/modlog.tsx
Normal file
|
@ -0,0 +1,454 @@
|
|||
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: {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
55
src/shared/components/moment-time.tsx
Normal file
55
src/shared/components/moment-time.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
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');
|
||||
}
|
||||
}
|
556
src/shared/components/navbar.tsx
Normal file
556
src/shared/components/navbar.tsx
Normal file
|
@ -0,0 +1,556 @@
|
|||
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,
|
||||
isBrowser,
|
||||
} from '../utils';
|
||||
import { i18n } from '../i18next';
|
||||
|
||||
interface NavbarState {
|
||||
isLoggedIn: boolean;
|
||||
expanded: boolean;
|
||||
replies: Comment[];
|
||||
mentions: Comment[];
|
||||
messages: 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;
|
||||
|
||||
if (isBrowser()) {
|
||||
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() {
|
||||
if (isBrowser()) {
|
||||
// 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;
|
||||
let expandedClass = `${!this.state.expanded && 'collapse'} navbar-collapse`;
|
||||
|
||||
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>
|
||||
{/* TODO this isn't working
|
||||
className={`${!this.state.expanded && 'collapse'
|
||||
} navbar-collapse`}
|
||||
*/}
|
||||
{!this.state.siteLoading && (
|
||||
<div class="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)}
|
||||
>
|
||||
{/* TODO No idea why, but this class here fails
|
||||
class={`form-control mr-0 search-input ${
|
||||
this.state.toggleSearch ? 'show-input' : 'hide-input'
|
||||
}`}
|
||||
|
||||
*/}
|
||||
<input
|
||||
onInput={linkEvent(this, this.handleSearchParam)}
|
||||
value={this.state.searchParam}
|
||||
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);
|
||||
console.log(res);
|
||||
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
src/shared/components/password_change.tsx
Normal file
162
src/shared/components/password_change.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
623
src/shared/components/post-form.tsx
Normal file
623
src/shared/components/post-form.tsx
Normal file
|
@ -0,0 +1,623 @@
|
|||
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: Community[];
|
||||
loading: boolean;
|
||||
imageLoading: boolean;
|
||||
previewMode: boolean;
|
||||
suggestedTitle: string;
|
||||
suggestedPosts: Post[];
|
||||
crossPosts: 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) {
|
||||
// TODO
|
||||
/* 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
src/shared/components/post-listing.tsx
Normal file
1458
src/shared/components/post-listing.tsx
Normal file
File diff suppressed because it is too large
Load diff
115
src/shared/components/post-listings.tsx
Normal file
115
src/shared/components/post-listings.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
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: 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(): 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: Post[]): Post[] {
|
||||
// A map from post url to list of posts (dupes)
|
||||
let urlMap = new Map<string, 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
src/shared/components/post.tsx
Normal file
561
src/shared/components/post.tsx
Normal file
|
@ -0,0 +1,561 @@
|
|||
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: Comment[];
|
||||
commentSort: CommentSortType;
|
||||
commentViewType: CommentViewType;
|
||||
community: Community;
|
||||
moderators: CommunityUser[];
|
||||
online: number;
|
||||
scrolled?: boolean;
|
||||
scrolled_comment_id?: number;
|
||||
loading: boolean;
|
||||
crossPosts: 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(): 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: 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
src/shared/components/private-message-form.tsx
Normal file
288
src/shared/components/private-message-form.tsx
Normal file
|
@ -0,0 +1,288 @@
|
|||
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
src/shared/components/private-message.tsx
Normal file
292
src/shared/components/private-message.tsx
Normal file
|
@ -0,0 +1,292 @@
|
|||
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'));
|
||||
}
|
||||
}
|
||||
}
|
536
src/shared/components/search.tsx
Normal file
536
src/shared/components/search.tsx
Normal file
|
@ -0,0 +1,536 @@
|
|||
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: {
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
</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
src/shared/components/setup.tsx
Normal file
211
src/shared/components/setup.tsx
Normal file
|
@ -0,0 +1,211 @@
|
|||
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
src/shared/components/sidebar.tsx
Normal file
477
src/shared/components/sidebar.tsx
Normal file
|
@ -0,0 +1,477 @@
|
|||
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: CommunityUser[];
|
||||
admins: 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
src/shared/components/site-form.tsx
Normal file
300
src/shared/components/site-form.tsx
Normal file
|
@ -0,0 +1,300 @@
|
|||
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
src/shared/components/sort-select.tsx
Normal file
76
src/shared/components/sort-select.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
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
src/shared/components/sponsors.tsx
Normal file
211
src/shared/components/sponsors.tsx
Normal file
|
@ -0,0 +1,211 @@
|
|||
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: 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
src/shared/components/symbols.tsx
Normal file
214
src/shared/components/symbols.tsx
Normal file
File diff suppressed because one or more lines are too long
315
src/shared/components/user-details.tsx
Normal file
315
src/shared/components/user-details.tsx
Normal file
|
@ -0,0 +1,315 @@
|
|||
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: UserView[];
|
||||
}
|
||||
|
||||
interface UserDetailsState {
|
||||
follows: CommunityUser[];
|
||||
moderates: CommunityUser[];
|
||||
comments: Comment[];
|
||||
posts: Post[];
|
||||
saved?: 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: { 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
src/shared/components/user-listing.tsx
Normal file
75
src/shared/components/user-listing.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
1109
src/shared/components/user.tsx
Normal file
1109
src/shared/components/user.tsx
Normal file
File diff suppressed because it is too large
Load diff
15
src/shared/env.ts
Normal file
15
src/shared/env.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
// TODO
|
||||
// const host = `${window.location.hostname}`;
|
||||
// const port = `${
|
||||
// window.location.port == '4444' ? '8536' : window.location.port
|
||||
// }`;
|
||||
// const endpoint = `${host}:${port}`;
|
||||
|
||||
// export const wsUri = `${
|
||||
// window.location.protocol == 'https:' ? 'wss://' : 'ws://'
|
||||
// }${endpoint}/api/v1/ws`;
|
||||
|
||||
const host = '192.168.50.60';
|
||||
const port = 8536;
|
||||
const endpoint = `${host}:${port}`;
|
||||
export const wsUri = `ws://${endpoint}/api/v1/ws`;
|
79
src/shared/i18next.ts
Normal file
79
src/shared/i18next.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import i18next from 'i18next';
|
||||
import { getLanguage } from './utils';
|
||||
import { en } from './translations/en';
|
||||
import { el } from './translations/el';
|
||||
import { eu } from './translations/eu';
|
||||
import { eo } from './translations/eo';
|
||||
import { es } from './translations/es';
|
||||
import { de } from './translations/de';
|
||||
import { fr } from './translations/fr';
|
||||
import { sv } from './translations/sv';
|
||||
import { ru } from './translations/ru';
|
||||
import { zh } from './translations/zh';
|
||||
import { nl } from './translations/nl';
|
||||
import { it } from './translations/it';
|
||||
import { fi } from './translations/fi';
|
||||
import { ca } from './translations/ca';
|
||||
import { fa } from './translations/fa';
|
||||
import { hi } from './translations/hi';
|
||||
import { pl } from './translations/pl';
|
||||
import { pt_BR } from './translations/pt_BR';
|
||||
import { ja } from './translations/ja';
|
||||
import { ka } from './translations/ka';
|
||||
import { gl } from './translations/gl';
|
||||
import { tr } from './translations/tr';
|
||||
import { hu } from './translations/hu';
|
||||
import { uk } from './translations/uk';
|
||||
import { sq } from './translations/sq';
|
||||
import { km } from './translations/km';
|
||||
import { ga } from './translations/ga';
|
||||
import { sr_Latn } from './translations/sr_Latn';
|
||||
|
||||
// https://github.com/nimbusec-oss/inferno-i18next/blob/master/tests/T.test.js#L66
|
||||
const resources = {
|
||||
en,
|
||||
el,
|
||||
eu,
|
||||
eo,
|
||||
es,
|
||||
ka,
|
||||
hi,
|
||||
de,
|
||||
zh,
|
||||
fr,
|
||||
sv,
|
||||
ru,
|
||||
nl,
|
||||
it,
|
||||
fi,
|
||||
ca,
|
||||
fa,
|
||||
pl,
|
||||
pt_BR,
|
||||
ja,
|
||||
gl,
|
||||
tr,
|
||||
hu,
|
||||
uk,
|
||||
sq,
|
||||
km,
|
||||
ga,
|
||||
sr_Latn,
|
||||
};
|
||||
|
||||
function format(value: any, format: any, lng: any): any {
|
||||
return format === 'uppercase' ? value.toUpperCase() : value;
|
||||
}
|
||||
|
||||
i18next.init({
|
||||
debug: false,
|
||||
// load: 'languageOnly',
|
||||
|
||||
// initImmediate: false,
|
||||
lng: getLanguage(),
|
||||
fallbackLng: 'en',
|
||||
resources,
|
||||
interpolation: { format },
|
||||
});
|
||||
|
||||
export { i18next as i18n, resources };
|
28
src/shared/interfaces.ts
Normal file
28
src/shared/interfaces.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
export enum CommentSortType {
|
||||
Hot,
|
||||
Top,
|
||||
New,
|
||||
Old,
|
||||
}
|
||||
|
||||
export enum CommentViewType {
|
||||
Tree,
|
||||
Chat,
|
||||
}
|
||||
|
||||
export enum DataType {
|
||||
Post,
|
||||
Comment,
|
||||
}
|
||||
|
||||
export enum BanType {
|
||||
Community,
|
||||
Site,
|
||||
}
|
||||
|
||||
export enum UserDetailsView {
|
||||
Overview,
|
||||
Comments,
|
||||
Posts,
|
||||
Saved,
|
||||
}
|
77
src/shared/routes.ts
Normal file
77
src/shared/routes.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { BrowserRouter, Route, Switch } from 'inferno-router';
|
||||
import { IRouteProps } from 'inferno-router/dist/Route';
|
||||
import { Main } from './components/main';
|
||||
import { Navbar } from './components/navbar';
|
||||
import { Footer } from './components/footer';
|
||||
import { Login } from './components/login';
|
||||
import { CreatePost } from './components/create-post';
|
||||
import { CreateCommunity } from './components/create-community';
|
||||
import { CreatePrivateMessage } from './components/create-private-message';
|
||||
import { PasswordChange } from './components/password_change';
|
||||
import { Post } from './components/post';
|
||||
import { Community } from './components/community';
|
||||
import { Communities } from './components/communities';
|
||||
import { User } from './components/user';
|
||||
import { Modlog } from './components/modlog';
|
||||
import { Setup } from './components/setup';
|
||||
import { AdminSettings } from './components/admin-settings';
|
||||
import { Inbox } from './components/inbox';
|
||||
import { Search } from './components/search';
|
||||
import { Sponsors } from './components/sponsors';
|
||||
import { Instances } from './components/instances';
|
||||
|
||||
export const routes: IRouteProps[] = [
|
||||
{ exact: true, path: `/`, component: Main },
|
||||
{
|
||||
path: `/home/data_type/:data_type/listing_type/:listing_type/sort/:sort/page/:page`,
|
||||
component: Main,
|
||||
},
|
||||
{ path: `/login`, component: Login },
|
||||
{ path: `/create_post`, component: CreatePost },
|
||||
{ path: `/create_community`, component: CreateCommunity },
|
||||
{
|
||||
path: `/create_private_message`,
|
||||
component: CreatePrivateMessage,
|
||||
},
|
||||
{
|
||||
path: `/communities/page/:page`,
|
||||
component: Communities,
|
||||
},
|
||||
{ path: `/communities`, component: Communities },
|
||||
{
|
||||
path: `/post/:id/comment/:comment_id`,
|
||||
component: Post,
|
||||
},
|
||||
{ path: `/post/:id`, component: Post },
|
||||
{
|
||||
path: `/c/:name/data_type/:data_type/sort/:sort/page/:page`,
|
||||
component: Community,
|
||||
},
|
||||
{ path: `/community/:id`, component: Community },
|
||||
{ path: `/c/:name`, component: Community },
|
||||
{
|
||||
path: `/u/:username/view/:view/sort/:sort/page/:page`,
|
||||
component: User,
|
||||
},
|
||||
{ path: `/user/:id`, component: User },
|
||||
{ path: `/u/:username`, component: User },
|
||||
{ path: `/inbox`, component: Inbox },
|
||||
{
|
||||
path: `/modlog/community/:community_id`,
|
||||
component: Modlog,
|
||||
},
|
||||
{ path: `/modlog`, component: Modlog },
|
||||
{ path: `/setup`, component: Setup },
|
||||
{ path: `/admin`, component: AdminSettings },
|
||||
{
|
||||
path: `/search/q/:q/type/:type/sort/:sort/page/:page`,
|
||||
component: Search,
|
||||
},
|
||||
{ path: `/search`, component: Search },
|
||||
{ path: `/sponsors`, component: Sponsors },
|
||||
{
|
||||
path: `/password_change/:token`,
|
||||
component: PasswordChange,
|
||||
},
|
||||
{ path: `/instances`, component: Instances },
|
||||
];
|
59
src/shared/services/UserService.ts
Normal file
59
src/shared/services/UserService.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
// import Cookies from 'js-cookie';
|
||||
import IsomorphicCookie from 'isomorphic-cookie';
|
||||
import { User, LoginResponse } from 'lemmy-js-client';
|
||||
import { setTheme } from '../utils';
|
||||
import jwt_decode from 'jwt-decode';
|
||||
import { Subject, BehaviorSubject } from 'rxjs';
|
||||
|
||||
interface Claims {
|
||||
id: number;
|
||||
iss: string;
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
private static _instance: UserService;
|
||||
public user: User;
|
||||
public claims: Claims;
|
||||
public jwtSub: Subject<string> = new Subject<string>();
|
||||
public unreadCountSub: BehaviorSubject<number> = new BehaviorSubject<number>(
|
||||
0
|
||||
);
|
||||
|
||||
private constructor() {
|
||||
let jwt = IsomorphicCookie.load('jwt');
|
||||
if (jwt) {
|
||||
this.setClaims(jwt);
|
||||
} else {
|
||||
setTheme();
|
||||
console.log('No JWT cookie found.');
|
||||
}
|
||||
}
|
||||
|
||||
public login(res: LoginResponse) {
|
||||
this.setClaims(res.jwt);
|
||||
IsomorphicCookie.save('jwt', res.jwt, { expires: 365 });
|
||||
console.log('jwt cookie set');
|
||||
}
|
||||
|
||||
public logout() {
|
||||
this.claims = undefined;
|
||||
this.user = undefined;
|
||||
IsomorphicCookie.remove('jwt');
|
||||
setTheme();
|
||||
this.jwtSub.next();
|
||||
console.log('Logged out.');
|
||||
}
|
||||
|
||||
public get auth(): string {
|
||||
return IsomorphicCookie.load('jwt');
|
||||
}
|
||||
|
||||
private setClaims(jwt: string) {
|
||||
this.claims = jwt_decode(jwt);
|
||||
this.jwtSub.next(jwt);
|
||||
}
|
||||
|
||||
public static get Instance() {
|
||||
return this._instance || (this._instance = new this());
|
||||
}
|
||||
}
|
409
src/shared/services/WebSocketService.ts
Normal file
409
src/shared/services/WebSocketService.ts
Normal file
|
@ -0,0 +1,409 @@
|
|||
import { wsUri } from '../env';
|
||||
import {
|
||||
LemmyWebsocket,
|
||||
LoginForm,
|
||||
RegisterForm,
|
||||
CommunityForm,
|
||||
DeleteCommunityForm,
|
||||
RemoveCommunityForm,
|
||||
PostForm,
|
||||
DeletePostForm,
|
||||
RemovePostForm,
|
||||
LockPostForm,
|
||||
StickyPostForm,
|
||||
SavePostForm,
|
||||
CommentForm,
|
||||
DeleteCommentForm,
|
||||
RemoveCommentForm,
|
||||
MarkCommentAsReadForm,
|
||||
SaveCommentForm,
|
||||
CommentLikeForm,
|
||||
GetPostForm,
|
||||
GetPostsForm,
|
||||
CreatePostLikeForm,
|
||||
GetCommunityForm,
|
||||
FollowCommunityForm,
|
||||
GetFollowedCommunitiesForm,
|
||||
GetUserDetailsForm,
|
||||
ListCommunitiesForm,
|
||||
GetModlogForm,
|
||||
BanFromCommunityForm,
|
||||
AddModToCommunityForm,
|
||||
TransferCommunityForm,
|
||||
AddAdminForm,
|
||||
TransferSiteForm,
|
||||
BanUserForm,
|
||||
SiteForm,
|
||||
UserView,
|
||||
GetRepliesForm,
|
||||
GetUserMentionsForm,
|
||||
MarkUserMentionAsReadForm,
|
||||
SearchForm,
|
||||
UserSettingsForm,
|
||||
DeleteAccountForm,
|
||||
PasswordResetForm,
|
||||
PasswordChangeForm,
|
||||
PrivateMessageForm,
|
||||
EditPrivateMessageForm,
|
||||
DeletePrivateMessageForm,
|
||||
MarkPrivateMessageAsReadForm,
|
||||
GetPrivateMessagesForm,
|
||||
GetCommentsForm,
|
||||
UserJoinForm,
|
||||
GetSiteConfig,
|
||||
GetSiteForm,
|
||||
SiteConfigForm,
|
||||
MarkAllAsReadForm,
|
||||
WebSocketJsonResponse,
|
||||
} from 'lemmy-js-client';
|
||||
import { UserService } from './';
|
||||
import { i18n } from '../i18next';
|
||||
import { toast, isBrowser } from '../utils';
|
||||
import { Observable } from 'rxjs';
|
||||
import { share } from 'rxjs/operators';
|
||||
import WebSocket from 'isomorphic-ws';
|
||||
import {
|
||||
Options as WSOptions,
|
||||
default as ReconnectingWebSocket,
|
||||
} from 'reconnecting-websocket';
|
||||
|
||||
export class WebSocketService {
|
||||
private static _instance: WebSocketService;
|
||||
public ws: ReconnectingWebSocket;
|
||||
public wsOptions: WSOptions = {
|
||||
WebSocket: WebSocket,
|
||||
connectionTimeout: 1000,
|
||||
maxRetries: 10,
|
||||
};
|
||||
public subject: Observable<any>;
|
||||
|
||||
public admins: UserView[];
|
||||
public banned: UserView[];
|
||||
private client = new LemmyWebsocket();
|
||||
|
||||
private constructor() {
|
||||
this.ws = new ReconnectingWebSocket(wsUri, [], this.wsOptions);
|
||||
let firstConnect = true;
|
||||
|
||||
this.subject = Observable.create((obs: any) => {
|
||||
this.ws.onmessage = e => {
|
||||
obs.next(JSON.parse(e.data));
|
||||
};
|
||||
this.ws.onopen = () => {
|
||||
console.log(`Connected to ${wsUri}`);
|
||||
|
||||
if (!firstConnect) {
|
||||
let res: WebSocketJsonResponse = {
|
||||
reconnect: true,
|
||||
};
|
||||
obs.next(res);
|
||||
}
|
||||
|
||||
firstConnect = false;
|
||||
};
|
||||
}).pipe(share());
|
||||
}
|
||||
|
||||
public static get Instance() {
|
||||
return this._instance || (this._instance = new this());
|
||||
}
|
||||
|
||||
public userJoin() {
|
||||
let form: UserJoinForm = { auth: UserService.Instance.auth };
|
||||
this.ws.send(this.client.userJoin(form));
|
||||
}
|
||||
|
||||
public login(form: LoginForm) {
|
||||
this.ws.send(this.client.login(form));
|
||||
}
|
||||
|
||||
public register(form: RegisterForm) {
|
||||
this.ws.send(this.client.register(form));
|
||||
}
|
||||
|
||||
public getCaptcha() {
|
||||
this.ws.send(this.client.getCaptcha());
|
||||
}
|
||||
|
||||
public createCommunity(form: CommunityForm) {
|
||||
this.setAuth(form); // TODO all these setauths at some point would be good to make required
|
||||
this.ws.send(this.client.createCommunity(form));
|
||||
}
|
||||
|
||||
public editCommunity(form: CommunityForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.editCommunity(form));
|
||||
}
|
||||
|
||||
public deleteCommunity(form: DeleteCommunityForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.deleteCommunity(form));
|
||||
}
|
||||
|
||||
public removeCommunity(form: RemoveCommunityForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.removeCommunity(form));
|
||||
}
|
||||
|
||||
public followCommunity(form: FollowCommunityForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.followCommunity(form));
|
||||
}
|
||||
|
||||
public listCommunities(form: ListCommunitiesForm) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.client.listCommunities(form));
|
||||
}
|
||||
|
||||
public getFollowedCommunities() {
|
||||
let form: GetFollowedCommunitiesForm = { auth: UserService.Instance.auth };
|
||||
this.ws.send(this.client.getFollowedCommunities(form));
|
||||
}
|
||||
|
||||
public listCategories() {
|
||||
this.ws.send(this.client.listCategories());
|
||||
}
|
||||
|
||||
public createPost(form: PostForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.createPost(form));
|
||||
}
|
||||
|
||||
public getPost(form: GetPostForm) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.client.getPost(form));
|
||||
}
|
||||
|
||||
public getCommunity(form: GetCommunityForm) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.client.getCommunity(form));
|
||||
}
|
||||
|
||||
public createComment(form: CommentForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.createComment(form));
|
||||
}
|
||||
|
||||
public editComment(form: CommentForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.editComment(form));
|
||||
}
|
||||
|
||||
public deleteComment(form: DeleteCommentForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.deleteComment(form));
|
||||
}
|
||||
|
||||
public removeComment(form: RemoveCommentForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.removeComment(form));
|
||||
}
|
||||
|
||||
public markCommentAsRead(form: MarkCommentAsReadForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.markCommentAsRead(form));
|
||||
}
|
||||
|
||||
public likeComment(form: CommentLikeForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.likeComment(form));
|
||||
}
|
||||
|
||||
public saveComment(form: SaveCommentForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.saveComment(form));
|
||||
}
|
||||
|
||||
public getPosts(form: GetPostsForm) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.client.getPosts(form));
|
||||
}
|
||||
|
||||
public getComments(form: GetCommentsForm) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.client.getComments(form));
|
||||
}
|
||||
|
||||
public likePost(form: CreatePostLikeForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.likePost(form));
|
||||
}
|
||||
|
||||
public editPost(form: PostForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.editPost(form));
|
||||
}
|
||||
|
||||
public deletePost(form: DeletePostForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.deletePost(form));
|
||||
}
|
||||
|
||||
public removePost(form: RemovePostForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.removePost(form));
|
||||
}
|
||||
|
||||
public lockPost(form: LockPostForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.lockPost(form));
|
||||
}
|
||||
|
||||
public stickyPost(form: StickyPostForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.stickyPost(form));
|
||||
}
|
||||
|
||||
public savePost(form: SavePostForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.savePost(form));
|
||||
}
|
||||
|
||||
public banFromCommunity(form: BanFromCommunityForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.banFromCommunity(form));
|
||||
}
|
||||
|
||||
public addModToCommunity(form: AddModToCommunityForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.addModToCommunity(form));
|
||||
}
|
||||
|
||||
public transferCommunity(form: TransferCommunityForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.transferCommunity(form));
|
||||
}
|
||||
|
||||
public transferSite(form: TransferSiteForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.transferSite(form));
|
||||
}
|
||||
|
||||
public banUser(form: BanUserForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.banUser(form));
|
||||
}
|
||||
|
||||
public addAdmin(form: AddAdminForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.addAdmin(form));
|
||||
}
|
||||
|
||||
public getUserDetails(form: GetUserDetailsForm) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.client.getUserDetails(form));
|
||||
}
|
||||
|
||||
public getReplies(form: GetRepliesForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.getReplies(form));
|
||||
}
|
||||
|
||||
public getUserMentions(form: GetUserMentionsForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.getUserMentions(form));
|
||||
}
|
||||
|
||||
public markUserMentionAsRead(form: MarkUserMentionAsReadForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.markUserMentionAsRead(form));
|
||||
}
|
||||
|
||||
public getModlog(form: GetModlogForm) {
|
||||
this.ws.send(this.client.getModlog(form));
|
||||
}
|
||||
|
||||
public createSite(form: SiteForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.createSite(form));
|
||||
}
|
||||
|
||||
public editSite(form: SiteForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.editSite(form));
|
||||
}
|
||||
|
||||
public getSite(form: GetSiteForm = {}) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.client.getSite(form));
|
||||
}
|
||||
|
||||
public getSiteConfig() {
|
||||
let form: GetSiteConfig = {};
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.getSiteConfig(form));
|
||||
}
|
||||
|
||||
public search(form: SearchForm) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.client.search(form));
|
||||
}
|
||||
|
||||
public markAllAsRead() {
|
||||
let form: MarkAllAsReadForm;
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.markAllAsRead(form));
|
||||
}
|
||||
|
||||
public saveUserSettings(form: UserSettingsForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.saveUserSettings(form));
|
||||
}
|
||||
|
||||
public deleteAccount(form: DeleteAccountForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.deleteAccount(form));
|
||||
}
|
||||
|
||||
public passwordReset(form: PasswordResetForm) {
|
||||
this.ws.send(this.client.passwordReset(form));
|
||||
}
|
||||
|
||||
public passwordChange(form: PasswordChangeForm) {
|
||||
this.ws.send(this.client.passwordChange(form));
|
||||
}
|
||||
|
||||
public createPrivateMessage(form: PrivateMessageForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.createPrivateMessage(form));
|
||||
}
|
||||
|
||||
public editPrivateMessage(form: EditPrivateMessageForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.editPrivateMessage(form));
|
||||
}
|
||||
|
||||
public deletePrivateMessage(form: DeletePrivateMessageForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.deletePrivateMessage(form));
|
||||
}
|
||||
|
||||
public markPrivateMessageAsRead(form: MarkPrivateMessageAsReadForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.markPrivateMessageAsRead(form));
|
||||
}
|
||||
|
||||
public getPrivateMessages(form: GetPrivateMessagesForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.getPrivateMessages(form));
|
||||
}
|
||||
|
||||
public saveSiteConfig(form: SiteConfigForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.client.saveSiteConfig(form));
|
||||
}
|
||||
|
||||
private setAuth(obj: any, throwErr: boolean = true) {
|
||||
obj.auth = UserService.Instance.auth;
|
||||
if (obj.auth == null && throwErr) {
|
||||
toast(i18n.t('not_logged_in'), 'danger');
|
||||
throw 'Not logged in';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBrowser()) {
|
||||
window.onbeforeunload = () => {
|
||||
WebSocketService.Instance.ws.close();
|
||||
};
|
||||
}
|
2
src/shared/services/index.ts
Normal file
2
src/shared/services/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { UserService } from './UserService';
|
||||
export { WebSocketService } from './WebSocketService';
|
1111
src/shared/utils.ts
Normal file
1111
src/shared/utils.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,12 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "esnext",
|
||||
"sourceMap": true,
|
||||
"jsx": "preserve",
|
||||
"importHelpers": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"exclude": ["node_modules", "fuse.ts"]
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "esnext",
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"jsx": "preserve",
|
||||
"importHelpers": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"exclude": ["node_modules", "fuse.ts"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue