mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2024-11-25 13:51:13 +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
|
package-lock.json
|
||||||
*.orig
|
*.orig
|
||||||
|
|
||||||
|
src/shared/translations
|
||||||
|
|
||||||
|
|
158
fuse.ts
158
fuse.ts
|
@ -1,76 +1,82 @@
|
||||||
import { CSSPlugin, FuseBox, FuseBoxOptions, Sparky } from "fuse-box";
|
import { CSSPlugin, FuseBox, FuseBoxOptions, Sparky } from 'fuse-box';
|
||||||
import path = require("path");
|
import path = require('path');
|
||||||
import TsTransformClasscat from "ts-transform-classcat";
|
import TsTransformClasscat from 'ts-transform-classcat';
|
||||||
import TsTransformInferno from "ts-transform-inferno";
|
import TsTransformInferno from 'ts-transform-inferno';
|
||||||
/**
|
/**
|
||||||
* Some of FuseBoxOptions overrides by ts config (module, target, etc)
|
* Some of FuseBoxOptions overrides by ts config (module, target, etc)
|
||||||
* https://fuse-box.org/page/working-with-targets
|
* https://fuse-box.org/page/working-with-targets
|
||||||
*/
|
*/
|
||||||
let fuse: FuseBox;
|
let fuse: FuseBox;
|
||||||
const fuseOptions: FuseBoxOptions = {
|
const fuseOptions: FuseBoxOptions = {
|
||||||
homeDir: "./src",
|
homeDir: './src',
|
||||||
output: "dist/$name.js",
|
output: 'dist/$name.js',
|
||||||
sourceMaps: { inline: false, vendor: false },
|
sourceMaps: { inline: false, vendor: false },
|
||||||
/**
|
/**
|
||||||
* Custom TypeScript Transformers (compile Inferno tsx to ts)
|
* Custom TypeScript Transformers (compile Inferno tsx to ts)
|
||||||
*/
|
*/
|
||||||
transformers: {
|
transformers: {
|
||||||
before: [TsTransformClasscat(), TsTransformInferno()]
|
before: [TsTransformClasscat(), TsTransformInferno()],
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
const fuseClientOptions: FuseBoxOptions = {
|
const fuseClientOptions: FuseBoxOptions = {
|
||||||
...fuseOptions,
|
...fuseOptions,
|
||||||
plugins: [
|
plugins: [
|
||||||
/**
|
/**
|
||||||
* https://fuse-box.org/page/css-resource-plugin
|
* https://fuse-box.org/page/css-resource-plugin
|
||||||
* Compile Sass {SassPlugin()}
|
* Compile Sass {SassPlugin()}
|
||||||
* Make .css files modules-like (allow import them like modules) {CSSModules}
|
* 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}
|
* Make .css files modules like and allow import it from node_modules too {CSSResourcePlugin}
|
||||||
* Use them all and bundle with {CSSPlugin}
|
* Use them all and bundle with {CSSPlugin}
|
||||||
* */
|
* */
|
||||||
CSSPlugin()
|
CSSPlugin(),
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
const fuseServerOptions: FuseBoxOptions = {
|
const fuseServerOptions: FuseBoxOptions = {
|
||||||
...fuseOptions
|
...fuseOptions,
|
||||||
};
|
};
|
||||||
Sparky.task("clean", () => {
|
|
||||||
/**Clean distribute (dist) folder */
|
Sparky.task('clean', () => {
|
||||||
Sparky.src("dist")
|
/**Clean distribute (dist) folder */
|
||||||
.clean("dist")
|
Sparky.src('dist/').clean('dist/');
|
||||||
.exec();
|
});
|
||||||
});
|
Sparky.task('config', () => {
|
||||||
Sparky.task("config", () => {
|
fuse = FuseBox.init(fuseOptions);
|
||||||
fuse = FuseBox.init(fuseOptions);
|
fuse.dev();
|
||||||
fuse.dev();
|
});
|
||||||
});
|
Sparky.task('test', ['&clean', '&config'], () => {
|
||||||
Sparky.task("test", ["&clean", "&config"], () => {
|
fuse.bundle('client/bundle').test('[**/**.test.tsx]', null);
|
||||||
fuse.bundle("client/bundle").test("[**/**.test.tsx]", null);
|
});
|
||||||
});
|
Sparky.task('client', () => {
|
||||||
Sparky.task("client", () => {
|
fuse.opts = fuseClientOptions;
|
||||||
fuse.opts = fuseClientOptions;
|
fuse
|
||||||
fuse
|
.bundle('client/bundle')
|
||||||
.bundle("client/bundle")
|
.target('browser@esnext')
|
||||||
.target("browser@esnext")
|
.watch('client/**')
|
||||||
.watch("client/**")
|
.hmr()
|
||||||
.hmr()
|
.instructions('> client/index.tsx');
|
||||||
.instructions("> client/index.tsx");
|
});
|
||||||
});
|
Sparky.task('copy-assets', () =>
|
||||||
Sparky.task("server", () => {
|
Sparky.src('**/**.*', { base: 'src/assets' }).dest('dist/assets')
|
||||||
/**Workaround. Should be fixed */
|
);
|
||||||
fuse.opts = fuseServerOptions;
|
Sparky.task('server', () => {
|
||||||
fuse
|
/**Workaround. Should be fixed */
|
||||||
.bundle("server/bundle")
|
fuse.opts = fuseServerOptions;
|
||||||
.watch("**")
|
fuse
|
||||||
.target("server@esnext")
|
.bundle('server/bundle')
|
||||||
.instructions("> [server/index.tsx]")
|
.watch('**')
|
||||||
.completed(proc => {
|
.target('server@esnext')
|
||||||
proc.require({
|
.instructions('> [server/index.tsx]')
|
||||||
// tslint:disable-next-line:no-shadowed-variable
|
.completed(proc => {
|
||||||
close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown()
|
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();
|
});
|
||||||
});
|
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>",
|
"author": "Dessalines <tyhou13@gmx.com>",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"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",
|
"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"
|
"test": "node -r ts-node/register --inspect fuse.ts test"
|
||||||
},
|
},
|
||||||
"repository": "https://github.com/LemmyNet/lemmy-isomorphic-ui",
|
"repository": "https://github.com/LemmyNet/lemmy-isomorphic-ui",
|
||||||
"dependencies": {
|
"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",
|
"cookie-parser": "^1.4.3",
|
||||||
|
"emoji-short-name": "^1.0.0",
|
||||||
"express": "~4.17.1",
|
"express": "~4.17.1",
|
||||||
|
"i18next": "^19.4.1",
|
||||||
"inferno": "^7.4.3",
|
"inferno": "^7.4.3",
|
||||||
"inferno-create-element": "^7.4.3",
|
"inferno-create-element": "^7.4.3",
|
||||||
|
"inferno-helmet": "^5.2.1",
|
||||||
"inferno-hydrate": "^7.4.3",
|
"inferno-hydrate": "^7.4.3",
|
||||||
|
"inferno-i18next": "github:nimbusec-oss/inferno-i18next#semver:^7.4.2",
|
||||||
"inferno-router": "^7.4.3",
|
"inferno-router": "^7.4.3",
|
||||||
"inferno-server": "^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": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "^1.4.1",
|
"@types/cookie-parser": "^1.4.1",
|
||||||
|
@ -26,6 +54,7 @@
|
||||||
"@types/jest": "^26.0.10",
|
"@types/jest": "^26.0.10",
|
||||||
"@types/node": "^14.6.0",
|
"@types/node": "^14.6.0",
|
||||||
"@types/serialize-javascript": "^4.0.0",
|
"@types/serialize-javascript": "^4.0.0",
|
||||||
|
"classcat": "^4.1.0",
|
||||||
"enzyme": "^3.3.0",
|
"enzyme": "^3.3.0",
|
||||||
"enzyme-adapter-inferno": "^1.3.0",
|
"enzyme-adapter-inferno": "^1.3.0",
|
||||||
"eslint": "^7.5.0",
|
"eslint": "^7.5.0",
|
||||||
|
@ -38,15 +67,19 @@
|
||||||
"jest": "^26.4.2",
|
"jest": "^26.4.2",
|
||||||
"jsdom": "16.4.0",
|
"jsdom": "16.4.0",
|
||||||
"jsdom-global": "3.0.2",
|
"jsdom-global": "3.0.2",
|
||||||
|
"lemmy-js-client": "^1.0.8",
|
||||||
"lint-staged": "^10.1.3",
|
"lint-staged": "^10.1.3",
|
||||||
"prettier": "^2.0.4",
|
"prettier": "^2.0.4",
|
||||||
"sortpack": "^2.1.4",
|
"sortpack": "^2.1.4",
|
||||||
"ts-node": "^9.0.0",
|
"ts-node": "^9.0.0",
|
||||||
"ts-transform-classcat": "^1.0.0",
|
"ts-transform-classcat": "^1.0.0",
|
||||||
"ts-transform-inferno": "^4.0.3",
|
"ts-transform-inferno": "^4.0.3",
|
||||||
"tslint-react-recommended": "^1.0.15",
|
|
||||||
"typescript": "^4.0.2"
|
"typescript": "^4.0.2"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.9.0"
|
||||||
|
},
|
||||||
|
"engineStrict": true,
|
||||||
"husky": {
|
"husky": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"pre-commit": "lint-staged"
|
"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 { Component } from 'inferno';
|
||||||
import { hydrate } from 'inferno-hydrate';
|
import { hydrate } from 'inferno-hydrate';
|
||||||
import { BrowserRouter } from 'inferno-router';
|
import { BrowserRouter } from 'inferno-router';
|
||||||
import App from './components/App/App';
|
import { App } from '../shared/components/app';
|
||||||
import { initDevTools } from 'inferno-devtools';
|
/* import { initDevTools } from 'inferno-devtools'; */
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
@ -14,8 +14,8 @@ declare global {
|
||||||
|
|
||||||
const wrapper = (
|
const wrapper = (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App name={window.isoData.name} />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
initDevTools();
|
/* initDevTools(); */
|
||||||
hydrate(wrapper, document.getElementById('root'));
|
hydrate(wrapper, document.getElementById('root'));
|
||||||
|
|
|
@ -1,28 +1,35 @@
|
||||||
import cookieParser = require('cookie-parser');
|
import cookieParser = require('cookie-parser');
|
||||||
import * as serialize from 'serialize-javascript';
|
import serialize from 'serialize-javascript';
|
||||||
import * as express from 'express';
|
import express from 'express';
|
||||||
import { StaticRouter } from 'inferno-router';
|
import { StaticRouter } from 'inferno-router';
|
||||||
import { renderToString } from 'inferno-server';
|
import { renderToString } from 'inferno-server';
|
||||||
|
import { matchPath } from 'inferno-router';
|
||||||
import path = require('path');
|
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 server = express();
|
||||||
const port = 1234;
|
const port = 1234;
|
||||||
|
|
||||||
server.use(express.json());
|
server.use(express.json());
|
||||||
server.use(express.urlencoded({ extended: false }));
|
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('/static', express.static(path.resolve('./dist/client')));
|
||||||
|
|
||||||
server.use(cookieParser());
|
server.use(cookieParser());
|
||||||
|
|
||||||
server.get('/*', (req, res) => {
|
server.get('/*', (req, res) => {
|
||||||
|
const activeRoute = routes.find(route => matchPath(req.url, route)) || {};
|
||||||
|
console.log(activeRoute);
|
||||||
const context = {} as any;
|
const context = {} as any;
|
||||||
const isoData = {
|
const isoData = {
|
||||||
name: 'fishing sux',
|
name: 'fishing sux',
|
||||||
};
|
};
|
||||||
|
let auth: string = IsomorphicCookie.load('jwt', req);
|
||||||
|
|
||||||
const wrapper = (
|
const wrapper = (
|
||||||
<StaticRouter location={req.url} context={context}>
|
<StaticRouter location={req.url} context={context}>
|
||||||
<App name={isoData.name} />
|
<App />
|
||||||
</StaticRouter>
|
</StaticRouter>
|
||||||
);
|
);
|
||||||
if (context.url) {
|
if (context.url) {
|
||||||
|
@ -30,17 +37,38 @@ server.get('/*', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
res.send(`
|
res.send(`
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>My Universal App</title>
|
<script>window.isoData = ${serialize(isoData)}</script>
|
||||||
<script>window.isoData = ${serialize(isoData)}</script>
|
|
||||||
</head>
|
<!-- Required meta tags -->
|
||||||
<body>
|
<meta name="Description" content="Lemmy">
|
||||||
<div id='root'>${renderToString(wrapper)}</div>
|
<meta charset="utf-8">
|
||||||
<script src='./static/bundle.js'></script>
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
</body>
|
|
||||||
</html>
|
<!-- 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, () => {
|
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": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"jsx": "preserve",
|
"inlineSources": true,
|
||||||
"importHelpers": true,
|
"jsx": "preserve",
|
||||||
"emitDecoratorMetadata": true,
|
"importHelpers": true,
|
||||||
"experimentalDecorators": true
|
"emitDecoratorMetadata": true,
|
||||||
},
|
"experimentalDecorators": true,
|
||||||
"exclude": ["node_modules", "fuse.ts"]
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "fuse.ts"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue