Partly functioning fuse-box, but moving te webpack now.

This commit is contained in:
Dessalines 2020-09-06 11:15:25 -05:00
parent 3125477c7b
commit 2eee936026
66 changed files with 19040 additions and 365 deletions

3
.eslintignore Normal file
View file

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

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ test/data/result.json
package-lock.json package-lock.json
*.orig *.orig
src/shared/translations

124
fuse.ts
View file

@ -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("server", () => { Sparky.task('copy-assets', () =>
/**Workaround. Should be fixed */ Sparky.src('**/**.*', { base: 'src/assets' }).dest('dist/assets')
fuse.opts = fuseServerOptions; );
fuse Sparky.task('server', () => {
.bundle("server/bundle") /**Workaround. Should be fixed */
.watch("**") fuse.opts = fuseServerOptions;
.target("server@esnext") fuse
.instructions("> [server/index.tsx]") .bundle('server/bundle')
.completed(proc => { .watch('**')
proc.require({ .target('server@esnext')
// tslint:disable-next-line:no-shadowed-variable .instructions('> [server/index.tsx]')
close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown() .completed(proc => {
}); proc.require({
// tslint:disable-next-line:no-shadowed-variable
close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown(),
}); });
});
}); });
Sparky.task("dev", ["&clean", "&config", "&client", "&server"], () => { Sparky.task(
fuse.run(); 'dev',
}); ['&clean', '&config', '&client', '&server', '&copy-assets'],
() => {
fuse.run();
}
);

27
generate_translations.js Normal file
View 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);
}
});
});

View file

@ -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"

View file

@ -1,10 +0,0 @@
.text {
color: brown;
font-size: 25pt;
}
.count {
color: blue;
}
.button {
color: red;
}

View file

@ -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');
}
}

View file

@ -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>
);
}
}

View file

@ -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>
);
}
}

View file

@ -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>
);
}
}

View file

@ -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'));

View file

@ -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, () => {

View 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);
}
}
}

View 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> */}
</>
);
}
}

View 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>
);
}
}

View 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 });
}
}

View 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 });
}
}
}
}
}

File diff suppressed because it is too large Load diff

View 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;
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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>
);
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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));
}
}

View 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 });
}
}
}

View 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);
}
}

View 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();
}
}

View 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
);
}
}

View 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);
}
}
}

View 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);
}
}

View 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);
}
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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');
}
}

View 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();
});
}
}
}

View 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);
}
}
}

View 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);
}
}
}

File diff suppressed because it is too large Load diff

View 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;
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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'));
}
}
}

View 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);
}
}
}

View 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('/');
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}
}

File diff suppressed because one or more lines are too long

View 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,
});
}
}
}

View 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} />}
</>
);
}
}

File diff suppressed because it is too large Load diff

15
src/shared/env.ts Normal file
View 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
View 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
View 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
View 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 },
];

View 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());
}
}

View 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();
};
}

View file

@ -0,0 +1,2 @@
export { UserService } from './UserService';
export { WebSocketService } from './WebSocketService';

1111
src/shared/utils.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -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"]
} }

2804
yarn.lock

File diff suppressed because it is too large Load diff