From: Dessalines <dessalines@users.noreply.github.com> Date: Tue, 21 Jun 2022 21:42:29 +0000 (-0400) Subject: Adding option types 2 (#689) X-Git-Url: http://these/git/%22https:/join-lemmy.org/static/sneer-club-logo.svg?a=commitdiff_plain;h=d905c91e1b0487f3bcfd0595f6cab62906f1aead;p=lemmy-ui.git Adding option types 2 (#689) * Not working, because of wrong API types. * Adding Rust-style Result and Option types. - Fixes #646 * Updating to use new lemmy-js-client with Options. --- diff --git a/package.json b/package.json index a76700b..f6ea01c 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,13 @@ }, "devDependencies": { "@babel/core": "^7.17.9", + "@babel/plugin-proposal-decorators": "^7.18.2", "@babel/plugin-transform-runtime": "^7.17.0", "@babel/plugin-transform-typescript": "^7.16.1", "@babel/preset-env": "7.16.11", "@babel/preset-typescript": "^7.16.0", "@babel/runtime": "^7.17.9", + "@sniptt/monads": "^0.5.10", "@types/autosize": "^4.0.0", "@types/express": "^4.17.13", "@types/node": "^17.0.29", @@ -67,6 +69,7 @@ "babel-plugin-inferno": "^6.4.0", "bootstrap": "^5.1.3", "bootswatch": "^5.1.3", + "class-transformer": "^0.5.1", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^10.2.4", "css-loader": "^6.7.1", @@ -74,7 +77,7 @@ "eslint-plugin-prettier": "^4.0.0", "husky": "^7.0.4", "import-sort-style-module": "^6.0.0", - "lemmy-js-client": "0.16.4", + "lemmy-js-client": "0.17.0-rc.30", "lint-staged": "^12.4.1", "mini-css-extract-plugin": "^2.6.0", "node-fetch": "^2.6.1", @@ -82,6 +85,7 @@ "prettier-plugin-import-sort": "^0.0.7", "prettier-plugin-organize-imports": "^2.3.4", "prettier-plugin-packagejson": "^2.2.17", + "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "run-node-webpack-plugin": "^1.3.0", "sass-loader": "^12.6.0", diff --git a/src/client/index.tsx b/src/client/index.tsx index d5773b4..3838dca 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,14 +1,15 @@ import { hydrate } from "inferno-hydrate"; import { BrowserRouter } from "inferno-router"; +import { GetSiteResponse } from "lemmy-js-client"; import { App } from "../shared/components/app/app"; -import { initializeSite } from "../shared/utils"; +import { convertWindowJson, initializeSite } from "../shared/utils"; -const site = window.isoData.site_res; +const site = convertWindowJson(GetSiteResponse, window.isoData.site_res); initializeSite(site); const wrapper = ( <BrowserRouter> - <App siteRes={window.isoData.site_res} /> + <App /> </BrowserRouter> ); diff --git a/src/server/index.tsx b/src/server/index.tsx index 65f7308..374fb03 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,3 +1,5 @@ +import { None, Option } from "@sniptt/monads"; +import { serialize as serializeO } from "class-transformer"; import express from "express"; import fs from "fs"; import { IncomingHttpHeaders } from "http"; @@ -5,7 +7,7 @@ import { Helmet } from "inferno-helmet"; import { matchPath, StaticRouter } from "inferno-router"; import { renderToString } from "inferno-server"; import IsomorphicCookie from "isomorphic-cookie"; -import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client"; +import { GetSite, GetSiteResponse, LemmyHttp, toOption } from "lemmy-js-client"; import path from "path"; import process from "process"; import serialize from "serialize-javascript"; @@ -18,7 +20,7 @@ import { IsoData, } from "../shared/interfaces"; import { routes } from "../shared/routes"; -import { initializeSite, setOptionalAuth } from "../shared/utils"; +import { initializeSite } from "../shared/utils"; const server = express(); const [hostname, port] = process.env["LEMMY_UI_HOST"] @@ -115,10 +117,9 @@ server.get("/*", async (req, res) => { try { const activeRoute = routes.find(route => matchPath(req.path, route)) || {}; const context = {} as any; - let auth: string = IsomorphicCookie.load("jwt", req); + let auth: Option<string> = toOption(IsomorphicCookie.load("jwt", req)); - let getSiteForm: GetSite = {}; - setOptionalAuth(getSiteForm, auth); + let getSiteForm = new GetSite({ auth }); let promises: Promise<any>[] = []; @@ -138,8 +139,8 @@ server.get("/*", async (req, res) => { console.error( "Incorrect JWT token, skipping auth so frontend can remove jwt cookie" ); - delete getSiteForm.auth; - delete initialFetchReq.auth; + getSiteForm.auth = None; + initialFetchReq.auth = None; try_site = await initialFetchReq.client.getSite(getSiteForm); } let site: GetSiteResponse = try_site; @@ -170,7 +171,7 @@ server.get("/*", async (req, res) => { const wrapper = ( <StaticRouter location={req.url} context={isoData}> - <App siteRes={isoData.site_res} /> + <App /> </StaticRouter> ); if (context.url) { @@ -194,7 +195,7 @@ server.get("/*", async (req, res) => { <!DOCTYPE html> <html ${helmet.htmlAttributes.toString()} lang="en"> <head> - <script>window.isoData = ${serialize(isoData)}</script> + <script>window.isoData = ${serializeO(isoData)}</script> <script>window.lemmyConfig = ${serialize(config)}</script> <!-- A remote debugging utility for mobile --> diff --git a/src/shared/components/app/app.tsx b/src/shared/components/app/app.tsx index 9ddff69..72119a8 100644 --- a/src/shared/components/app/app.tsx +++ b/src/shared/components/app/app.tsx @@ -2,53 +2,46 @@ import { Component } from "inferno"; import { Helmet } from "inferno-helmet"; import { Provider } from "inferno-i18next-dess"; import { Route, Switch } from "inferno-router"; -import { GetSiteResponse } from "lemmy-js-client"; import { i18n } from "../../i18next"; import { routes } from "../../routes"; -import { favIconPngUrl, favIconUrl } from "../../utils"; +import { favIconPngUrl, favIconUrl, setIsoData } from "../../utils"; import { Footer } from "./footer"; import { Navbar } from "./navbar"; import { NoMatch } from "./no-match"; import "./styles.scss"; import { Theme } from "./theme"; -export interface AppProps { - siteRes: GetSiteResponse; -} - -export class App extends Component<AppProps, any> { +export class App extends Component<any, any> { + private isoData = setIsoData(this.context); constructor(props: any, context: any) { super(props, context); } render() { - let siteRes = this.props.siteRes; + let siteRes = this.isoData.site_res; + let siteView = siteRes.site_view; + return ( <> <Provider i18next={i18n}> <div> - <Theme - myUserInfo={siteRes.my_user} - defaultTheme={siteRes?.site_view?.site?.default_theme} - /> - {siteRes && - siteRes.site_view && - this.props.siteRes.site_view.site.icon && ( - <Helmet> - <link - id="favicon" - rel="shortcut icon" - type="image/x-icon" - href={this.props.siteRes.site_view.site.icon || favIconUrl} - /> - <link - rel="apple-touch-icon" - href={ - this.props.siteRes.site_view.site.icon || favIconPngUrl - } - /> - </Helmet> - )} - <Navbar site_res={this.props.siteRes} /> + <Theme defaultTheme={siteView.map(s => s.site.default_theme)} /> + {siteView + .andThen(s => s.site.icon) + .match({ + some: icon => ( + <Helmet> + <link + id="favicon" + rel="shortcut icon" + type="image/x-icon" + href={icon || favIconUrl} + /> + <link rel="apple-touch-icon" href={icon || favIconPngUrl} /> + </Helmet> + ), + none: <></>, + })} + <Navbar siteRes={siteRes} /> <div class="mt-4 p-0 fl-1"> <Switch> {routes.map(({ path, exact, component: C, ...rest }) => ( @@ -62,7 +55,7 @@ export class App extends Component<AppProps, any> { <Route render={props => <NoMatch {...props} />} /> </Switch> </div> - <Footer site={this.props.siteRes} /> + <Footer site={siteRes} /> </div> </Provider> </> diff --git a/src/shared/components/app/footer.tsx b/src/shared/components/app/footer.tsx index 601551b..e5e1db5 100644 --- a/src/shared/components/app/footer.tsx +++ b/src/shared/components/app/footer.tsx @@ -32,7 +32,9 @@ export class Footer extends Component<FooterProps, any> { {i18n.t("modlog")} </NavLink> </li> - {this.props.site.site_view?.site.legal_information && ( + {this.props.site.site_view + .andThen(s => s.site.legal_information) + .isSome() && ( <li className="nav-item"> <NavLink className="nav-link" to="/legal"> {i18n.t("legal_information")} diff --git a/src/shared/components/app/navbar.tsx b/src/shared/components/app/navbar.tsx index 9ab3b65..604a90a 100644 --- a/src/shared/components/app/navbar.tsx +++ b/src/shared/components/app/navbar.tsx @@ -1,3 +1,4 @@ +import { None, Some } from "@sniptt/monads"; import { Component, createRef, linkEvent, RefObject } from "inferno"; import { NavLink } from "inferno-router"; import { @@ -11,12 +12,15 @@ import { GetUnreadRegistrationApplicationCountResponse, PrivateMessageResponse, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; import { - authField, + amAdmin, + auth, donateLemmyUrl, getLanguages, isBrowser, @@ -27,19 +31,16 @@ import { showAvatars, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { Icon } from "../common/icon"; import { PictrsImage } from "../common/pictrs-image"; interface NavbarProps { - site_res: GetSiteResponse; + siteRes: GetSiteResponse; } interface NavbarState { - isLoggedIn: boolean; expanded: boolean; unreadInboxCount: number; unreadReportCount: number; @@ -58,7 +59,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> { private unreadApplicationCountSub: Subscription; private searchTextField: RefObject<HTMLInputElement>; emptyState: NavbarState = { - isLoggedIn: !!this.props.site_res.my_user, unreadInboxCount: 0, unreadReportCount: 0, unreadApplicationCount: 0, @@ -81,18 +81,13 @@ export class Navbar extends Component<NavbarProps, NavbarState> { // Subscribe to jwt changes if (isBrowser()) { this.searchTextField = createRef(); - console.log(`isLoggedIn = ${this.state.isLoggedIn}`); // On the first load, check the unreads - if (this.state.isLoggedIn == false) { - // setTheme(data.my_user.theme, true); - // i18n.changeLanguage(getLanguage()); - // i18n.changeLanguage('de'); - } else { + if (UserService.Instance.myUserInfo.isSome()) { this.requestNotificationPermission(); WebSocketService.Instance.send( wsClient.userJoin({ - auth: authField(), + auth: auth().unwrap(), }) ); this.fetchUnreads(); @@ -100,13 +95,11 @@ export class Navbar extends Component<NavbarProps, NavbarState> { this.userSub = UserService.Instance.jwtSub.subscribe(res => { // A login - if (res !== undefined) { + if (res.isSome()) { this.requestNotificationPermission(); WebSocketService.Instance.send( - wsClient.getSite({ auth: authField() }) + wsClient.getSite({ auth: res.map(r => r.jwt) }) ); - } else { - this.setState({ isLoggedIn: false }); } }); @@ -157,32 +150,28 @@ export class Navbar extends Component<NavbarProps, NavbarState> { // TODO class active corresponding to current page navbar() { - let myUserInfo = - UserService.Instance.myUserInfo || this.props.site_res.my_user; - let person = myUserInfo?.local_user_view.person; return ( - <nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3"> + <nav class="navbar navbar-expand-md navbar-light shadow-sm p-0 px-3"> <div class="container"> - {this.props.site_res.site_view && ( - <NavLink - to="/" - onMouseUp={linkEvent(this, this.handleHideExpandNavbar)} - title={ - this.props.site_res.site_view.site.description || - this.props.site_res.site_view.site.name - } - className="d-flex align-items-center navbar-brand mr-md-3" - > - {this.props.site_res.site_view.site.icon && showAvatars() && ( - <PictrsImage - src={this.props.site_res.site_view.site.icon} - icon - /> - )} - {this.props.site_res.site_view.site.name} - </NavLink> - )} - {this.state.isLoggedIn && ( + {this.props.siteRes.site_view.match({ + some: siteView => ( + <NavLink + to="/" + onMouseUp={linkEvent(this, this.handleHideExpandNavbar)} + title={siteView.site.description.unwrapOr(siteView.site.name)} + className="d-flex align-items-center navbar-brand mr-md-3" + > + {siteView.site.icon.match({ + some: icon => + showAvatars() && <PictrsImage src={icon} icon />, + none: <></>, + })} + {siteView.site.name} + </NavLink> + ), + none: <></>, + })} + {UserService.Instance.myUserInfo.isSome() && ( <> <ul class="navbar-nav ml-auto"> <li className="nav-item"> @@ -204,7 +193,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </NavLink> </li> </ul> - {UserService.Instance.myUserInfo?.moderates.length > 0 && ( + {this.moderatesSomething && ( <ul class="navbar-nav ml-1"> <li className="nav-item"> <NavLink @@ -226,8 +215,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </li> </ul> )} - {UserService.Instance.myUserInfo?.local_user_view.person - .admin && ( + {this.amAdmin && ( <ul class="navbar-nav ml-1"> <li className="nav-item"> <NavLink @@ -312,7 +300,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </li> </ul> <ul class="navbar-nav my-2"> - {this.canAdmin && ( + {this.amAdmin && ( <li className="nav-item"> <NavLink to="/admin" @@ -358,7 +346,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </button> </form> )} - {this.state.isLoggedIn ? ( + {UserService.Instance.myUserInfo.isSome() ? ( <> <ul class="navbar-nav my-2"> <li className="nav-item"> @@ -380,7 +368,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </NavLink> </li> </ul> - {UserService.Instance.myUserInfo?.moderates.length > 0 && ( + {this.moderatesSomething && ( <ul class="navbar-nav my-2"> <li className="nav-item"> <NavLink @@ -402,8 +390,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </li> </ul> )} - {UserService.Instance.myUserInfo?.local_user_view.person - .admin && ( + {this.amAdmin && ( <ul class="navbar-nav my-2"> <li className="nav-item"> <NavLink @@ -427,69 +414,81 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </li> </ul> )} - <ul class="navbar-nav"> - <li class="nav-item dropdown"> - <button - class="nav-link btn btn-link dropdown-toggle" - onClick={linkEvent(this, this.handleToggleDropdown)} - id="navbarDropdown" - role="button" - aria-expanded="false" - > - <span> - {person.avatar && showAvatars() && ( - <PictrsImage src={person.avatar} icon /> - )} - {person.display_name - ? person.display_name - : person.name} - </span> - </button> - {this.state.showDropdown && ( - <div - class="dropdown-content" - onMouseLeave={linkEvent( - this, - this.handleToggleDropdown - )} - > - <li className="nav-item"> - <NavLink - to={`/u/${UserService.Instance.myUserInfo.local_user_view.person.name}`} - className="nav-link" - title={i18n.t("profile")} - > - <Icon icon="user" classes="mr-1" /> - {i18n.t("profile")} - </NavLink> - </li> - <li className="nav-item"> - <NavLink - to="/settings" - className="nav-link" - title={i18n.t("settings")} - > - <Icon icon="settings" classes="mr-1" /> - {i18n.t("settings")} - </NavLink> - </li> - <li> - <hr class="dropdown-divider" /> - </li> - <li className="nav-item"> + {UserService.Instance.myUserInfo + .map(m => m.local_user_view.person) + .match({ + some: person => ( + <ul class="navbar-nav"> + <li class="nav-item dropdown"> <button - className="nav-link btn btn-link" - onClick={linkEvent(this, this.handleLogoutClick)} - title="test" + class="nav-link btn btn-link dropdown-toggle" + onClick={linkEvent(this, this.handleToggleDropdown)} + id="navbarDropdown" + role="button" + aria-expanded="false" > - <Icon icon="log-out" classes="mr-1" /> - {i18n.t("logout")} + <span> + {showAvatars() && + person.avatar.match({ + some: avatar => ( + <PictrsImage src={avatar} icon /> + ), + none: <></>, + })} + {person.display_name.unwrapOr(person.name)} + </span> </button> + {this.state.showDropdown && ( + <div + class="dropdown-content" + onMouseLeave={linkEvent( + this, + this.handleToggleDropdown + )} + > + <li className="nav-item"> + <NavLink + to={`/u/${person.name}`} + className="nav-link" + title={i18n.t("profile")} + > + <Icon icon="user" classes="mr-1" /> + {i18n.t("profile")} + </NavLink> + </li> + <li className="nav-item"> + <NavLink + to="/settings" + className="nav-link" + title={i18n.t("settings")} + > + <Icon icon="settings" classes="mr-1" /> + {i18n.t("settings")} + </NavLink> + </li> + <li> + <hr class="dropdown-divider" /> + </li> + <li className="nav-item"> + <button + className="nav-link btn btn-link" + onClick={linkEvent( + this, + this.handleLogoutClick + )} + title="test" + > + <Icon icon="log-out" classes="mr-1" /> + {i18n.t("logout")} + </button> + </li> + </div> + )} </li> - </div> - )} - </li> - </ul> + </ul> + ), + none: <></>, + })} </> ) : ( <ul class="navbar-nav my-2"> @@ -521,6 +520,24 @@ export class Navbar extends Component<NavbarProps, NavbarState> { ); } + get moderatesSomething(): boolean { + return ( + UserService.Instance.myUserInfo.map(m => m.moderates).unwrapOr([]) + .length > 0 + ); + } + + get amAdmin(): boolean { + return amAdmin(Some(this.props.siteRes.admins)); + } + + get canCreateCommunity(): boolean { + let adminOnly = this.props.siteRes.site_view + .map(s => s.site.community_creation_admin_only) + .unwrapOr(false); + return !adminOnly || this.amAdmin; + } + handleToggleExpandNavbar(i: Navbar) { i.state.expanded = !i.state.expanded; i.setState(i.state); @@ -561,8 +578,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> { handleLogoutClick(i: Navbar) { i.setState({ showDropdown: false, expanded: false }); UserService.Instance.logout(); - window.location.href = "/"; - location.reload(); } handleToggleDropdown(i: Navbar) { @@ -576,98 +591,117 @@ export class Navbar extends Component<NavbarProps, NavbarState> { if (msg.error) { if (msg.error == "not_logged_in") { UserService.Instance.logout(); - location.reload(); } return; } else if (msg.reconnect) { console.log(i18n.t("websocket_reconnected")); - WebSocketService.Instance.send( - wsClient.userJoin({ - auth: authField(), - }) - ); - this.fetchUnreads(); + if (UserService.Instance.myUserInfo.isSome()) { + WebSocketService.Instance.send( + wsClient.userJoin({ + auth: auth().unwrap(), + }) + ); + this.fetchUnreads(); + } } else if (op == UserOperation.GetUnreadCount) { - let data = wsJsonToRes<GetUnreadCountResponse>(msg).data; + let data = wsJsonToRes<GetUnreadCountResponse>( + msg, + GetUnreadCountResponse + ); this.state.unreadInboxCount = data.replies + data.mentions + data.private_messages; this.setState(this.state); this.sendUnreadCount(); } else if (op == UserOperation.GetReportCount) { - let data = wsJsonToRes<GetReportCountResponse>(msg).data; + let data = wsJsonToRes<GetReportCountResponse>( + msg, + GetReportCountResponse + ); this.state.unreadReportCount = data.post_reports + data.comment_reports; this.setState(this.state); this.sendReportUnread(); } else if (op == UserOperation.GetUnreadRegistrationApplicationCount) { - let data = - wsJsonToRes<GetUnreadRegistrationApplicationCountResponse>(msg).data; + let data = wsJsonToRes<GetUnreadRegistrationApplicationCountResponse>( + msg, + GetUnreadRegistrationApplicationCountResponse + ); this.state.unreadApplicationCount = data.registration_applications; this.setState(this.state); this.sendApplicationUnread(); } else if (op == UserOperation.GetSite) { // This is only called on a successful login - let data = wsJsonToRes<GetSiteResponse>(msg).data; - console.log(data.my_user); + let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse); UserService.Instance.myUserInfo = data.my_user; - setTheme( - UserService.Instance.myUserInfo.local_user_view.local_user.theme - ); - i18n.changeLanguage(getLanguages()[0]); - this.state.isLoggedIn = true; - this.setState(this.state); - } else if (op == UserOperation.CreateComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; - - if (this.state.isLoggedIn) { - if ( - data.recipient_ids.includes( - UserService.Instance.myUserInfo.local_user_view.local_user.id - ) - ) { - this.state.unreadInboxCount++; + UserService.Instance.myUserInfo.match({ + some: mui => { + setTheme(mui.local_user_view.local_user.theme); + i18n.changeLanguage(getLanguages()[0]); this.setState(this.state); - this.sendUnreadCount(); - notifyComment(data.comment_view, this.context.router); - } - } + }, + none: void 0, + }); + } else if (op == UserOperation.CreateComment) { + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + + UserService.Instance.myUserInfo.match({ + some: mui => { + if (data.recipient_ids.includes(mui.local_user_view.local_user.id)) { + this.state.unreadInboxCount++; + this.setState(this.state); + this.sendUnreadCount(); + notifyComment(data.comment_view, this.context.router); + } + }, + none: void 0, + }); } else if (op == UserOperation.CreatePrivateMessage) { - let data = wsJsonToRes<PrivateMessageResponse>(msg).data; - - if (this.state.isLoggedIn) { - if ( - data.private_message_view.recipient.id == - UserService.Instance.myUserInfo.local_user_view.person.id - ) { - this.state.unreadInboxCount++; - this.setState(this.state); - this.sendUnreadCount(); - notifyPrivateMessage(data.private_message_view, this.context.router); - } - } + let data = wsJsonToRes<PrivateMessageResponse>( + msg, + PrivateMessageResponse + ); + + UserService.Instance.myUserInfo.match({ + some: mui => { + if ( + data.private_message_view.recipient.id == + mui.local_user_view.person.id + ) { + this.state.unreadInboxCount++; + this.setState(this.state); + this.sendUnreadCount(); + notifyPrivateMessage( + data.private_message_view, + this.context.router + ); + } + }, + none: void 0, + }); } } fetchUnreads() { console.log("Fetching inbox unreads..."); - let unreadForm: GetUnreadCount = { - auth: authField(), - }; + let unreadForm = new GetUnreadCount({ + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm)); console.log("Fetching reports..."); - let reportCountForm: GetReportCount = { - auth: authField(), - }; + let reportCountForm = new GetReportCount({ + community_id: None, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm)); - if (UserService.Instance.myUserInfo?.local_user_view.person.admin) { + if (this.amAdmin) { console.log("Fetching applications..."); - let applicationCountForm: GetUnreadRegistrationApplicationCount = { - auth: authField(), - }; + let applicationCountForm = new GetUnreadRegistrationApplicationCount({ + auth: auth().unwrap(), + }); WebSocketService.Instance.send( wsClient.getUnreadRegistrationApplicationCount(applicationCountForm) ); @@ -694,23 +728,8 @@ export class Navbar extends Component<NavbarProps, NavbarState> { ); } - get canAdmin(): boolean { - return ( - UserService.Instance.myUserInfo && - this.props.site_res.admins - .map(a => a.person.id) - .includes(UserService.Instance.myUserInfo.local_user_view.person.id) - ); - } - - get canCreateCommunity(): boolean { - let adminOnly = - this.props.site_res.site_view?.site.community_creation_admin_only; - return !adminOnly || this.canAdmin; - } - requestNotificationPermission() { - if (UserService.Instance.myUserInfo) { + if (UserService.Instance.myUserInfo.isSome()) { document.addEventListener("DOMContentLoaded", function () { if (!Notification) { toast(i18n.t("notifications_error"), "danger"); diff --git a/src/shared/components/app/theme.tsx b/src/shared/components/app/theme.tsx index cdb7f7e..a30358f 100644 --- a/src/shared/components/app/theme.tsx +++ b/src/shared/components/app/theme.tsx @@ -1,16 +1,18 @@ +import { Option } from "@sniptt/monads"; import { Component } from "inferno"; import { Helmet } from "inferno-helmet"; -import { MyUserInfo } from "lemmy-js-client"; +import { UserService } from "../../services"; interface Props { - myUserInfo: MyUserInfo | undefined; - defaultTheme?: string; + defaultTheme: Option<string>; } export class Theme extends Component<Props> { render() { - let user = this.props.myUserInfo; - let hasTheme = user && user.local_user_view.local_user.theme !== "browser"; + let user = UserService.Instance.myUserInfo; + let hasTheme = user + .map(m => m.local_user_view.local_user.theme !== "browser") + .unwrapOr(false); if (hasTheme) { return ( @@ -18,20 +20,22 @@ export class Theme extends Component<Props> { <link rel="stylesheet" type="text/css" - href={`/css/themes/${user.local_user_view.local_user.theme}.css`} + href={`/css/themes/${ + user.unwrap().local_user_view.local_user.theme + }.css`} /> </Helmet> ); } else if ( - this.props.defaultTheme != null && - this.props.defaultTheme != "browser" + this.props.defaultTheme.isSome() && + this.props.defaultTheme.unwrap() != "browser" ) { return ( <Helmet> <link rel="stylesheet" type="text/css" - href={`/css/themes/${this.props.defaultTheme}.css`} + href={`/css/themes/${this.props.defaultTheme.unwrap()}.css`} /> </Helmet> ); diff --git a/src/shared/components/comment/comment-form.tsx b/src/shared/components/comment/comment-form.tsx index 58cabeb..7abf39b 100644 --- a/src/shared/components/comment/comment-form.tsx +++ b/src/shared/components/comment/comment-form.tsx @@ -1,3 +1,4 @@ +import { Either, None, Option, Some } from "@sniptt/monads"; import { Component } from "inferno"; import { T } from "inferno-i18next-dess"; import { Link } from "inferno-router"; @@ -6,25 +7,27 @@ import { CreateComment, EditComment, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { CommentNode as CommentNodeI } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, capitalizeFirstLetter, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { Icon } from "../common/icon"; import { MarkdownTextArea } from "../common/markdown-textarea"; interface CommentFormProps { - postId?: number; - node?: CommentNodeI; // Can either be the parent, or the editable comment + /** + * Can either be the parent, or the editable comment. The right side is a postId. + */ + node: Either<CommentNodeI, number>; edit?: boolean; disabled?: boolean; focus?: boolean; @@ -34,19 +37,19 @@ interface CommentFormProps { interface CommentFormState { buttonTitle: string; finished: boolean; - formId: string; + formId: Option<string>; } export class CommentForm extends Component<CommentFormProps, CommentFormState> { private subscription: Subscription; private emptyState: CommentFormState = { - buttonTitle: !this.props.node + buttonTitle: this.props.node.isRight() ? capitalizeFirstLetter(i18n.t("post")) : this.props.edit ? capitalizeFirstLetter(i18n.t("save")) : capitalizeFirstLetter(i18n.t("reply")), finished: false, - formId: "empty_form", + formId: None, }; constructor(props: any, context: any) { @@ -66,23 +69,25 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { } render() { + let initialContent = this.props.node.match({ + left: node => + this.props.edit ? Some(node.comment_view.comment.content) : None, + right: () => None, + }); return ( <div class="mb-3"> - {UserService.Instance.myUserInfo ? ( + {UserService.Instance.myUserInfo.isSome() ? ( <MarkdownTextArea - initialContent={ - this.props.edit - ? this.props.node.comment_view.comment.content - : null - } - buttonTitle={this.state.buttonTitle} + initialContent={initialContent} + buttonTitle={Some(this.state.buttonTitle)} + maxLength={None} finished={this.state.finished} - replyType={!!this.props.node} + replyType={this.props.node.isLeft()} focus={this.props.focus} disabled={this.props.disabled} onSubmit={this.handleCommentSubmit} onReplyCancel={this.handleReplyCancel} - placeholder={i18n.t("comment_here")} + placeholder={Some(i18n.t("comment_here"))} /> ) : ( <div class="alert alert-warning" role="alert"> @@ -101,28 +106,40 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { handleCommentSubmit(msg: { val: string; formId: string }) { let content = msg.val; - this.state.formId = msg.formId; - - let node = this.props.node; - - if (this.props.edit) { - let form: EditComment = { - content, - form_id: this.state.formId, - comment_id: node.comment_view.comment.id, - auth: authField(), - }; - WebSocketService.Instance.send(wsClient.editComment(form)); - } else { - let form: CreateComment = { - content, - form_id: this.state.formId, - post_id: node ? node.comment_view.post.id : this.props.postId, - parent_id: node ? node.comment_view.comment.id : null, - auth: authField(), - }; - WebSocketService.Instance.send(wsClient.createComment(form)); - } + this.state.formId = Some(msg.formId); + + this.props.node.match({ + left: node => { + if (this.props.edit) { + let form = new EditComment({ + content, + form_id: this.state.formId, + comment_id: node.comment_view.comment.id, + auth: auth().unwrap(), + }); + WebSocketService.Instance.send(wsClient.editComment(form)); + } else { + let form = new CreateComment({ + content, + form_id: this.state.formId, + post_id: node.comment_view.post.id, + parent_id: Some(node.comment_view.comment.id), + auth: auth().unwrap(), + }); + WebSocketService.Instance.send(wsClient.createComment(form)); + } + }, + right: postId => { + let form = new CreateComment({ + content, + form_id: this.state.formId, + post_id: postId, + parent_id: None, + auth: auth().unwrap(), + }); + WebSocketService.Instance.send(wsClient.createComment(form)); + }, + }); this.setState(this.state); } @@ -135,15 +152,15 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { console.log(msg); // Only do the showing and hiding if logged in - if (UserService.Instance.myUserInfo) { + if (UserService.Instance.myUserInfo.isSome()) { if ( op == UserOperation.CreateComment || op == UserOperation.EditComment ) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); // This only finishes this form, if the randomly generated form_id matches the one received - if (this.state.formId == data.form_id) { + if (this.state.formId.unwrapOr("") == data.form_id.unwrapOr("")) { this.setState({ finished: true }); // Necessary because it broke tribute for some reason diff --git a/src/shared/components/comment/comment-node.tsx b/src/shared/components/comment/comment-node.tsx index d2a36ac..356a482 100644 --- a/src/shared/components/comment/comment-node.tsx +++ b/src/shared/components/comment/comment-node.tsx @@ -1,3 +1,4 @@ +import { Left, None, Option, Some } from "@sniptt/monads"; import classNames from "classnames"; import { Component, linkEvent } from "inferno"; import { Link } from "inferno-router"; @@ -18,6 +19,7 @@ import { PersonViewSafe, RemoveComment, SaveComment, + toUndefined, TransferCommunity, } from "lemmy-js-client"; import moment from "moment"; @@ -25,10 +27,13 @@ import { i18n } from "../../i18next"; import { BanType, CommentNode as CommentNodeI } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + amCommunityCreator, + auth, + canAdmin, canMod, colorList, futureDaysToUnixTime, + isAdmin, isBanned, isMod, mdToHtml, @@ -48,11 +53,11 @@ interface CommentNodeState { showReply: boolean; showEdit: boolean; showRemoveDialog: boolean; - removeReason: string; + removeReason: Option<string>; showBanDialog: boolean; removeData: boolean; - banReason: string; - banExpireDays: number; + banReason: Option<string>; + banExpireDays: Option<number>; banType: BanType; showConfirmTransferSite: boolean; showConfirmTransferCommunity: boolean; @@ -63,7 +68,7 @@ interface CommentNodeState { showAdvanced: boolean; showReportDialog: boolean; reportReason: string; - my_vote: number; + my_vote: Option<number>; score: number; upvotes: number; downvotes: number; @@ -74,16 +79,14 @@ interface CommentNodeState { interface CommentNodeProps { node: CommentNodeI; + moderators: Option<CommunityModeratorView[]>; + admins: Option<PersonViewSafe[]>; noBorder?: boolean; noIndent?: boolean; viewOnly?: boolean; locked?: boolean; markable?: boolean; showContext?: boolean; - moderators: CommunityModeratorView[]; - admins: PersonViewSafe[]; - // TODO is this necessary, can't I get it from the node itself? - postCreatorId?: number; showCommunity?: boolean; enableDownvotes: boolean; } @@ -93,11 +96,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { showReply: false, showEdit: false, showRemoveDialog: false, - removeReason: null, + removeReason: None, showBanDialog: false, removeData: false, - banReason: null, - banExpireDays: null, + banReason: None, + banExpireDays: None, banType: BanType.Community, collapsed: false, viewSource: false, @@ -143,10 +146,24 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { render() { let node = this.props.node; let cv = this.props.node.comment_view; + + let canMod_ = canMod( + this.props.moderators, + this.props.admins, + cv.creator.id + ); + let canAdmin_ = canAdmin(this.props.admins, cv.creator.id); + let isMod_ = isMod(this.props.moderators, cv.creator.id); + let isAdmin_ = isAdmin(this.props.admins, cv.creator.id); + let amCommunityCreator_ = amCommunityCreator( + this.props.moderators, + cv.creator.id + ); + return ( <div className={`comment ${ - cv.comment.parent_id && !this.props.noIndent ? "ml-1" : "" + cv.comment.parent_id.isSome() && !this.props.noIndent ? "ml-1" : "" }`} > <div @@ -156,24 +173,26 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } ${this.isCommentNew ? "mark" : ""}`} style={ !this.props.noIndent && - cv.comment.parent_id && + cv.comment.parent_id.isSome() && `border-left: 2px ${this.state.borderColor} solid !important` } > <div - class={`${!this.props.noIndent && cv.comment.parent_id && "ml-2"}`} + class={`${ + !this.props.noIndent && cv.comment.parent_id.isSome() && "ml-2" + }`} > <div class="d-flex flex-wrap align-items-center text-muted small"> <span class="mr-2"> <PersonListing person={cv.creator} /> </span> - {this.isMod && ( + {isMod_ && ( <div className="badge badge-light d-none d-sm-inline mr-2"> {i18n.t("mod")} </div> )} - {this.isAdmin && ( + {isAdmin_ && ( <div className="badge badge-light d-none d-sm-inline mr-2"> {i18n.t("admin")} </div> @@ -239,13 +258,16 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { </> )} <span> - <MomentTime data={cv.comment} /> + <MomentTime + published={cv.comment.published} + updated={cv.comment.updated} + /> </span> </div> {/* end of user row */} {this.state.showEdit && ( <CommentForm - node={node} + node={Left(node)} edit onReplyCancel={this.handleReplyCancel} disabled={this.props.locked} @@ -293,403 +315,412 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { )} </button> )} - {UserService.Instance.myUserInfo && !this.props.viewOnly && ( - <> - <button - className={`btn btn-link btn-animate ${ - this.state.my_vote == 1 ? "text-info" : "text-muted" - }`} - onClick={linkEvent(node, this.handleCommentUpvote)} - data-tippy-content={i18n.t("upvote")} - aria-label={i18n.t("upvote")} - > - <Icon icon="arrow-up1" classes="icon-inline" /> - {showScores() && - this.state.upvotes !== this.state.score && ( - <span class="ml-1"> - {numToSI(this.state.upvotes)} - </span> - )} - </button> - {this.props.enableDownvotes && ( + {UserService.Instance.myUserInfo.isSome() && + !this.props.viewOnly && ( + <> <button className={`btn btn-link btn-animate ${ - this.state.my_vote == -1 - ? "text-danger" + this.state.my_vote.unwrapOr(0) == 1 + ? "text-info" : "text-muted" }`} - onClick={linkEvent(node, this.handleCommentDownvote)} - data-tippy-content={i18n.t("downvote")} - aria-label={i18n.t("downvote")} + onClick={linkEvent(node, this.handleCommentUpvote)} + data-tippy-content={i18n.t("upvote")} + aria-label={i18n.t("upvote")} > - <Icon icon="arrow-down1" classes="icon-inline" /> + <Icon icon="arrow-up1" classes="icon-inline" /> {showScores() && this.state.upvotes !== this.state.score && ( <span class="ml-1"> - {numToSI(this.state.downvotes)} + {numToSI(this.state.upvotes)} </span> )} </button> - )} - <button - class="btn btn-link btn-animate text-muted" - onClick={linkEvent(this, this.handleReplyClick)} - data-tippy-content={i18n.t("reply")} - aria-label={i18n.t("reply")} - > - <Icon icon="reply1" classes="icon-inline" /> - </button> - {!this.state.showAdvanced ? ( - <button - className="btn btn-link btn-animate text-muted" - onClick={linkEvent(this, this.handleShowAdvanced)} - data-tippy-content={i18n.t("more")} - aria-label={i18n.t("more")} - > - <Icon icon="more-vertical" classes="icon-inline" /> - </button> - ) : ( - <> - {!this.myComment && ( - <> - <button class="btn btn-link btn-animate"> - <Link - className="text-muted" - to={`/create_private_message/recipient/${cv.creator.id}`} - title={i18n.t("message").toLowerCase()} - > - <Icon icon="mail" /> - </Link> - </button> - <button - class="btn btn-link btn-animate text-muted" - onClick={linkEvent( - this, - this.handleShowReportDialog - )} - data-tippy-content={i18n.t( - "show_report_dialog" - )} - aria-label={i18n.t("show_report_dialog")} - > - <Icon icon="flag" /> - </button> - <button - class="btn btn-link btn-animate text-muted" - onClick={linkEvent( - this, - this.handleBlockUserClick - )} - data-tippy-content={i18n.t("block_user")} - aria-label={i18n.t("block_user")} - > - <Icon icon="slash" /> - </button> - </> - )} + {this.props.enableDownvotes && ( <button - class="btn btn-link btn-animate text-muted" + className={`btn btn-link btn-animate ${ + this.state.my_vote.unwrapOr(0) == -1 + ? "text-danger" + : "text-muted" + }`} onClick={linkEvent( - this, - this.handleSaveCommentClick + node, + this.handleCommentDownvote )} - data-tippy-content={ - cv.saved ? i18n.t("unsave") : i18n.t("save") - } - aria-label={ - cv.saved ? i18n.t("unsave") : i18n.t("save") - } + data-tippy-content={i18n.t("downvote")} + aria-label={i18n.t("downvote")} > - {this.state.saveLoading ? ( - this.loadingIcon - ) : ( - <Icon - icon="star" - classes={`icon-inline ${ - cv.saved && "text-warning" - }`} - /> - )} + <Icon icon="arrow-down1" classes="icon-inline" /> + {showScores() && + this.state.upvotes !== this.state.score && ( + <span class="ml-1"> + {numToSI(this.state.downvotes)} + </span> + )} </button> + )} + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent(this, this.handleReplyClick)} + data-tippy-content={i18n.t("reply")} + aria-label={i18n.t("reply")} + > + <Icon icon="reply1" classes="icon-inline" /> + </button> + {!this.state.showAdvanced ? ( <button className="btn btn-link btn-animate text-muted" - onClick={linkEvent(this, this.handleViewSource)} - data-tippy-content={i18n.t("view_source")} - aria-label={i18n.t("view_source")} + onClick={linkEvent(this, this.handleShowAdvanced)} + data-tippy-content={i18n.t("more")} + aria-label={i18n.t("more")} > - <Icon - icon="file-text" - classes={`icon-inline ${ - this.state.viewSource && "text-success" - }`} - /> + <Icon icon="more-vertical" classes="icon-inline" /> </button> - {this.myComment && ( - <> - <button - class="btn btn-link btn-animate text-muted" - onClick={linkEvent(this, this.handleEditClick)} - data-tippy-content={i18n.t("edit")} - aria-label={i18n.t("edit")} - > - <Icon icon="edit" classes="icon-inline" /> - </button> - <button - class="btn btn-link btn-animate text-muted" - onClick={linkEvent( - this, - this.handleDeleteClick - )} - data-tippy-content={ - !cv.comment.deleted - ? i18n.t("delete") - : i18n.t("restore") - } - aria-label={ - !cv.comment.deleted - ? i18n.t("delete") - : i18n.t("restore") - } - > + ) : ( + <> + {!this.myComment && ( + <> + <button class="btn btn-link btn-animate"> + <Link + className="text-muted" + to={`/create_private_message/recipient/${cv.creator.id}`} + title={i18n.t("message").toLowerCase()} + > + <Icon icon="mail" /> + </Link> + </button> + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent( + this, + this.handleShowReportDialog + )} + data-tippy-content={i18n.t( + "show_report_dialog" + )} + aria-label={i18n.t("show_report_dialog")} + > + <Icon icon="flag" /> + </button> + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent( + this, + this.handleBlockUserClick + )} + data-tippy-content={i18n.t("block_user")} + aria-label={i18n.t("block_user")} + > + <Icon icon="slash" /> + </button> + </> + )} + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent( + this, + this.handleSaveCommentClick + )} + data-tippy-content={ + cv.saved ? i18n.t("unsave") : i18n.t("save") + } + aria-label={ + cv.saved ? i18n.t("unsave") : i18n.t("save") + } + > + {this.state.saveLoading ? ( + this.loadingIcon + ) : ( <Icon - icon="trash" + icon="star" classes={`icon-inline ${ - cv.comment.deleted && "text-danger" + cv.saved && "text-warning" }`} /> - </button> - </> - )} - {/* Admins and mods can remove comments */} - {(this.canMod || this.canAdmin) && ( - <> - {!cv.comment.removed ? ( + )} + </button> + <button + className="btn btn-link btn-animate text-muted" + onClick={linkEvent(this, this.handleViewSource)} + data-tippy-content={i18n.t("view_source")} + aria-label={i18n.t("view_source")} + > + <Icon + icon="file-text" + classes={`icon-inline ${ + this.state.viewSource && "text-success" + }`} + /> + </button> + {this.myComment && ( + <> <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleModRemoveShow + this.handleEditClick )} - aria-label={i18n.t("remove")} + data-tippy-content={i18n.t("edit")} + aria-label={i18n.t("edit")} > - {i18n.t("remove")} + <Icon icon="edit" classes="icon-inline" /> </button> - ) : ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleModRemoveSubmit + this.handleDeleteClick )} - aria-label={i18n.t("restore")} + data-tippy-content={ + !cv.comment.deleted + ? i18n.t("delete") + : i18n.t("restore") + } + aria-label={ + !cv.comment.deleted + ? i18n.t("delete") + : i18n.t("restore") + } > - {i18n.t("restore")} + <Icon + icon="trash" + classes={`icon-inline ${ + cv.comment.deleted && "text-danger" + }`} + /> </button> - )} - </> - )} - {/* Mods can ban from community, and appoint as mods to community */} - {this.canMod && ( - <> - {!this.isMod && - (!cv.creator_banned_from_community ? ( + </> + )} + {/* Admins and mods can remove comments */} + {(canMod_ || canAdmin_) && ( + <> + {!cv.comment.removed ? ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleModBanFromCommunityShow + this.handleModRemoveShow )} - aria-label={i18n.t("ban")} + aria-label={i18n.t("remove")} > - {i18n.t("ban")} + {i18n.t("remove")} </button> ) : ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleModBanFromCommunitySubmit + this.handleModRemoveSubmit )} - aria-label={i18n.t("unban")} + aria-label={i18n.t("restore")} > - {i18n.t("unban")} + {i18n.t("restore")} </button> - ))} - {!cv.creator_banned_from_community && - (!this.state.showConfirmAppointAsMod ? ( - <button - class="btn btn-link btn-animate text-muted" - onClick={linkEvent( - this, - this.handleShowConfirmAppointAsMod - )} - aria-label={ - this.isMod - ? i18n.t("remove_as_mod") - : i18n.t("appoint_as_mod") - } - > - {this.isMod - ? i18n.t("remove_as_mod") - : i18n.t("appoint_as_mod")} - </button> - ) : ( - <> + )} + </> + )} + {/* Mods can ban from community, and appoint as mods to community */} + {canMod_ && ( + <> + {!isMod_ && + (!cv.creator_banned_from_community ? ( <button class="btn btn-link btn-animate text-muted" - aria-label={i18n.t("are_you_sure")} + onClick={linkEvent( + this, + this.handleModBanFromCommunityShow + )} + aria-label={i18n.t("ban")} > - {i18n.t("are_you_sure")} + {i18n.t("ban")} </button> + ) : ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleAddModToCommunity + this.handleModBanFromCommunitySubmit )} - aria-label={i18n.t("yes")} + aria-label={i18n.t("unban")} > - {i18n.t("yes")} + {i18n.t("unban")} </button> + ))} + {!cv.creator_banned_from_community && + (!this.state.showConfirmAppointAsMod ? ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleCancelConfirmAppointAsMod + this.handleShowConfirmAppointAsMod )} - aria-label={i18n.t("no")} + aria-label={ + isMod_ + ? i18n.t("remove_as_mod") + : i18n.t("appoint_as_mod") + } > - {i18n.t("no")} + {isMod_ + ? i18n.t("remove_as_mod") + : i18n.t("appoint_as_mod")} </button> - </> - ))} - </> - )} - {/* Community creators and admins can transfer community to another mod */} - {(this.amCommunityCreator || this.canAdmin) && - this.isMod && - cv.creator.local && - (!this.state.showConfirmTransferCommunity ? ( - <button - class="btn btn-link btn-animate text-muted" - onClick={linkEvent( - this, - this.handleShowConfirmTransferCommunity - )} - aria-label={i18n.t("transfer_community")} - > - {i18n.t("transfer_community")} - </button> - ) : ( - <> - <button - class="btn btn-link btn-animate text-muted" - aria-label={i18n.t("are_you_sure")} - > - {i18n.t("are_you_sure")} - </button> - <button - class="btn btn-link btn-animate text-muted" - onClick={linkEvent( - this, - this.handleTransferCommunity - )} - aria-label={i18n.t("yes")} - > - {i18n.t("yes")} - </button> + ) : ( + <> + <button + class="btn btn-link btn-animate text-muted" + aria-label={i18n.t("are_you_sure")} + > + {i18n.t("are_you_sure")} + </button> + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent( + this, + this.handleAddModToCommunity + )} + aria-label={i18n.t("yes")} + > + {i18n.t("yes")} + </button> + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent( + this, + this.handleCancelConfirmAppointAsMod + )} + aria-label={i18n.t("no")} + > + {i18n.t("no")} + </button> + </> + ))} + </> + )} + {/* Community creators and admins can transfer community to another mod */} + {(amCommunityCreator_ || canAdmin_) && + isMod_ && + cv.creator.local && + (!this.state.showConfirmTransferCommunity ? ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this - .handleCancelShowConfirmTransferCommunity + this.handleShowConfirmTransferCommunity )} - aria-label={i18n.t("no")} + aria-label={i18n.t("transfer_community")} > - {i18n.t("no")} + {i18n.t("transfer_community")} </button> - </> - ))} - {/* Admins can ban from all, and appoint other admins */} - {this.canAdmin && ( - <> - {!this.isAdmin && - (!isBanned(cv.creator) ? ( + ) : ( + <> <button class="btn btn-link btn-animate text-muted" - onClick={linkEvent( - this, - this.handleModBanShow - )} - aria-label={i18n.t("ban_from_site")} + aria-label={i18n.t("are_you_sure")} > - {i18n.t("ban_from_site")} + {i18n.t("are_you_sure")} </button> - ) : ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleModBanSubmit + this.handleTransferCommunity )} - aria-label={i18n.t("unban_from_site")} + aria-label={i18n.t("yes")} > - {i18n.t("unban_from_site")} + {i18n.t("yes")} </button> - ))} - {!isBanned(cv.creator) && - cv.creator.local && - (!this.state.showConfirmAppointAsAdmin ? ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleShowConfirmAppointAsAdmin + this + .handleCancelShowConfirmTransferCommunity )} - aria-label={ - this.isAdmin - ? i18n.t("remove_as_admin") - : i18n.t("appoint_as_admin") - } + aria-label={i18n.t("no")} > - {this.isAdmin - ? i18n.t("remove_as_admin") - : i18n.t("appoint_as_admin")} + {i18n.t("no")} </button> - ) : ( - <> - <button class="btn btn-link btn-animate text-muted"> - {i18n.t("are_you_sure")} + </> + ))} + {/* Admins can ban from all, and appoint other admins */} + {canAdmin_ && ( + <> + {!isAdmin_ && + (!isBanned(cv.creator) ? ( + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent( + this, + this.handleModBanShow + )} + aria-label={i18n.t("ban_from_site")} + > + {i18n.t("ban_from_site")} </button> + ) : ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleAddAdmin + this.handleModBanSubmit )} - aria-label={i18n.t("yes")} + aria-label={i18n.t("unban_from_site")} > - {i18n.t("yes")} + {i18n.t("unban_from_site")} </button> + ))} + {!isBanned(cv.creator) && + cv.creator.local && + (!this.state.showConfirmAppointAsAdmin ? ( <button class="btn btn-link btn-animate text-muted" onClick={linkEvent( this, - this.handleCancelConfirmAppointAsAdmin + this.handleShowConfirmAppointAsAdmin )} - aria-label={i18n.t("no")} + aria-label={ + isAdmin_ + ? i18n.t("remove_as_admin") + : i18n.t("appoint_as_admin") + } > - {i18n.t("no")} + {isAdmin_ + ? i18n.t("remove_as_admin") + : i18n.t("appoint_as_admin")} </button> - </> - ))} - </> - )} - </> - )} - </> - )} + ) : ( + <> + <button class="btn btn-link btn-animate text-muted"> + {i18n.t("are_you_sure")} + </button> + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent( + this, + this.handleAddAdmin + )} + aria-label={i18n.t("yes")} + > + {i18n.t("yes")} + </button> + <button + class="btn btn-link btn-animate text-muted" + onClick={linkEvent( + this, + this.handleCancelConfirmAppointAsAdmin + )} + aria-label={i18n.t("no")} + > + {i18n.t("no")} + </button> + </> + ))} + </> + )} + </> + )} + </> + )} </div> {/* end of button group */} </div> @@ -713,7 +744,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { id={`mod-remove-reason-${cv.comment.id}`} class="form-control mr-2" placeholder={i18n.t("reason")} - value={this.state.removeReason} + value={toUndefined(this.state.removeReason)} onInput={linkEvent(this, this.handleModRemoveReasonChange)} /> <button @@ -765,7 +796,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { id={`mod-ban-reason-${cv.comment.id}`} class="form-control mr-2" placeholder={i18n.t("reason")} - value={this.state.banReason} + value={toUndefined(this.state.banReason)} onInput={linkEvent(this, this.handleModBanReasonChange)} /> <label @@ -779,7 +810,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { id={`mod-ban-expires-${cv.comment.id}`} class="form-control mr-2" placeholder={i18n.t("number_of_days")} - value={this.state.banExpireDays} + value={toUndefined(this.state.banExpireDays)} onInput={linkEvent(this, this.handleModBanExpireDaysChange)} /> <div class="form-group"> @@ -819,19 +850,19 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { )} {this.state.showReply && ( <CommentForm - node={node} + node={Left(node)} onReplyCancel={this.handleReplyCancel} disabled={this.props.locked} focus /> )} - {node.children && !this.state.collapsed && ( + {!this.state.collapsed && node.children && ( <CommentNodes nodes={node.children} locked={this.props.locked} moderators={this.props.moderators} admins={this.props.admins} - postCreatorId={this.props.postCreatorId} + maxCommentsShown={None} enableDownvotes={this.props.enableDownvotes} /> )} @@ -881,82 +912,18 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } get myComment(): boolean { - return ( - this.props.node.comment_view.creator.id == - UserService.Instance.myUserInfo?.local_user_view.person.id - ); - } - - get isMod(): boolean { - return ( - this.props.moderators && - isMod( - this.props.moderators.map(m => m.moderator.id), - this.props.node.comment_view.creator.id - ) - ); - } - - get isAdmin(): boolean { - return ( - this.props.admins && - isMod( - this.props.admins.map(a => a.person.id), - this.props.node.comment_view.creator.id + return UserService.Instance.myUserInfo + .map( + m => + m.local_user_view.person.id == this.props.node.comment_view.creator.id ) - ); + .unwrapOr(false); } get isPostCreator(): boolean { - return this.props.node.comment_view.creator.id == this.props.postCreatorId; - } - - get canMod(): boolean { - if (this.props.admins && this.props.moderators) { - let adminsThenMods = this.props.admins - .map(a => a.person.id) - .concat(this.props.moderators.map(m => m.moderator.id)); - - return canMod( - UserService.Instance.myUserInfo, - adminsThenMods, - this.props.node.comment_view.creator.id - ); - } else { - return false; - } - } - - get canAdmin(): boolean { return ( - this.props.admins && - canMod( - UserService.Instance.myUserInfo, - this.props.admins.map(a => a.person.id), - this.props.node.comment_view.creator.id - ) - ); - } - - get amCommunityCreator(): boolean { - return ( - this.props.moderators && - UserService.Instance.myUserInfo && - this.props.node.comment_view.creator.id != - UserService.Instance.myUserInfo.local_user_view.person.id && - UserService.Instance.myUserInfo.local_user_view.person.id == - this.props.moderators[0].moderator.id - ); - } - - get amSiteCreator(): boolean { - return ( - this.props.admins && - UserService.Instance.myUserInfo && - this.props.node.comment_view.creator.id != - UserService.Instance.myUserInfo.local_user_view.person.id && - UserService.Instance.myUserInfo.local_user_view.person.id == - this.props.admins[0].person.id + this.props.node.comment_view.creator.id == + this.props.node.comment_view.post.creator_id ); } @@ -980,32 +947,32 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } handleBlockUserClick(i: CommentNode) { - let blockUserForm: BlockPerson = { + let blockUserForm = new BlockPerson({ person_id: i.props.node.comment_view.creator.id, block: true, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm)); } handleDeleteClick(i: CommentNode) { let comment = i.props.node.comment_view.comment; - let deleteForm: DeleteComment = { + let deleteForm = new DeleteComment({ comment_id: comment.id, deleted: !comment.deleted, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.deleteComment(deleteForm)); } handleSaveCommentClick(i: CommentNode) { let cv = i.props.node.comment_view; let save = cv.saved == undefined ? true : !cv.saved; - let form: SaveComment = { + let form = new SaveComment({ comment_id: cv.comment.id, save, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.saveComment(form)); @@ -1021,12 +988,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { handleCommentUpvote(i: CommentNodeI, event: any) { event.preventDefault(); - let new_vote = this.state.my_vote == 1 ? 0 : 1; + let myVote = this.state.my_vote.unwrapOr(0); + let newVote = myVote == 1 ? 0 : 1; - if (this.state.my_vote == 1) { + if (myVote == 1) { this.state.score--; this.state.upvotes--; - } else if (this.state.my_vote == -1) { + } else if (myVote == -1) { this.state.downvotes--; this.state.upvotes++; this.state.score += 2; @@ -1035,13 +1003,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.state.score++; } - this.state.my_vote = new_vote; + this.state.my_vote = Some(newVote); - let form: CreateCommentLike = { + let form = new CreateCommentLike({ comment_id: i.comment_view.comment.id, - score: this.state.my_vote, - auth: authField(), - }; + score: newVote, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.likeComment(form)); this.setState(this.state); @@ -1050,13 +1018,14 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { handleCommentDownvote(i: CommentNodeI, event: any) { event.preventDefault(); - let new_vote = this.state.my_vote == -1 ? 0 : -1; + let myVote = this.state.my_vote.unwrapOr(0); + let newVote = myVote == -1 ? 0 : -1; - if (this.state.my_vote == 1) { + if (myVote == 1) { this.state.score -= 2; this.state.upvotes--; this.state.downvotes++; - } else if (this.state.my_vote == -1) { + } else if (myVote == -1) { this.state.downvotes--; this.state.score++; } else { @@ -1064,13 +1033,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.state.score--; } - this.state.my_vote = new_vote; + this.state.my_vote = Some(newVote); - let form: CreateCommentLike = { + let form = new CreateCommentLike({ comment_id: i.comment_view.comment.id, - score: this.state.my_vote, - auth: authField(), - }; + score: newVote, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.likeComment(form)); this.setState(this.state); @@ -1089,11 +1058,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { handleReportSubmit(i: CommentNode) { let comment = i.props.node.comment_view.comment; - let form: CreateCommentReport = { + let form = new CreateCommentReport({ comment_id: comment.id, reason: i.state.reportReason, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.createCommentReport(form)); i.state.showReportDialog = false; @@ -1107,7 +1076,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } handleModRemoveReasonChange(i: CommentNode, event: any) { - i.state.removeReason = event.target.value; + i.state.removeReason = Some(event.target.value); i.setState(i.state); } @@ -1118,12 +1087,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { handleModRemoveSubmit(i: CommentNode) { let comment = i.props.node.comment_view.comment; - let form: RemoveComment = { + let form = new RemoveComment({ comment_id: comment.id, removed: !comment.removed, reason: i.state.removeReason, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.removeComment(form)); i.state.showRemoveDialog = false; @@ -1138,18 +1107,18 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { handleMarkRead(i: CommentNode) { if (i.isPersonMentionType(i.props.node.comment_view)) { - let form: MarkPersonMentionAsRead = { + let form = new MarkPersonMentionAsRead({ person_mention_id: i.props.node.comment_view.person_mention.id, read: !i.props.node.comment_view.person_mention.read, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.markPersonMentionAsRead(form)); } else { - let form: MarkCommentAsRead = { + let form = new MarkCommentAsRead({ comment_id: i.props.node.comment_view.comment.id, read: !i.props.node.comment_view.comment.read, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.markCommentAsRead(form)); } @@ -1172,12 +1141,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } handleModBanReasonChange(i: CommentNode, event: any) { - i.state.banReason = event.target.value; + i.state.banReason = Some(event.target.value); i.setState(i.state); } handleModBanExpireDaysChange(i: CommentNode, event: any) { - i.state.banExpireDays = event.target.value; + i.state.banExpireDays = Some(event.target.value); i.setState(i.state); } @@ -1202,15 +1171,15 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { if (ban == false) { i.state.removeData = false; } - let form: BanFromCommunity = { + let form = new BanFromCommunity({ person_id: cv.creator.id, community_id: cv.community.id, ban, - remove_data: i.state.removeData, + remove_data: Some(i.state.removeData), reason: i.state.banReason, - expires: futureDaysToUnixTime(i.state.banExpireDays), - auth: authField(), - }; + expires: i.state.banExpireDays.map(futureDaysToUnixTime), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.banFromCommunity(form)); } else { // If its an unban, restore all their data @@ -1218,14 +1187,14 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { if (ban == false) { i.state.removeData = false; } - let form: BanPerson = { + let form = new BanPerson({ person_id: cv.creator.id, ban, - remove_data: i.state.removeData, + remove_data: Some(i.state.removeData), reason: i.state.banReason, - expires: futureDaysToUnixTime(i.state.banExpireDays), - auth: authField(), - }; + expires: i.state.banExpireDays.map(futureDaysToUnixTime), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.banPerson(form)); } @@ -1245,12 +1214,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { handleAddModToCommunity(i: CommentNode) { let cv = i.props.node.comment_view; - let form: AddModToCommunity = { + let form = new AddModToCommunity({ person_id: cv.creator.id, community_id: cv.community.id, - added: !i.isMod, - auth: authField(), - }; + added: !isMod(i.props.moderators, cv.creator.id), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.addModToCommunity(form)); i.state.showConfirmAppointAsMod = false; i.setState(i.state); @@ -1267,11 +1236,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } handleAddAdmin(i: CommentNode) { - let form: AddAdmin = { - person_id: i.props.node.comment_view.creator.id, - added: !i.isAdmin, - auth: authField(), - }; + let creatorId = i.props.node.comment_view.creator.id; + let form = new AddAdmin({ + person_id: creatorId, + added: !isAdmin(i.props.admins, creatorId), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.addAdmin(form)); i.state.showConfirmAppointAsAdmin = false; i.setState(i.state); @@ -1289,11 +1259,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { handleTransferCommunity(i: CommentNode) { let cv = i.props.node.comment_view; - let form: TransferCommunity = { + let form = new TransferCommunity({ community_id: cv.community.id, person_id: cv.creator.id, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.transferCommunity(form)); i.state.showConfirmTransferCommunity = false; i.setState(i.state); @@ -1333,9 +1303,9 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } get scoreColor() { - if (this.state.my_vote == 1) { + if (this.state.my_vote.unwrapOr(0) == 1) { return "text-info"; - } else if (this.state.my_vote == -1) { + } else if (this.state.my_vote.unwrapOr(0) == -1) { return "text-danger"; } else { return "text-muted"; diff --git a/src/shared/components/comment/comment-nodes.tsx b/src/shared/components/comment/comment-nodes.tsx index af1610a..62167ec 100644 --- a/src/shared/components/comment/comment-nodes.tsx +++ b/src/shared/components/comment/comment-nodes.tsx @@ -1,3 +1,4 @@ +import { Option } from "@sniptt/monads"; import { Component } from "inferno"; import { CommunityModeratorView, PersonViewSafe } from "lemmy-js-client"; import { CommentNode as CommentNodeI } from "../../interfaces"; @@ -5,9 +6,9 @@ import { CommentNode } from "./comment-node"; interface CommentNodesProps { nodes: CommentNodeI[]; - moderators?: CommunityModeratorView[]; - admins?: PersonViewSafe[]; - postCreatorId?: number; + moderators: Option<CommunityModeratorView[]>; + admins: Option<PersonViewSafe[]>; + maxCommentsShown: Option<number>; noBorder?: boolean; noIndent?: boolean; viewOnly?: boolean; @@ -15,8 +16,7 @@ interface CommentNodesProps { markable?: boolean; showContext?: boolean; showCommunity?: boolean; - maxCommentsShown?: number; - enableDownvotes: boolean; + enableDownvotes?: boolean; } export class CommentNodes extends Component<CommentNodesProps, any> { @@ -25,9 +25,9 @@ export class CommentNodes extends Component<CommentNodesProps, any> { } render() { - let maxComments = this.props.maxCommentsShown - ? this.props.maxCommentsShown - : this.props.nodes.length; + let maxComments = this.props.maxCommentsShown.unwrapOr( + this.props.nodes.length + ); return ( <div className="comments"> @@ -41,7 +41,6 @@ export class CommentNodes extends Component<CommentNodesProps, any> { 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} diff --git a/src/shared/components/comment/comment-report.tsx b/src/shared/components/comment/comment-report.tsx index 8a45648..98668e7 100644 --- a/src/shared/components/comment/comment-report.tsx +++ b/src/shared/components/comment/comment-report.tsx @@ -1,3 +1,4 @@ +import { None } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { @@ -8,7 +9,7 @@ import { import { i18n } from "../../i18next"; import { CommentNode as CommentNodeI } from "../../interfaces"; import { WebSocketService } from "../../services"; -import { authField, wsClient } from "../../utils"; +import { auth, wsClient } from "../../utils"; import { Icon } from "../common/icon"; import { PersonListing } from "../person/person-listing"; import { CommentNode } from "./comment-node"; @@ -42,6 +43,7 @@ export class CommentReport extends Component<CommentReportProps, any> { subscribed: false, saved: false, creator_blocked: false, + recipient: None, my_vote: r.my_vote, }; @@ -53,8 +55,8 @@ export class CommentReport extends Component<CommentReportProps, any> { <div> <CommentNode node={node} - moderators={[]} - admins={[]} + moderators={None} + admins={None} enableDownvotes={true} viewOnly={true} showCommunity={true} @@ -65,21 +67,24 @@ export class CommentReport extends Component<CommentReportProps, any> { <div> {i18n.t("reason")}: {r.comment_report.reason} </div> - {r.resolver && ( - <div> - {r.comment_report.resolved ? ( - <T i18nKey="resolved_by"> - # - <PersonListing person={r.resolver} /> - </T> - ) : ( - <T i18nKey="unresolved_by"> - # - <PersonListing person={r.resolver} /> - </T> - )} - </div> - )} + {r.resolver.match({ + some: resolver => ( + <div> + {r.comment_report.resolved ? ( + <T i18nKey="resolved_by"> + # + <PersonListing person={resolver} /> + </T> + ) : ( + <T i18nKey="unresolved_by"> + # + <PersonListing person={resolver} /> + </T> + )} + </div> + ), + none: <></>, + })} <button className="btn btn-link btn-animate text-muted py-0" onClick={linkEvent(this, this.handleResolveReport)} @@ -98,11 +103,11 @@ export class CommentReport extends Component<CommentReportProps, any> { } handleResolveReport(i: CommentReport) { - let form: ResolveCommentReport = { + let form = new ResolveCommentReport({ report_id: i.props.report.comment_report.id, resolved: !i.props.report.comment_report.resolved, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.resolveCommentReport(form)); } } diff --git a/src/shared/components/common/banner-icon-header.tsx b/src/shared/components/common/banner-icon-header.tsx index 362ac69..d3383b5 100644 --- a/src/shared/components/common/banner-icon-header.tsx +++ b/src/shared/components/common/banner-icon-header.tsx @@ -1,9 +1,10 @@ +import { Option } from "@sniptt/monads"; import { Component } from "inferno"; import { PictrsImage } from "./pictrs-image"; interface BannerIconHeaderProps { - banner?: string; - icon?: string; + banner: Option<string>; + icon: Option<string>; } export class BannerIconHeader extends Component<BannerIconHeaderProps, any> { @@ -14,17 +15,21 @@ export class BannerIconHeader extends Component<BannerIconHeaderProps, any> { render() { return ( <div class="position-relative mb-2"> - {this.props.banner && ( - <PictrsImage src={this.props.banner} banner alt="" /> - )} - {this.props.icon && ( - <PictrsImage - src={this.props.icon} - iconOverlay - pushup={!!this.props.banner} - alt="" - /> - )} + {this.props.banner.match({ + some: banner => <PictrsImage src={banner} banner alt="" />, + none: <></>, + })} + {this.props.icon.match({ + some: icon => ( + <PictrsImage + src={icon} + iconOverlay + pushup={this.props.banner.isSome()} + alt="" + /> + ), + none: <></>, + })} </div> ); } diff --git a/src/shared/components/common/html-tags.tsx b/src/shared/components/common/html-tags.tsx index 9efed1f..1997b4f 100644 --- a/src/shared/components/common/html-tags.tsx +++ b/src/shared/components/common/html-tags.tsx @@ -1,3 +1,4 @@ +import { Option } from "@sniptt/monads"; import { Component } from "inferno"; import { Helmet } from "inferno-helmet"; import { httpExternalPath } from "../../env"; @@ -6,8 +7,8 @@ import { md } from "../../utils"; interface HtmlTagsProps { title: string; path: string; - description?: string; - image?: string; + description: Option<string>; + image: Option<string>; } /// Taken from https://metatags.io/ @@ -31,14 +32,17 @@ export class HtmlTags extends Component<HtmlTagsProps, any> { <meta property="twitter:card" content="summary_large_image" /> {/* Optional desc and images */} - {this.props.description && + {this.props.description.isSome() && ["description", "og:description", "twitter:description"].map(n => ( - <meta name={n} content={md.renderInline(this.props.description)} /> + <meta + name={n} + content={md.renderInline(this.props.description.unwrap())} + /> ))} - {this.props.image && + {this.props.image.isSome() && ["og:image", "twitter:image"].map(p => ( - <meta property={p} content={this.props.image} /> + <meta property={p} content={this.props.image.unwrap()} /> ))} </Helmet> ); diff --git a/src/shared/components/common/image-upload-form.tsx b/src/shared/components/common/image-upload-form.tsx index 9b0fb84..5f7a816 100644 --- a/src/shared/components/common/image-upload-form.tsx +++ b/src/shared/components/common/image-upload-form.tsx @@ -1,3 +1,4 @@ +import { Option } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { pictrsUri } from "../../env"; import { i18n } from "../../i18next"; @@ -7,7 +8,7 @@ import { Icon } from "./icon"; interface ImageUploadFormProps { uploadTitle: string; - imageSrc: string; + imageSrc: Option<string>; onUpload(url: string): any; onRemove(): any; rounded?: boolean; @@ -38,26 +39,29 @@ export class ImageUploadForm extends Component< htmlFor={this.id} class="pointer 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)} - aria-label={i18n.t("remove")} - > - <Icon icon="x" classes="mini-overlay" /> - </a> - </span> - )} + {this.props.imageSrc.match({ + some: imageSrc => ( + <span class="d-inline-block position-relative"> + <img + src={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)} + aria-label={i18n.t("remove")} + > + <Icon icon="x" classes="mini-overlay" /> + </a> + </span> + ), + none: ( + <span class="btn btn-secondary">{this.props.uploadTitle}</span> + ), + })} </label> <input id={this.id} @@ -65,7 +69,7 @@ export class ImageUploadForm extends Component< accept="image/*,video/*" name={this.id} class="d-none" - disabled={!UserService.Instance.myUserInfo} + disabled={UserService.Instance.myUserInfo.isNone()} onChange={linkEvent(this, this.handleImageUpload)} /> </form> diff --git a/src/shared/components/common/listing-type-select.tsx b/src/shared/components/common/listing-type-select.tsx index 515f2dc..0ddd0e9 100644 --- a/src/shared/components/common/listing-type-select.tsx +++ b/src/shared/components/common/listing-type-select.tsx @@ -46,11 +46,7 @@ export class ListingTypeSelect extends Component< title={i18n.t("subscribed_description")} className={`btn btn-outline-secondary ${this.state.type_ == ListingType.Subscribed && "active"} - ${ - UserService.Instance.myUserInfo == undefined - ? "disabled" - : "pointer" - } + ${UserService.Instance.myUserInfo.isNone() ? "disabled" : "pointer"} `} > <input @@ -59,7 +55,7 @@ export class ListingTypeSelect extends Component< value={ListingType.Subscribed} checked={this.state.type_ == ListingType.Subscribed} onChange={linkEvent(this, this.handleTypeChange)} - disabled={UserService.Instance.myUserInfo == undefined} + disabled={UserService.Instance.myUserInfo.isNone()} /> {i18n.t("subscribed")} </label> diff --git a/src/shared/components/common/markdown-textarea.tsx b/src/shared/components/common/markdown-textarea.tsx index af284e5..e369f8f 100644 --- a/src/shared/components/common/markdown-textarea.tsx +++ b/src/shared/components/common/markdown-textarea.tsx @@ -1,6 +1,8 @@ +import { None, Option, Some } from "@sniptt/monads"; import autosize from "autosize"; import { Component, linkEvent } from "inferno"; import { Prompt } from "inferno-router"; +import { toUndefined } from "lemmy-js-client"; import { pictrsUri } from "../../env"; import { i18n } from "../../i18next"; import { UserService } from "../../services"; @@ -18,22 +20,22 @@ import { import { Icon, Spinner } from "./icon"; interface MarkdownTextAreaProps { - initialContent?: string; - finished?: boolean; - buttonTitle?: string; + initialContent: Option<string>; + placeholder: Option<string>; + buttonTitle: Option<string>; + maxLength: Option<number>; replyType?: boolean; focus?: boolean; disabled?: boolean; - maxLength?: number; - onSubmit?(msg: { val: string; formId: string }): any; + finished?: boolean; + hideNavigationWarnings?: boolean; onContentChange?(val: string): any; onReplyCancel?(): any; - hideNavigationWarnings?: boolean; - placeholder?: string; + onSubmit?(msg: { val: string; formId: string }): any; } interface MarkdownTextAreaState { - content: string; + content: Option<string>; previewMode: boolean; loading: boolean; imageLoading: boolean; @@ -68,7 +70,7 @@ export class MarkdownTextArea extends Component< autosize(textarea); this.tribute.attach(textarea); textarea.addEventListener("tribute-replaced", () => { - this.state.content = textarea.value; + this.state.content = Some(textarea.value); this.setState(this.state); autosize.update(textarea); }); @@ -85,7 +87,7 @@ export class MarkdownTextArea extends Component< } componentDidUpdate() { - if (!this.props.hideNavigationWarnings && this.state.content) { + if (!this.props.hideNavigationWarnings && this.state.content.isSome()) { window.onbeforeunload = () => true; } else { window.onbeforeunload = undefined; @@ -96,7 +98,7 @@ export class MarkdownTextArea extends Component< if (nextProps.finished) { this.state.previewMode = false; this.state.loading = false; - this.state.content = ""; + this.state.content = None; this.setState(this.state); if (this.props.replyType) { this.props.onReplyCancel(); @@ -118,7 +120,9 @@ export class MarkdownTextArea extends Component< return ( <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}> <Prompt - when={!this.props.hideNavigationWarnings && this.state.content} + when={ + !this.props.hideNavigationWarnings && this.state.content.isSome() + } message={i18n.t("block_leaving")} /> <div class="form-group row"> @@ -126,21 +130,25 @@ export class MarkdownTextArea extends Component< <textarea id={this.id} className={`form-control ${this.state.previewMode && "d-none"}`} - value={this.state.content} + value={toUndefined(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} - placeholder={this.props.placeholder} + maxLength={this.props.maxLength.unwrapOr(10000)} + placeholder={toUndefined(this.props.placeholder)} /> - {this.state.previewMode && ( - <div - className="card border-secondary card-body md-div" - dangerouslySetInnerHTML={mdToHtml(this.state.content)} - /> - )} + {this.state.previewMode && + this.state.content.match({ + some: content => ( + <div + className="card border-secondary card-body md-div" + dangerouslySetInnerHTML={mdToHtml(content)} + /> + ), + none: <></>, + })} </div> <label class="sr-only" htmlFor={this.id}> {i18n.t("body")} @@ -148,19 +156,22 @@ export class MarkdownTextArea extends Component< </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 ? ( - <Spinner /> - ) : ( - <span>{this.props.buttonTitle}</span> - )} - </button> - )} + {this.props.buttonTitle.match({ + some: buttonTitle => ( + <button + type="submit" + class="btn btn-sm btn-secondary mr-2" + disabled={this.props.disabled || this.state.loading} + > + {this.state.loading ? ( + <Spinner /> + ) : ( + <span>{buttonTitle}</span> + )} + </button> + ), + none: <></>, + })} {this.props.replyType && ( <button type="button" @@ -170,7 +181,7 @@ export class MarkdownTextArea extends Component< {i18n.t("cancel")} </button> )} - {this.state.content && ( + {this.state.content.isSome() && ( <button className={`btn btn-sm btn-secondary mr-2 ${ this.state.previewMode && "active" @@ -210,7 +221,7 @@ export class MarkdownTextArea extends Component< <label htmlFor={`file-upload-${this.id}`} className={`mb-0 ${ - UserService.Instance.myUserInfo && "pointer" + UserService.Instance.myUserInfo.isSome() && "pointer" }`} data-tippy-content={i18n.t("upload_image")} > @@ -226,7 +237,7 @@ export class MarkdownTextArea extends Component< accept="image/*,video/*" name="file" class="d-none" - disabled={!UserService.Instance.myUserInfo} + disabled={UserService.Instance.myUserInfo.isNone()} onChange={linkEvent(this, this.handleImageUpload)} /> </form> @@ -344,9 +355,12 @@ export class MarkdownTextArea extends Component< let deleteToken = res.files[0].delete_token; let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`; let imageMarkdown = `![](${url})`; - let content = i.state.content; - content = content ? `${content}\n${imageMarkdown}` : imageMarkdown; - i.state.content = content; + i.state.content = Some( + i.state.content.match({ + some: content => `${content}\n${imageMarkdown}`, + none: imageMarkdown, + }) + ); i.state.imageLoading = false; i.contentChange(); i.setState(i.state); @@ -373,12 +387,12 @@ export class MarkdownTextArea extends Component< contentChange() { if (this.props.onContentChange) { - this.props.onContentChange(this.state.content); + this.props.onContentChange(toUndefined(this.state.content)); } } handleContentChange(i: MarkdownTextArea, event: any) { - i.state.content = event.target.value; + i.state.content = Some(event.target.value); i.contentChange(); i.setState(i.state); } @@ -393,7 +407,7 @@ export class MarkdownTextArea extends Component< event.preventDefault(); i.state.loading = true; i.setState(i.state); - let msg = { val: i.state.content, formId: i.formId }; + let msg = { val: toUndefined(i.state.content), formId: i.formId }; i.props.onSubmit(msg); } @@ -403,23 +417,28 @@ export class MarkdownTextArea extends Component< 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 (i.state.content.isNone()) { + i.state.content = Some(""); + } + + let content = i.state.content.unwrap(); + 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)}`; + let selectedText = content.substring(start, end); + i.state.content = Some( + `${content.substring(0, start)}[${selectedText}]()${content.substring( + end + )}` + ); textarea.focus(); setTimeout(() => (textarea.selectionEnd = end + 3), 10); } else { - i.state.content += "[]()"; + i.state.content = Some(`${content} []()`); textarea.focus(); setTimeout(() => (textarea.selectionEnd -= 1), 10); } @@ -432,7 +451,7 @@ export class MarkdownTextArea extends Component< } simpleBeginningofLine(chars: string) { - this.simpleSurroundBeforeAfter(`${chars} `, "", ""); + this.simpleSurroundBeforeAfter(`${chars}`, "", ""); } simpleSurroundBeforeAfter( @@ -440,23 +459,27 @@ export class MarkdownTextArea extends Component< afterChars: string, emptyChars = "___" ) { - if (!this.state.content) { - this.state.content = ""; + if (this.state.content.isNone()) { + this.state.content = Some(""); } let textarea: any = document.getElementById(this.id); let start: number = textarea.selectionStart; let end: number = textarea.selectionEnd; + let content = this.state.content.unwrap(); + if (start !== end) { - let selectedText = this.state.content.substring(start, end); - this.state.content = `${this.state.content.substring( - 0, - start - )}${beforeChars}${selectedText}${afterChars}${this.state.content.substring( - end - )}`; + let selectedText = content.substring(start, end); + this.state.content = Some( + `${content.substring( + 0, + start + )}${beforeChars}${selectedText}${afterChars}${content.substring(end)}` + ); } else { - this.state.content += `${beforeChars}${emptyChars}${afterChars}`; + this.state.content = Some( + `${content}${beforeChars}${emptyChars}${afterChars}` + ); } this.contentChange(); this.setState(this.state); @@ -530,10 +553,10 @@ export class MarkdownTextArea extends Component< } simpleInsert(chars: string) { - if (!this.state.content) { - this.state.content = `${chars} `; + if (this.state.content.isNone()) { + this.state.content = Some(`${chars} `); } else { - this.state.content += `\n${chars} `; + this.state.content = Some(`${this.state.content.unwrap()}\n${chars} `); } let textarea: any = document.getElementById(this.id); @@ -561,12 +584,12 @@ export class MarkdownTextArea extends Component< .split("\n") .map(t => `> ${t}`) .join("\n") + "\n\n"; - if (this.state.content == null) { - this.state.content = ""; + if (this.state.content.isNone()) { + this.state.content = Some(""); } else { - this.state.content += "\n"; + this.state.content = Some(`${this.state.content.unwrap()}\n`); } - this.state.content += quotedText; + this.state.content = Some(`${this.state.content.unwrap()}${quotedText}`); this.contentChange(); this.setState(this.state); // Not sure why this needs a delay @@ -578,6 +601,8 @@ export class MarkdownTextArea extends Component< let textarea: any = document.getElementById(this.id); let start: number = textarea.selectionStart; let end: number = textarea.selectionEnd; - return start !== end ? this.state.content.substring(start, end) : ""; + return start !== end + ? this.state.content.unwrap().substring(start, end) + : ""; } } diff --git a/src/shared/components/common/moment-time.tsx b/src/shared/components/common/moment-time.tsx index 1afc979..3d3c548 100644 --- a/src/shared/components/common/moment-time.tsx +++ b/src/shared/components/common/moment-time.tsx @@ -1,3 +1,4 @@ +import { Option } from "@sniptt/monads"; import { Component } from "inferno"; import moment from "moment"; import { i18n } from "../../i18next"; @@ -5,11 +6,8 @@ import { capitalizeFirstLetter, getLanguages } from "../../utils"; import { Icon } from "./icon"; interface MomentTimeProps { - data: { - published?: string; - when_?: string; - updated?: string; - }; + published: string; + updated: Option<string>; showAgo?: boolean; ignoreUpdated?: boolean; } @@ -24,40 +22,32 @@ export class MomentTime extends Component<MomentTimeProps, any> { } createdAndModifiedTimes() { - let created = this.props.data.published || this.props.data.when_; - return ` - <div> - <div> - ${capitalizeFirstLetter(i18n.t("created"))}: ${this.format(created)} - </div> - <div> - ${capitalizeFirstLetter(i18n.t("modified"))} ${this.format( - this.props.data.updated - )} - </div> - </div>`; + return `${capitalizeFirstLetter(i18n.t("created"))}: ${this.format( + this.props.published + )}\n\n\n${ + this.props.updated.isSome() && capitalizeFirstLetter(i18n.t("modified")) + } ${this.format(this.props.updated.unwrap())}`; } render() { - if (!this.props.ignoreUpdated && this.props.data.updated) { + if (!this.props.ignoreUpdated && this.props.updated.isSome()) { return ( <span data-tippy-content={this.createdAndModifiedTimes()} - data-tippy-allowHtml={true} className="font-italics pointer unselectable" > <Icon icon="edit-2" classes="icon-inline mr-1" /> - {moment.utc(this.props.data.updated).fromNow(!this.props.showAgo)} + {moment.utc(this.props.updated.unwrap()).fromNow(!this.props.showAgo)} </span> ); } else { - let created = this.props.data.published || this.props.data.when_; + let published = this.props.published; return ( <span className="pointer unselectable" - data-tippy-content={this.format(created)} + data-tippy-content={this.format(published)} > - {moment.utc(created).fromNow(!this.props.showAgo)} + {moment.utc(published).fromNow(!this.props.showAgo)} </span> ); } diff --git a/src/shared/components/common/registration-application.tsx b/src/shared/components/common/registration-application.tsx index cad47b8..36875b8 100644 --- a/src/shared/components/common/registration-application.tsx +++ b/src/shared/components/common/registration-application.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { @@ -6,7 +7,7 @@ import { } from "lemmy-js-client"; import { i18n } from "../../i18next"; import { WebSocketService } from "../../services"; -import { authField, mdToHtml, wsClient } from "../../utils"; +import { auth, mdToHtml, wsClient } from "../../utils"; import { PersonListing } from "../person/person-listing"; import { MarkdownTextArea } from "./markdown-textarea"; import { MomentTime } from "./moment-time"; @@ -16,7 +17,7 @@ interface RegistrationApplicationProps { } interface RegistrationApplicationState { - denyReason?: string; + denyReason: Option<string>; denyExpanded: boolean; } @@ -47,35 +48,44 @@ export class RegistrationApplication extends Component< {i18n.t("applicant")}: <PersonListing person={a.creator} /> </div> <div> - {i18n.t("created")}: <MomentTime showAgo data={ra} /> + {i18n.t("created")}:{" "} + <MomentTime showAgo published={ra.published} updated={None} /> </div> <div>{i18n.t("answer")}:</div> <div className="md-div" dangerouslySetInnerHTML={mdToHtml(ra.answer)} /> - {a.admin && ( - <div> - {accepted ? ( - <T i18nKey="approved_by"> - # - <PersonListing person={a.admin} /> - </T> - ) : ( - <div> - <T i18nKey="denied_by"> + {a.admin.match({ + some: admin => ( + <div> + {accepted ? ( + <T i18nKey="approved_by"> # - <PersonListing person={a.admin} /> + <PersonListing person={admin} /> </T> + ) : ( <div> - {i18n.t("deny_reason")}:{" "} - <div - className="md-div d-inline-flex" - dangerouslySetInnerHTML={mdToHtml(ra.deny_reason || "")} - /> + <T i18nKey="denied_by"> + # + <PersonListing person={admin} /> + </T> + {ra.deny_reason.match({ + some: deny_reason => ( + <div> + {i18n.t("deny_reason")}:{" "} + <div + className="md-div d-inline-flex" + dangerouslySetInnerHTML={mdToHtml(deny_reason)} + /> + </div> + ), + none: <></>, + })} </div> - </div> - )} - </div> - )} + )} + </div> + ), + none: <></>, + })} {this.state.denyExpanded && ( <div class="form-group row"> @@ -86,6 +96,9 @@ export class RegistrationApplication extends Component< <MarkdownTextArea initialContent={this.state.denyReason} onContentChange={this.handleDenyReasonChange} + placeholder={None} + buttonTitle={None} + maxLength={None} hideNavigationWarnings /> </div> @@ -115,12 +128,12 @@ export class RegistrationApplication extends Component< handleApprove(i: RegistrationApplication) { i.setState({ denyExpanded: false }); - let form: ApproveRegistrationApplication = { + let form = new ApproveRegistrationApplication({ id: i.props.application.registration_application.id, - deny_reason: "", + deny_reason: None, approve: true, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send( wsClient.approveRegistrationApplication(form) ); @@ -129,12 +142,12 @@ export class RegistrationApplication extends Component< handleDeny(i: RegistrationApplication) { if (i.state.denyExpanded) { i.setState({ denyExpanded: false }); - let form: ApproveRegistrationApplication = { + let form = new ApproveRegistrationApplication({ id: i.props.application.registration_application.id, approve: false, deny_reason: i.state.denyReason, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send( wsClient.approveRegistrationApplication(form) ); @@ -144,7 +157,7 @@ export class RegistrationApplication extends Component< } handleDenyReasonChange(val: string) { - this.state.denyReason = val; + this.state.denyReason = Some(val); this.setState(this.state); } } diff --git a/src/shared/components/community/communities.tsx b/src/shared/components/community/communities.tsx index edc2730..a9d4a6c 100644 --- a/src/shared/components/community/communities.tsx +++ b/src/shared/components/community/communities.tsx @@ -1,33 +1,32 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { CommunityResponse, - CommunityView, FollowCommunity, + GetSiteResponse, ListCommunities, ListCommunitiesResponse, ListingType, - SiteView, SortType, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { InitialFetchRequest } from "shared/interfaces"; import { i18n } from "../../i18next"; import { WebSocketService } from "../../services"; import { - authField, + auth, getListingTypeFromPropsNoDefault, getPageFromProps, isBrowser, numToSI, setIsoData, - setOptionalAuth, showLocal, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; @@ -38,10 +37,10 @@ import { CommunityLink } from "./community-link"; const communityLimit = 100; interface CommunitiesState { - communities: CommunityView[]; + listCommunitiesResponse: Option<ListCommunitiesResponse>; page: number; loading: boolean; - site_view: SiteView; + siteRes: GetSiteResponse; searchText: string; listingType: ListingType; } @@ -53,13 +52,13 @@ interface CommunitiesProps { export class Communities extends Component<any, CommunitiesState> { private subscription: Subscription; - private isoData = setIsoData(this.context); + private isoData = setIsoData(this.context, ListCommunitiesResponse); private emptyState: CommunitiesState = { - communities: [], + listCommunitiesResponse: None, loading: true, page: getPageFromProps(this.props), listingType: getListingTypeFromPropsNoDefault(this.props), - site_view: this.isoData.site_res.site_view, + siteRes: this.isoData.site_res, searchText: "", }; @@ -74,7 +73,8 @@ export class Communities extends Component<any, CommunitiesState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.communities = this.isoData.routeData[0].communities; + let listRes = Some(this.isoData.routeData[0] as ListCommunitiesResponse); + this.state.listCommunitiesResponse = listRes; this.state.loading = false; } else { this.refetch(); @@ -105,7 +105,10 @@ export class Communities extends Component<any, CommunitiesState> { } get documentTitle(): string { - return `${i18n.t("communities")} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `${i18n.t("communities")} - ${siteView.site.name}`, + none: "", + }); } render() { @@ -114,6 +117,8 @@ export class Communities extends Component<any, CommunitiesState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> {this.state.loading ? ( <h5> @@ -157,48 +162,51 @@ export class Communities extends Component<any, CommunitiesState> { </tr> </thead> <tbody> - {this.state.communities.map(cv => ( - <tr> - <td> - <CommunityLink community={cv.community} /> - </td> - <td class="text-right"> - {numToSI(cv.counts.subscribers)} - </td> - <td class="text-right"> - {numToSI(cv.counts.users_active_month)} - </td> - <td class="text-right d-none d-lg-table-cell"> - {numToSI(cv.counts.posts)} - </td> - <td class="text-right d-none d-lg-table-cell"> - {numToSI(cv.counts.comments)} - </td> - <td class="text-right"> - {cv.subscribed ? ( - <button - class="btn btn-link d-inline-block" - onClick={linkEvent( - cv.community.id, - this.handleUnsubscribe - )} - > - {i18n.t("unsubscribe")} - </button> - ) : ( - <button - class="btn btn-link d-inline-block" - onClick={linkEvent( - cv.community.id, - this.handleSubscribe - )} - > - {i18n.t("subscribe")} - </button> - )} - </td> - </tr> - ))} + {this.state.listCommunitiesResponse + .map(l => l.communities) + .unwrapOr([]) + .map(cv => ( + <tr> + <td> + <CommunityLink community={cv.community} /> + </td> + <td class="text-right"> + {numToSI(cv.counts.subscribers)} + </td> + <td class="text-right"> + {numToSI(cv.counts.users_active_month)} + </td> + <td class="text-right d-none d-lg-table-cell"> + {numToSI(cv.counts.posts)} + </td> + <td class="text-right d-none d-lg-table-cell"> + {numToSI(cv.counts.comments)} + </td> + <td class="text-right"> + {cv.subscribed ? ( + <button + class="btn btn-link d-inline-block" + onClick={linkEvent( + cv.community.id, + this.handleUnsubscribe + )} + > + {i18n.t("unsubscribe")} + </button> + ) : ( + <button + class="btn btn-link d-inline-block" + onClick={linkEvent( + cv.community.id, + this.handleSubscribe + )} + > + {i18n.t("subscribe")} + </button> + )} + </td> + </tr> + ))} </tbody> </table> </div> @@ -258,20 +266,20 @@ export class Communities extends Component<any, CommunitiesState> { } handleUnsubscribe(communityId: number) { - let form: FollowCommunity = { + let form = new FollowCommunity({ community_id: communityId, follow: false, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.followCommunity(form)); } handleSubscribe(communityId: number) { - let form: FollowCommunity = { + let form = new FollowCommunity({ community_id: communityId, follow: true, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.followCommunity(form)); } @@ -287,13 +295,13 @@ export class Communities extends Component<any, CommunitiesState> { } refetch() { - let listCommunitiesForm: ListCommunities = { - type_: this.state.listingType, - sort: SortType.TopMonth, - limit: communityLimit, - page: this.state.page, - auth: authField(false), - }; + let listCommunitiesForm = new ListCommunities({ + type_: Some(this.state.listingType), + sort: Some(SortType.TopMonth), + limit: Some(communityLimit), + page: Some(this.state.page), + auth: auth(false).ok(), + }); WebSocketService.Instance.send( wsClient.listCommunities(listCommunitiesForm) @@ -302,17 +310,17 @@ export class Communities extends Component<any, CommunitiesState> { static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let pathSplit = req.path.split("/"); - let type_: ListingType = pathSplit[3] - ? ListingType[pathSplit[3]] - : ListingType.Local; - let page = pathSplit[5] ? Number(pathSplit[5]) : 1; - let listCommunitiesForm: ListCommunities = { + let type_: Option<ListingType> = Some( + pathSplit[3] ? ListingType[pathSplit[3]] : ListingType.Local + ); + let page = Some(pathSplit[5] ? Number(pathSplit[5]) : 1); + let listCommunitiesForm = new ListCommunities({ type_, - sort: SortType.TopMonth, - limit: communityLimit, + sort: Some(SortType.TopMonth), + limit: Some(communityLimit), page, - }; - setOptionalAuth(listCommunitiesForm, req.auth); + auth: req.auth, + }); return [req.client.listCommunities(listCommunitiesForm)]; } @@ -324,18 +332,26 @@ export class Communities extends Component<any, CommunitiesState> { toast(i18n.t(msg.error), "danger"); return; } else if (op == UserOperation.ListCommunities) { - let data = wsJsonToRes<ListCommunitiesResponse>(msg).data; - this.state.communities = data.communities; + let data = wsJsonToRes<ListCommunitiesResponse>( + msg, + ListCommunitiesResponse + ); + this.state.listCommunitiesResponse = Some(data); this.state.loading = false; window.scrollTo(0, 0); this.setState(this.state); } else if (op == UserOperation.FollowCommunity) { - let data = wsJsonToRes<CommunityResponse>(msg).data; - let found = this.state.communities.find( - c => c.community.id == data.community_view.community.id - ); - found.subscribed = data.community_view.subscribed; - found.counts.subscribers = data.community_view.counts.subscribers; + let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse); + this.state.listCommunitiesResponse.match({ + some: res => { + let found = res.communities.find( + c => c.community.id == data.community_view.community.id + ); + found.subscribed = data.community_view.subscribed; + found.counts.subscribers = data.community_view.counts.subscribers; + }, + none: void 0, + }); this.setState(this.state); } } diff --git a/src/shared/components/community/community-form.tsx b/src/shared/components/community/community-form.tsx index 0f12a25..d1f5f75 100644 --- a/src/shared/components/community/community-form.tsx +++ b/src/shared/components/community/community-form.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { Prompt } from "inferno-router"; import { @@ -5,30 +6,31 @@ import { CommunityView, CreateCommunity, EditCommunity, + toUndefined, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, capitalizeFirstLetter, randomStr, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { Icon, Spinner } from "../common/icon"; import { ImageUploadForm } from "../common/image-upload-form"; import { MarkdownTextArea } from "../common/markdown-textarea"; interface CommunityFormProps { - community_view?: CommunityView; // If a community is given, that means this is an edit + community_view: Option<CommunityView>; // If a community is given, that means this is an edit onCancel?(): any; onCreate?(community: CommunityView): any; onEdit?(community: CommunityView): any; - enableNsfw: boolean; + enableNsfw?: boolean; } interface CommunityFormState { @@ -44,15 +46,16 @@ export class CommunityForm extends Component< private subscription: Subscription; private emptyState: CommunityFormState = { - communityForm: { - name: null, - title: null, - nsfw: false, - icon: null, - banner: null, - posting_restricted_to_mods: false, - auth: authField(false), - }, + communityForm: new CreateCommunity({ + name: undefined, + title: undefined, + description: None, + nsfw: None, + icon: None, + banner: None, + posting_restricted_to_mods: None, + auth: undefined, + }), loading: false, }; @@ -70,31 +73,34 @@ export class CommunityForm extends Component< this.handleBannerUpload = this.handleBannerUpload.bind(this); this.handleBannerRemove = this.handleBannerRemove.bind(this); - let cv = this.props.community_view; - if (cv) { - this.state.communityForm = { - name: cv.community.name, - title: cv.community.title, - description: cv.community.description, - nsfw: cv.community.nsfw, - icon: cv.community.icon, - banner: cv.community.banner, - posting_restricted_to_mods: cv.community.posting_restricted_to_mods, - auth: authField(), - }; - } + this.props.community_view.match({ + some: cv => { + this.state.communityForm = new CreateCommunity({ + name: cv.community.name, + title: cv.community.title, + description: cv.community.description, + nsfw: Some(cv.community.nsfw), + icon: cv.community.icon, + banner: cv.community.banner, + posting_restricted_to_mods: Some( + cv.community.posting_restricted_to_mods + ), + auth: auth().unwrap(), + }); + }, + none: void 0, + }); this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); } - // TODO this should be checked out componentDidUpdate() { if ( !this.state.loading && (this.state.communityForm.name || this.state.communityForm.title || - this.state.communityForm.description) + this.state.communityForm.description.isSome()) ) { window.onbeforeunload = () => true; } else { @@ -115,12 +121,12 @@ export class CommunityForm extends Component< !this.state.loading && (this.state.communityForm.name || this.state.communityForm.title || - this.state.communityForm.description) + this.state.communityForm.description.isSome()) } message={i18n.t("block_leaving")} /> <form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}> - {!this.props.community_view && ( + {this.props.community_view.isNone() && ( <div class="form-group row"> <label class="col-12 col-sm-2 col-form-label" @@ -205,6 +211,9 @@ export class CommunityForm extends Component< <div class="col-12 col-sm-10"> <MarkdownTextArea initialContent={this.state.communityForm.description} + placeholder={Some("description")} + buttonTitle={None} + maxLength={None} onContentChange={this.handleCommunityDescriptionChange} /> </div> @@ -221,7 +230,7 @@ export class CommunityForm extends Component< class="form-check-input position-static" id="community-nsfw" type="checkbox" - checked={this.state.communityForm.nsfw} + checked={toUndefined(this.state.communityForm.nsfw)} onChange={linkEvent(this, this.handleCommunityNsfwChange)} /> </div> @@ -238,7 +247,9 @@ export class CommunityForm extends Component< class="form-check-input position-static" id="community-only-mods-can-post" type="checkbox" - checked={this.state.communityForm.posting_restricted_to_mods} + checked={toUndefined( + this.state.communityForm.posting_restricted_to_mods + )} onChange={linkEvent( this, this.handleCommunityPostingRestrictedToMods @@ -256,13 +267,13 @@ export class CommunityForm extends Component< > {this.state.loading ? ( <Spinner /> - ) : this.props.community_view ? ( + ) : this.props.community_view.isSome() ? ( capitalizeFirstLetter(i18n.t("save")) ) : ( capitalizeFirstLetter(i18n.t("create")) )} </button> - {this.props.community_view && ( + {this.props.community_view.isSome() && ( <button type="button" class="btn btn-secondary" @@ -281,17 +292,29 @@ export class CommunityForm extends Component< handleCreateCommunitySubmit(i: CommunityForm, event: any) { event.preventDefault(); i.state.loading = true; - if (i.props.community_view) { - let form: EditCommunity = { - ...i.state.communityForm, - community_id: i.props.community_view.community.id, - }; - WebSocketService.Instance.send(wsClient.editCommunity(form)); - } else { - WebSocketService.Instance.send( - wsClient.createCommunity(i.state.communityForm) - ); - } + let cForm = i.state.communityForm; + + i.props.community_view.match({ + some: cv => { + let form = new EditCommunity({ + community_id: cv.community.id, + title: Some(cForm.title), + description: cForm.description, + icon: cForm.icon, + banner: cForm.banner, + nsfw: cForm.nsfw, + posting_restricted_to_mods: cForm.posting_restricted_to_mods, + auth: auth().unwrap(), + }); + + WebSocketService.Instance.send(wsClient.editCommunity(form)); + }, + none: () => { + WebSocketService.Instance.send( + wsClient.createCommunity(i.state.communityForm) + ); + }, + }); i.setState(i.state); } @@ -306,7 +329,7 @@ export class CommunityForm extends Component< } handleCommunityDescriptionChange(val: string) { - this.state.communityForm.description = val; + this.state.communityForm.description = Some(val); this.setState(this.state); } @@ -325,22 +348,22 @@ export class CommunityForm extends Component< } handleIconUpload(url: string) { - this.state.communityForm.icon = url; + this.state.communityForm.icon = Some(url); this.setState(this.state); } handleIconRemove() { - this.state.communityForm.icon = ""; + this.state.communityForm.icon = Some(""); this.setState(this.state); } handleBannerUpload(url: string) { - this.state.communityForm.banner = url; + this.state.communityForm.banner = Some(url); this.setState(this.state); } handleBannerRemove() { - this.state.communityForm.banner = ""; + this.state.communityForm.banner = Some(""); this.setState(this.state); } @@ -354,42 +377,51 @@ export class CommunityForm extends Component< this.setState(this.state); return; } else if (op == UserOperation.CreateCommunity) { - let data = wsJsonToRes<CommunityResponse>(msg).data; + let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse); this.state.loading = false; this.props.onCreate(data.community_view); // Update myUserInfo let community = data.community_view.community; - let person = UserService.Instance.myUserInfo.local_user_view.person; - UserService.Instance.myUserInfo.follows.push({ - community, - follower: person, - }); - UserService.Instance.myUserInfo.moderates.push({ - community, - moderator: person, + + UserService.Instance.myUserInfo.match({ + some: mui => { + let person = mui.local_user_view.person; + mui.follows.push({ + community, + follower: person, + }); + mui.moderates.push({ + community, + moderator: person, + }); + }, + none: void 0, }); } else if (op == UserOperation.EditCommunity) { - let data = wsJsonToRes<CommunityResponse>(msg).data; + let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse); this.state.loading = false; this.props.onEdit(data.community_view); let community = data.community_view.community; - let followFound = UserService.Instance.myUserInfo.follows.findIndex( - f => f.community.id == community.id - ); - if (followFound) { - UserService.Instance.myUserInfo.follows[followFound].community = - community; - } - - let moderatesFound = UserService.Instance.myUserInfo.moderates.findIndex( - f => f.community.id == community.id - ); - if (moderatesFound) { - UserService.Instance.myUserInfo.moderates[moderatesFound].community = - community; - } + UserService.Instance.myUserInfo.match({ + some: mui => { + let followFound = mui.follows.findIndex( + f => f.community.id == community.id + ); + if (followFound) { + mui.follows[followFound].community = community; + } + + let moderatesFound = mui.moderates.findIndex( + f => f.community.id == community.id + ); + if (moderatesFound) { + mui.moderates[moderatesFound].community = community; + } + }, + none: void 0, + }); } } } diff --git a/src/shared/components/community/community-link.tsx b/src/shared/components/community/community-link.tsx index 4ea7f8a..cc98705 100644 --- a/src/shared/components/community/community-link.tsx +++ b/src/shared/components/community/community-link.tsx @@ -56,12 +56,14 @@ export class CommunityLink extends Component<CommunityLinkProps, any> { } avatarAndName(displayName: string) { - let community = this.props.community; return ( <> - {!this.props.hideAvatar && community.icon && showAvatars() && ( - <PictrsImage src={community.icon} icon /> - )} + {!this.props.hideAvatar && + showAvatars() && + this.props.community.icon.match({ + some: icon => <PictrsImage src={icon} icon />, + none: <></>, + })} <span class="overflow-wrap-anywhere">{displayName}</span> </> ); diff --git a/src/shared/components/community/community.tsx b/src/shared/components/community/community.tsx index 0dd6f03..732cba2 100644 --- a/src/shared/components/community/community.tsx +++ b/src/shared/components/community/community.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { AddModToCommunityResponse, @@ -19,20 +20,25 @@ import { PostResponse, PostView, SortType, + toOption, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { DataType, InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, commentsToFlatNodes, communityRSSUrl, createCommentLikeRes, createPostLikeFindRes, editCommentRes, editPostFindRes, + enableDownvotes, + enableNsfw, fetchLimit, getDataTypeFromProps, getPageFromProps, @@ -43,15 +49,12 @@ import { saveCommentRes, saveScrollPosition, setIsoData, - setOptionalAuth, setupTippy, showLocal, toast, updatePersonBlock, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { CommentNodes } from "../comment/comment-nodes"; import { BannerIconHeader } from "../common/banner-icon-header"; @@ -66,7 +69,7 @@ import { PostListings } from "../post/post-listings"; import { CommunityLink } from "./community-link"; interface State { - communityRes: GetCommunityResponse; + communityRes: Option<GetCommunityResponse>; siteRes: GetSiteResponse; communityName: string; communityLoading: boolean; @@ -93,10 +96,15 @@ interface UrlParams { } export class Community extends Component<any, State> { - private isoData = setIsoData(this.context); + private isoData = setIsoData( + this.context, + GetCommunityResponse, + GetPostsResponse, + GetCommentsResponse + ); private subscription: Subscription; private emptyState: State = { - communityRes: undefined, + communityRes: None, communityName: this.props.match.params.name, communityLoading: true, postsLoading: true, @@ -123,12 +131,21 @@ export class Community extends Component<any, State> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.communityRes = this.isoData.routeData[0]; - if (this.state.dataType == DataType.Post) { - this.state.posts = this.isoData.routeData[1].posts; - } else { - this.state.comments = this.isoData.routeData[1].comments; - } + this.state.communityRes = Some( + this.isoData.routeData[0] as GetCommunityResponse + ); + let postsRes = Some(this.isoData.routeData[1] as GetPostsResponse); + let commentsRes = Some(this.isoData.routeData[2] as GetCommentsResponse); + + postsRes.match({ + some: pvs => (this.state.posts = pvs.posts), + none: void 0, + }); + commentsRes.match({ + some: cvs => (this.state.comments = cvs.comments), + none: void 0, + }); + this.state.communityLoading = false; this.state.postsLoading = false; this.state.commentsLoading = false; @@ -139,10 +156,11 @@ export class Community extends Component<any, State> { } fetchCommunity() { - let form: GetCommunity = { - name: this.state.communityName ? this.state.communityName : null, - auth: authField(false), - }; + let form = new GetCommunity({ + name: Some(this.state.communityName), + id: None, + auth: auth(false).ok(), + }); WebSocketService.Instance.send(wsClient.getCommunity(form)); } @@ -169,56 +187,62 @@ export class Community extends Component<any, State> { let promises: Promise<any>[] = []; let communityName = pathSplit[2]; - let communityForm: GetCommunity = { name: communityName }; - setOptionalAuth(communityForm, req.auth); + let communityForm = new GetCommunity({ + name: Some(communityName), + id: None, + auth: req.auth, + }); promises.push(req.client.getCommunity(communityForm)); let dataType: DataType = pathSplit[4] ? DataType[pathSplit[4]] : DataType.Post; - let sort: SortType = pathSplit[6] - ? SortType[pathSplit[6]] - : UserService.Instance.myUserInfo - ? Object.values(SortType)[ - UserService.Instance.myUserInfo.local_user_view.local_user - .default_sort_type - ] - : SortType.Active; + let sort: Option<SortType> = toOption( + pathSplit[6] + ? SortType[pathSplit[6]] + : UserService.Instance.myUserInfo.match({ + some: mui => + Object.values(SortType)[ + mui.local_user_view.local_user.default_sort_type + ], + none: SortType.Active, + }) + ); - let page = pathSplit[8] ? Number(pathSplit[8]) : 1; + let page = toOption(pathSplit[8] ? Number(pathSplit[8]) : 1); if (dataType == DataType.Post) { - let getPostsForm: GetPosts = { + let getPostsForm = new GetPosts({ + community_name: Some(communityName), + community_id: None, page, - limit: fetchLimit, + limit: Some(fetchLimit), sort, - type_: ListingType.Community, - saved_only: false, - }; - setOptionalAuth(getPostsForm, req.auth); - this.setName(getPostsForm, communityName); + type_: Some(ListingType.Community), + saved_only: Some(false), + auth: req.auth, + }); promises.push(req.client.getPosts(getPostsForm)); + promises.push(Promise.resolve()); } else { - let getCommentsForm: GetComments = { + let getCommentsForm = new GetComments({ + community_name: Some(communityName), + community_id: None, page, - limit: fetchLimit, + limit: Some(fetchLimit), sort, - type_: ListingType.Community, - saved_only: false, - }; - this.setName(getCommentsForm, communityName); - setOptionalAuth(getCommentsForm, req.auth); + type_: Some(ListingType.Community), + saved_only: Some(false), + auth: req.auth, + }); + promises.push(Promise.resolve()); promises.push(req.client.getComments(getCommentsForm)); } return promises; } - static setName(obj: any, name_: string) { - obj.community_name = name_; - } - componentDidUpdate(_: any, lastState: State) { if ( lastState.dataType !== this.state.dataType || @@ -231,11 +255,18 @@ export class Community extends Component<any, State> { } get documentTitle(): string { - return `${this.state.communityRes.community_view.community.title} - ${this.state.siteRes.site_view.site.name}`; + return this.state.communityRes.match({ + some: res => + this.state.siteRes.site_view.match({ + some: siteView => + `${res.community_view.community.title} - ${siteView.site.name}`, + none: "", + }), + none: "", + }); } render() { - let cv = this.state.communityRes?.community_view; return ( <div class="container"> {this.state.communityLoading ? ( @@ -243,83 +274,99 @@ export class Community extends Component<any, State> { <Spinner large /> </h5> ) : ( - <> - <HtmlTags - title={this.documentTitle} - path={this.context.router.route.match.url} - description={cv.community.description} - image={cv.community.icon} - /> + this.state.communityRes.match({ + some: res => ( + <> + <HtmlTags + title={this.documentTitle} + path={this.context.router.route.match.url} + description={res.community_view.community.description} + image={res.community_view.community.icon} + /> - <div class="row"> - <div class="col-12 col-md-8"> - {this.communityInfo()} - <div class="d-block d-md-none"> - <button - class="btn btn-secondary d-inline-block mb-2 mr-3" - onClick={linkEvent(this, this.handleShowSidebarMobile)} - > - {i18n.t("sidebar")}{" "} - <Icon - icon={ - this.state.showSidebarMobile - ? `minus-square` - : `plus-square` - } - classes="icon-inline" - /> - </button> - {this.state.showSidebarMobile && ( - <> - <Sidebar - community_view={cv} - moderators={this.state.communityRes.moderators} - admins={this.state.siteRes.admins} - online={this.state.communityRes.online} - enableNsfw={ - this.state.siteRes.site_view.site.enable_nsfw - } - /> - {!cv.community.local && this.state.communityRes.site && ( - <SiteSidebar - site={this.state.communityRes.site} - showLocal={showLocal(this.isoData)} + <div class="row"> + <div class="col-12 col-md-8"> + {this.communityInfo()} + <div class="d-block d-md-none"> + <button + class="btn btn-secondary d-inline-block mb-2 mr-3" + onClick={linkEvent(this, this.handleShowSidebarMobile)} + > + {i18n.t("sidebar")}{" "} + <Icon + icon={ + this.state.showSidebarMobile + ? `minus-square` + : `plus-square` + } + classes="icon-inline" /> + </button> + {this.state.showSidebarMobile && ( + <> + <Sidebar + community_view={res.community_view} + moderators={res.moderators} + admins={this.state.siteRes.admins} + online={res.online} + enableNsfw={enableNsfw(this.state.siteRes)} + /> + {!res.community_view.community.local && + res.site.match({ + some: site => ( + <SiteSidebar + site={site} + showLocal={showLocal(this.isoData)} + admins={None} + counts={None} + online={None} + /> + ), + none: <></>, + })} + </> )} - </> - )} + </div> + {this.selects()} + {this.listings()} + <Paginator + page={this.state.page} + onChange={this.handlePageChange} + /> + </div> + <div class="d-none d-md-block col-md-4"> + <Sidebar + community_view={res.community_view} + moderators={res.moderators} + admins={this.state.siteRes.admins} + online={res.online} + enableNsfw={enableNsfw(this.state.siteRes)} + /> + {!res.community_view.community.local && + res.site.match({ + some: site => ( + <SiteSidebar + site={site} + showLocal={showLocal(this.isoData)} + admins={None} + counts={None} + online={None} + /> + ), + none: <></>, + })} + </div> </div> - {this.selects()} - {this.listings()} - <Paginator - page={this.state.page} - onChange={this.handlePageChange} - /> - </div> - <div class="d-none d-md-block col-md-4"> - <Sidebar - community_view={cv} - moderators={this.state.communityRes.moderators} - admins={this.state.siteRes.admins} - online={this.state.communityRes.online} - enableNsfw={this.state.siteRes.site_view.site.enable_nsfw} - /> - {!cv.community.local && this.state.communityRes.site && ( - <SiteSidebar - site={this.state.communityRes.site} - showLocal={showLocal(this.isoData)} - /> - )} - </div> - </div> - </> + </> + ), + none: <></>, + }) )} </div> ); } listings() { - let site = this.state.siteRes.site_view.site; return this.state.dataType == DataType.Post ? ( this.state.postsLoading ? ( <h5> @@ -329,8 +376,8 @@ export class Community extends Component<any, State> { <PostListings posts={this.state.posts} removeDuplicates - enableDownvotes={site.enable_downvotes} - enableNsfw={site.enable_nsfw} + enableDownvotes={enableDownvotes(this.state.siteRes)} + enableNsfw={enableNsfw(this.state.siteRes)} /> ) ) : this.state.commentsLoading ? ( @@ -342,32 +389,38 @@ export class Community extends Component<any, State> { nodes={commentsToFlatNodes(this.state.comments)} noIndent showContext - enableDownvotes={site.enable_downvotes} + enableDownvotes={enableDownvotes(this.state.siteRes)} + moderators={this.state.communityRes.map(r => r.moderators)} + admins={Some(this.state.siteRes.admins)} + maxCommentsShown={None} /> ); } communityInfo() { - let community = this.state.communityRes.community_view.community; - return ( - <div class="mb-2"> - <BannerIconHeader banner={community.banner} icon={community.icon} /> - <h5 class="mb-0 overflow-wrap-anywhere">{community.title}</h5> - <CommunityLink - community={community} - realLink - useApubName - muted - hideAvatar - /> - </div> - ); + return this.state.communityRes + .map(r => r.community_view.community) + .match({ + some: community => ( + <div class="mb-2"> + <BannerIconHeader banner={community.banner} icon={community.icon} /> + <h5 class="mb-0 overflow-wrap-anywhere">{community.title}</h5> + <CommunityLink + community={community} + realLink + useApubName + muted + hideAvatar + /> + </div> + ), + none: <></>, + }); } selects() { - let communityRss = communityRSSUrl( - this.state.communityRes.community_view.community.actor_id, - this.state.sort + let communityRss = this.state.communityRes.map(r => + communityRSSUrl(r.community_view.community.actor_id, this.state.sort) ); return ( <div class="mb-3"> @@ -380,10 +433,17 @@ export class Community extends Component<any, State> { <span class="mr-2"> <SortSelect sort={this.state.sort} onChange={this.handleSortChange} /> </span> - <a href={communityRss} title="RSS" rel={relTags}> - <Icon icon="rss" classes="text-muted small" /> - </a> - <link rel="alternate" type="application/atom+xml" href={communityRss} /> + {communityRss.match({ + some: rss => ( + <> + <a href={rss} title="RSS" rel={relTags}> + <Icon icon="rss" classes="text-muted small" /> + </a> + <link rel="alternate" type="application/atom+xml" href={rss} /> + </> + ), + none: <></>, + })} </div> ); } @@ -422,26 +482,28 @@ export class Community extends Component<any, State> { fetchData() { if (this.state.dataType == DataType.Post) { - let form: GetPosts = { - page: this.state.page, - limit: fetchLimit, - sort: this.state.sort, - type_: ListingType.Community, - community_name: this.state.communityName, - saved_only: false, - auth: authField(false), - }; + let form = new GetPosts({ + page: Some(this.state.page), + limit: Some(fetchLimit), + sort: Some(this.state.sort), + type_: Some(ListingType.Community), + community_name: Some(this.state.communityName), + community_id: None, + saved_only: Some(false), + auth: auth(false).ok(), + }); WebSocketService.Instance.send(wsClient.getPosts(form)); } else { - let form: GetComments = { - page: this.state.page, - limit: fetchLimit, - sort: this.state.sort, - type_: ListingType.Community, - community_name: this.state.communityName, - saved_only: false, - auth: authField(false), - }; + let form = new GetComments({ + page: Some(this.state.page), + limit: Some(fetchLimit), + sort: Some(this.state.sort), + type_: Some(ListingType.Community), + community_name: Some(this.state.communityName), + community_id: None, + saved_only: Some(false), + auth: auth(false).ok(), + }); WebSocketService.Instance.send(wsClient.getComments(form)); } } @@ -454,15 +516,20 @@ export class Community extends Component<any, State> { this.context.router.history.push("/"); return; } else if (msg.reconnect) { - WebSocketService.Instance.send( - wsClient.communityJoin({ - community_id: this.state.communityRes.community_view.community.id, - }) - ); + this.state.communityRes.match({ + some: res => { + WebSocketService.Instance.send( + wsClient.communityJoin({ + community_id: res.community_view.community.id, + }) + ); + }, + none: void 0, + }); this.fetchData(); } else if (op == UserOperation.GetCommunity) { - let data = wsJsonToRes<GetCommunityResponse>(msg).data; - this.state.communityRes = data; + let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse); + this.state.communityRes = Some(data); this.state.communityLoading = false; this.setState(this.state); // TODO why is there no auth in this form? @@ -476,18 +543,25 @@ export class Community extends Component<any, State> { op == UserOperation.DeleteCommunity || op == UserOperation.RemoveCommunity ) { - let data = wsJsonToRes<CommunityResponse>(msg).data; - this.state.communityRes.community_view = data.community_view; + let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse); + this.state.communityRes.match({ + some: res => (res.community_view = data.community_view), + none: void 0, + }); this.setState(this.state); } else if (op == UserOperation.FollowCommunity) { - let data = wsJsonToRes<CommunityResponse>(msg).data; - this.state.communityRes.community_view.subscribed = - data.community_view.subscribed; - this.state.communityRes.community_view.counts.subscribers = - data.community_view.counts.subscribers; + let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse); + this.state.communityRes.match({ + some: res => { + res.community_view.subscribed = data.community_view.subscribed; + res.community_view.counts.subscribers = + data.community_view.counts.subscribers; + }, + none: void 0, + }); this.setState(this.state); } else if (op == UserOperation.GetPosts) { - let data = wsJsonToRes<GetPostsResponse>(msg).data; + let data = wsJsonToRes<GetPostsResponse>(msg, GetPostsResponse); this.state.posts = data.posts; this.state.postsLoading = false; this.setState(this.state); @@ -501,29 +575,39 @@ export class Community extends Component<any, State> { op == UserOperation.StickyPost || op == UserOperation.SavePost ) { - let data = wsJsonToRes<PostResponse>(msg).data; + let data = wsJsonToRes<PostResponse>(msg, PostResponse); editPostFindRes(data.post_view, this.state.posts); this.setState(this.state); } else if (op == UserOperation.CreatePost) { - let data = wsJsonToRes<PostResponse>(msg).data; + let data = wsJsonToRes<PostResponse>(msg, PostResponse); this.state.posts.unshift(data.post_view); if ( - UserService.Instance.myUserInfo?.local_user_view.local_user - .show_new_post_notifs + UserService.Instance.myUserInfo + .map(m => m.local_user_view.local_user.show_new_post_notifs) + .unwrapOr(false) ) { notifyPost(data.post_view, this.context.router); } this.setState(this.state); } else if (op == UserOperation.CreatePostLike) { - let data = wsJsonToRes<PostResponse>(msg).data; + let data = wsJsonToRes<PostResponse>(msg, PostResponse); createPostLikeFindRes(data.post_view, this.state.posts); this.setState(this.state); } else if (op == UserOperation.AddModToCommunity) { - let data = wsJsonToRes<AddModToCommunityResponse>(msg).data; - this.state.communityRes.moderators = data.moderators; + let data = wsJsonToRes<AddModToCommunityResponse>( + msg, + AddModToCommunityResponse + ); + this.state.communityRes.match({ + some: res => (res.moderators = data.moderators), + none: void 0, + }); this.setState(this.state); } else if (op == UserOperation.BanFromCommunity) { - let data = wsJsonToRes<BanFromCommunityResponse>(msg).data; + let data = wsJsonToRes<BanFromCommunityResponse>( + msg, + BanFromCommunityResponse + ); // TODO this might be incorrect this.state.posts @@ -532,7 +616,7 @@ export class Community extends Component<any, State> { this.setState(this.state); } else if (op == UserOperation.GetComments) { - let data = wsJsonToRes<GetCommentsResponse>(msg).data; + let data = wsJsonToRes<GetCommentsResponse>(msg, GetCommentsResponse); this.state.comments = data.comments; this.state.commentsLoading = false; this.setState(this.state); @@ -541,11 +625,11 @@ export class Community extends Component<any, State> { op == UserOperation.DeleteComment || op == UserOperation.RemoveComment ) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); editCommentRes(data.comment_view, this.state.comments); this.setState(this.state); } else if (op == UserOperation.CreateComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); // Necessary since it might be a user reply if (data.form_id) { @@ -553,23 +637,23 @@ export class Community extends Component<any, State> { this.setState(this.state); } } else if (op == UserOperation.SaveComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); saveCommentRes(data.comment_view, this.state.comments); this.setState(this.state); } else if (op == UserOperation.CreateCommentLike) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); createCommentLikeRes(data.comment_view, this.state.comments); this.setState(this.state); } else if (op == UserOperation.BlockPerson) { - let data = wsJsonToRes<BlockPersonResponse>(msg).data; + let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse); updatePersonBlock(data); } else if (op == UserOperation.CreatePostReport) { - let data = wsJsonToRes<PostReportResponse>(msg).data; + let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse); if (data) { toast(i18n.t("report_created")); } } else if (op == UserOperation.CreateCommentReport) { - let data = wsJsonToRes<CommentReportResponse>(msg).data; + let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse); if (data) { toast(i18n.t("report_created")); } diff --git a/src/shared/components/community/create-community.tsx b/src/shared/components/community/create-community.tsx index f40bfd2..9a36678 100644 --- a/src/shared/components/community/create-community.tsx +++ b/src/shared/components/community/create-community.tsx @@ -1,15 +1,22 @@ +import { None } from "@sniptt/monads"; import { Component } from "inferno"; -import { CommunityView, SiteView } from "lemmy-js-client"; +import { CommunityView, GetSiteResponse } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { UserService } from "../../services"; -import { isBrowser, setIsoData, toast, wsSubscribe } from "../../utils"; +import { + enableNsfw, + isBrowser, + setIsoData, + toast, + wsSubscribe, +} from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; import { CommunityForm } from "./community-form"; interface CreateCommunityState { - site_view: SiteView; + siteRes: GetSiteResponse; loading: boolean; } @@ -17,7 +24,7 @@ export class CreateCommunity extends Component<any, CreateCommunityState> { private isoData = setIsoData(this.context); private subscription: Subscription; private emptyState: CreateCommunityState = { - site_view: this.isoData.site_res.site_view, + siteRes: this.isoData.site_res, loading: false, }; constructor(props: any, context: any) { @@ -28,7 +35,7 @@ export class CreateCommunity extends Component<any, CreateCommunityState> { this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); - if (!UserService.Instance.myUserInfo && isBrowser()) { + if (UserService.Instance.myUserInfo.isNone() && isBrowser()) { toast(i18n.t("not_logged_in"), "danger"); this.context.router.history.push(`/login`); } @@ -41,7 +48,10 @@ export class CreateCommunity extends Component<any, CreateCommunityState> { } get documentTitle(): string { - return `${i18n.t("create_community")} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `${i18n.t("create_community")} - ${siteView.site.name}`, + none: "", + }); } render() { @@ -50,6 +60,8 @@ export class CreateCommunity extends Component<any, CreateCommunityState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> {this.state.loading ? ( <h5> @@ -60,8 +72,9 @@ export class CreateCommunity extends Component<any, CreateCommunityState> { <div class="col-12 col-lg-6 offset-lg-3 mb-4"> <h5>{i18n.t("create_community")}</h5> <CommunityForm + community_view={None} onCreate={this.handleCommunityCreate} - enableNsfw={this.state.site_view.site.enable_nsfw} + enableNsfw={enableNsfw(this.state.siteRes)} /> </div> </div> diff --git a/src/shared/components/community/sidebar.tsx b/src/shared/components/community/sidebar.tsx index baa655f..159080a 100644 --- a/src/shared/components/community/sidebar.tsx +++ b/src/shared/components/community/sidebar.tsx @@ -1,3 +1,4 @@ +import { Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { Link } from "inferno-router"; import { @@ -8,11 +9,15 @@ import { FollowCommunity, PersonViewSafe, RemoveCommunity, + toUndefined, } from "lemmy-js-client"; import { i18n } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; import { - authField, + amAdmin, + amMod, + amTopMod, + auth, getUnixTime, mdToHtml, numToSI, @@ -29,15 +34,15 @@ interface SidebarProps { moderators: CommunityModeratorView[]; admins: PersonViewSafe[]; online: number; - enableNsfw: boolean; + enableNsfw?: boolean; showIcon?: boolean; } interface SidebarState { + removeReason: Option<string>; + removeExpires: Option<string>; showEdit: boolean; showRemoveDialog: boolean; - removeReason: string; - removeExpires: string; showConfirmLeaveModTeam: boolean; } @@ -64,7 +69,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { this.sidebar() ) : ( <CommunityForm - community_view={this.props.community_view} + community_view={Some(this.props.community_view)} onEdit={this.handleEditCommunity} onCancel={this.handleEditCancel} enableNsfw={this.props.enableNsfw} @@ -284,14 +289,12 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { description() { let description = this.props.community_view.community.description; - return ( - description && ( - <div - className="md-div" - dangerouslySetInnerHTML={mdToHtml(description)} - /> - ) - ); + return description.match({ + some: desc => ( + <div className="md-div" dangerouslySetInnerHTML={mdToHtml(desc)} /> + ), + none: <></>, + }); } adminButtons() { @@ -299,7 +302,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { return ( <> <ul class="list-inline mb-1 text-muted font-weight-bold"> - {this.canMod && ( + {amMod(Some(this.props.moderators)) && ( <> <li className="list-inline-item-action"> <button @@ -311,7 +314,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { <Icon icon="edit" classes="icon-inline" /> </button> </li> - {!this.amTopMod && + {!amTopMod(Some(this.props.moderators)) && (!this.state.showConfirmLeaveModTeam ? ( <li className="list-inline-item-action"> <button @@ -350,7 +353,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { </li> </> ))} - {this.amTopMod && ( + {amTopMod(Some(this.props.moderators)) && ( <li className="list-inline-item-action"> <button class="btn btn-link text-muted d-inline-block" @@ -377,7 +380,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { )} </> )} - {this.canAdmin && ( + {amAdmin(Some(this.props.admins)) && ( <li className="list-inline-item"> {!this.props.community_view.community.removed ? ( <button @@ -408,7 +411,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { id="remove-reason" class="form-control mr-2" placeholder={i18n.t("optional")} - value={this.state.removeReason} + value={toUndefined(this.state.removeReason)} onInput={linkEvent(this, this.handleModRemoveReasonChange)} /> </div> @@ -445,11 +448,11 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { handleDeleteClick(i: Sidebar, event: any) { event.preventDefault(); - let deleteForm: DeleteCommunity = { + let deleteForm = new DeleteCommunity({ community_id: i.props.community_view.community.id, deleted: !i.props.community_view.community.deleted, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.deleteCommunity(deleteForm)); } @@ -459,15 +462,20 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { } handleLeaveModTeamClick(i: Sidebar) { - let form: AddModToCommunity = { - person_id: UserService.Instance.myUserInfo.local_user_view.person.id, - community_id: i.props.community_view.community.id, - added: false, - auth: authField(), - }; - WebSocketService.Instance.send(wsClient.addModToCommunity(form)); - i.state.showConfirmLeaveModTeam = false; - i.setState(i.state); + UserService.Instance.myUserInfo.match({ + some: mui => { + let form = new AddModToCommunity({ + person_id: mui.local_user_view.person.id, + community_id: i.props.community_view.community.id, + added: false, + auth: auth().unwrap(), + }); + WebSocketService.Instance.send(wsClient.addModToCommunity(form)); + i.state.showConfirmLeaveModTeam = false; + i.setState(i.state); + }, + none: void 0, + }); } handleCancelLeaveModTeamClick(i: Sidebar) { @@ -478,67 +486,47 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { handleUnsubscribe(i: Sidebar, event: any) { event.preventDefault(); let community_id = i.props.community_view.community.id; - let form: FollowCommunity = { + let form = new FollowCommunity({ community_id, follow: false, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.followCommunity(form)); // Update myUserInfo - UserService.Instance.myUserInfo.follows = - UserService.Instance.myUserInfo.follows.filter( - i => i.community.id != community_id - ); + UserService.Instance.myUserInfo.match({ + some: mui => + (mui.follows = mui.follows.filter(i => i.community.id != community_id)), + none: void 0, + }); } handleSubscribe(i: Sidebar, event: any) { event.preventDefault(); let community_id = i.props.community_view.community.id; - let form: FollowCommunity = { + let form = new FollowCommunity({ community_id, follow: true, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.followCommunity(form)); // Update myUserInfo - UserService.Instance.myUserInfo.follows.push({ - community: i.props.community_view.community, - follower: UserService.Instance.myUserInfo.local_user_view.person, + UserService.Instance.myUserInfo.match({ + some: mui => + mui.follows.push({ + community: i.props.community_view.community, + follower: mui.local_user_view.person, + }), + none: void 0, }); } - private get amTopMod(): boolean { - return ( - this.props.moderators[0].moderator.id == - UserService.Instance.myUserInfo.local_user_view.person.id - ); - } - - get canMod(): boolean { - return ( - UserService.Instance.myUserInfo && - this.props.moderators - .map(m => m.moderator.id) - .includes(UserService.Instance.myUserInfo.local_user_view.person.id) - ); - } - - get canAdmin(): boolean { - return ( - UserService.Instance.myUserInfo && - this.props.admins - .map(a => a.person.id) - .includes(UserService.Instance.myUserInfo.local_user_view.person.id) - ); - } - get canPost(): boolean { return ( !this.props.community_view.community.posting_restricted_to_mods || - this.canMod || - this.canAdmin + amMod(Some(this.props.moderators)) || + amAdmin(Some(this.props.admins)) ); } @@ -560,13 +548,13 @@ export class Sidebar extends Component<SidebarProps, SidebarState> { handleModRemoveSubmit(i: Sidebar, event: any) { event.preventDefault(); - let removeForm: RemoveCommunity = { + let removeForm = new RemoveCommunity({ community_id: i.props.community_view.community.id, removed: !i.props.community_view.community.removed, reason: i.state.removeReason, - expires: getUnixTime(i.state.removeExpires), - auth: authField(), - }; + expires: i.state.removeExpires.map(getUnixTime), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.removeCommunity(removeForm)); i.state.showRemoveDialog = false; diff --git a/src/shared/components/home/admin-settings.tsx b/src/shared/components/home/admin-settings.tsx index cb78484..67a000c 100644 --- a/src/shared/components/home/admin-settings.tsx +++ b/src/shared/components/home/admin-settings.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import autosize from "autosize"; import { Component, linkEvent } from "inferno"; import { @@ -9,14 +10,17 @@ import { PersonViewSafe, SaveSiteConfig, SiteResponse, + toUndefined, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { InitialFetchRequest } from "../../interfaces"; import { WebSocketService } from "../../services"; import { - authField, + auth, capitalizeFirstLetter, isBrowser, randomStr, @@ -24,9 +28,7 @@ import { showLocal, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; @@ -35,24 +37,26 @@ import { SiteForm } from "./site-form"; interface AdminSettingsState { siteRes: GetSiteResponse; - siteConfigRes: GetSiteConfigResponse; - siteConfigHjson: string; - loading: boolean; + siteConfigRes: Option<GetSiteConfigResponse>; + siteConfigHjson: Option<string>; banned: PersonViewSafe[]; + loading: boolean; siteConfigLoading: boolean; leaveAdminTeamLoading: boolean; } export class AdminSettings extends Component<any, AdminSettingsState> { private siteConfigTextAreaId = `site-config-${randomStr()}`; - private isoData = setIsoData(this.context); + private isoData = setIsoData( + this.context, + GetSiteConfigResponse, + BannedPersonsResponse + ); private subscription: Subscription; private emptyState: AdminSettingsState = { siteRes: this.isoData.site_res, - siteConfigHjson: null, - siteConfigRes: { - config_hjson: null, - }, + siteConfigHjson: None, + siteConfigRes: None, banned: [], loading: true, siteConfigLoading: null, @@ -69,20 +73,26 @@ export class AdminSettings extends Component<any, AdminSettingsState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.siteConfigRes = this.isoData.routeData[0]; - this.state.siteConfigHjson = this.state.siteConfigRes.config_hjson; - this.state.banned = this.isoData.routeData[1].banned; + this.state.siteConfigRes = Some( + this.isoData.routeData[0] as GetSiteConfigResponse + ); + this.state.siteConfigHjson = this.state.siteConfigRes.map( + s => s.config_hjson + ); + this.state.banned = ( + this.isoData.routeData[1] as BannedPersonsResponse + ).banned; this.state.siteConfigLoading = false; this.state.loading = false; } else { WebSocketService.Instance.send( wsClient.getSiteConfig({ - auth: authField(), + auth: auth().unwrap(), }) ); WebSocketService.Instance.send( wsClient.getBannedPersons({ - auth: authField(), + auth: auth().unwrap(), }) ); } @@ -91,10 +101,10 @@ export class AdminSettings extends Component<any, AdminSettingsState> { static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let promises: Promise<any>[] = []; - let siteConfigForm: GetSiteConfig = { auth: req.auth }; + let siteConfigForm = new GetSiteConfig({ auth: req.auth.unwrap() }); promises.push(req.client.getSiteConfig(siteConfigForm)); - let bannedPersonsForm: GetBannedPersons = { auth: req.auth }; + let bannedPersonsForm = new GetBannedPersons({ auth: req.auth.unwrap() }); promises.push(req.client.getBannedPersons(bannedPersonsForm)); return promises; @@ -114,9 +124,10 @@ export class AdminSettings extends Component<any, AdminSettingsState> { } get documentTitle(): string { - return `${i18n.t("admin_settings")} - ${ - this.state.siteRes.site_view.site.name - }`; + return this.state.siteRes.site_view.match({ + some: siteView => `${i18n.t("admin_settings")} - ${siteView.site.name}`, + none: "", + }); } render() { @@ -132,13 +143,18 @@ export class AdminSettings extends Component<any, AdminSettingsState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> - {this.state.siteRes.site_view.site.id && ( - <SiteForm - site={this.state.siteRes.site_view.site} - showLocal={showLocal(this.isoData)} - /> - )} + {this.state.siteRes.site_view.match({ + some: siteView => ( + <SiteForm + site={Some(siteView.site)} + showLocal={showLocal(this.isoData)} + /> + ), + none: <></>, + })} {this.admins()} {this.bannedUsers()} </div> @@ -210,7 +226,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> { <div class="col-12"> <textarea id={this.siteConfigTextAreaId} - value={this.state.siteConfigHjson} + value={toUndefined(this.state.siteConfigHjson)} onInput={linkEvent(this, this.handleSiteConfigHjsonChange)} class="form-control text-monospace" rows={3} @@ -236,10 +252,10 @@ export class AdminSettings extends Component<any, AdminSettingsState> { handleSiteConfigSubmit(i: AdminSettings, event: any) { event.preventDefault(); i.state.siteConfigLoading = true; - let form: SaveSiteConfig = { - config_hjson: i.state.siteConfigHjson, - auth: authField(), - }; + let form = new SaveSiteConfig({ + config_hjson: toUndefined(i.state.siteConfigHjson), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.saveSiteConfig(form)); i.setState(i.state); } @@ -251,7 +267,9 @@ export class AdminSettings extends Component<any, AdminSettingsState> { handleLeaveAdminTeam(i: AdminSettings) { i.state.leaveAdminTeamLoading = true; - WebSocketService.Instance.send(wsClient.leaveAdmin({ auth: authField() })); + WebSocketService.Instance.send( + wsClient.leaveAdmin({ auth: auth().unwrap() }) + ); i.setState(i.state); } @@ -265,24 +283,26 @@ export class AdminSettings extends Component<any, AdminSettingsState> { this.setState(this.state); return; } else if (op == UserOperation.EditSite) { - let data = wsJsonToRes<SiteResponse>(msg).data; - this.state.siteRes.site_view = data.site_view; + let data = wsJsonToRes<SiteResponse>(msg, SiteResponse); + this.state.siteRes.site_view = Some(data.site_view); this.setState(this.state); toast(i18n.t("site_saved")); } else if (op == UserOperation.GetBannedPersons) { - let data = wsJsonToRes<BannedPersonsResponse>(msg).data; + let data = wsJsonToRes<BannedPersonsResponse>(msg, BannedPersonsResponse); this.state.banned = data.banned; this.setState(this.state); } else if (op == UserOperation.GetSiteConfig) { - let data = wsJsonToRes<GetSiteConfigResponse>(msg).data; - this.state.siteConfigRes = data; + let data = wsJsonToRes<GetSiteConfigResponse>(msg, GetSiteConfigResponse); + this.state.siteConfigRes = Some(data); this.state.loading = false; - this.state.siteConfigHjson = this.state.siteConfigRes.config_hjson; + this.state.siteConfigHjson = this.state.siteConfigRes.map( + s => s.config_hjson + ); this.setState(this.state); var textarea: any = document.getElementById(this.siteConfigTextAreaId); autosize(textarea); } else if (op == UserOperation.LeaveAdmin) { - let data = wsJsonToRes<GetSiteResponse>(msg).data; + let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse); this.state.siteRes.site_view = data.site_view; this.setState(this.state); this.state.leaveAdminTeamLoading = false; @@ -290,9 +310,11 @@ export class AdminSettings extends Component<any, AdminSettingsState> { this.setState(this.state); this.context.router.history.push("/"); } else if (op == UserOperation.SaveSiteConfig) { - let data = wsJsonToRes<GetSiteConfigResponse>(msg).data; - this.state.siteConfigRes = data; - this.state.siteConfigHjson = this.state.siteConfigRes.config_hjson; + let data = wsJsonToRes<GetSiteConfigResponse>(msg, GetSiteConfigResponse); + this.state.siteConfigRes = Some(data); + this.state.siteConfigHjson = this.state.siteConfigRes.map( + s => s.config_hjson + ); this.state.siteConfigLoading = false; toast(i18n.t("site_saved")); this.setState(this.state); diff --git a/src/shared/components/home/home.tsx b/src/shared/components/home/home.tsx index 6a9365c..5ae3588 100644 --- a/src/shared/components/home/home.tsx +++ b/src/shared/components/home/home.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { Link } from "inferno-router"; @@ -23,38 +24,41 @@ import { SiteResponse, SortType, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { DataType, InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, commentsToFlatNodes, createCommentLikeRes, createPostLikeFindRes, editCommentRes, editPostFindRes, + enableDownvotes, + enableNsfw, fetchLimit, getDataTypeFromProps, getListingTypeFromProps, getPageFromProps, getSortTypeFromProps, + isBrowser, notifyPost, relTags, restoreScrollPosition, saveCommentRes, saveScrollPosition, setIsoData, - setOptionalAuth, setupTippy, showLocal, toast, + trendingFetchLimit, updatePersonBlock, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { CommentNodes } from "../comment/comment-nodes"; import { DataTypeSelect } from "../common/data-type-select"; @@ -70,17 +74,17 @@ import { SiteSidebar } from "./site-sidebar"; interface HomeState { trendingCommunities: CommunityView[]; siteRes: GetSiteResponse; - showSubscribedMobile: boolean; - showTrendingMobile: boolean; - showSidebarMobile: boolean; - subscribedCollapsed: boolean; - loading: boolean; posts: PostView[]; comments: CommentView[]; listingType: ListingType; dataType: DataType; sort: SortType; page: number; + showSubscribedMobile: boolean; + showTrendingMobile: boolean; + showSidebarMobile: boolean; + subscribedCollapsed: boolean; + loading: boolean; } interface HomeProps { @@ -98,7 +102,12 @@ interface UrlParams { } export class Home extends Component<any, HomeState> { - private isoData = setIsoData(this.context); + private isoData = setIsoData( + this.context, + GetPostsResponse, + GetCommentsResponse, + ListCommunitiesResponse + ); private subscription: Subscription; private emptyState: HomeState = { trendingCommunities: [], @@ -113,7 +122,7 @@ export class Home extends Component<any, HomeState> { listingType: getListingTypeFromProps( this.props, ListingType[ - this.isoData.site_res.site_view?.site.default_post_listing_type + this.isoData.site_res.site_view.unwrap().site.default_post_listing_type ] ), dataType: getDataTypeFromProps(this.props), @@ -135,12 +144,25 @@ export class Home extends Component<any, HomeState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - if (this.state.dataType == DataType.Post) { - this.state.posts = this.isoData.routeData[0].posts; - } else { - this.state.comments = this.isoData.routeData[0].comments; + let postsRes = Some(this.isoData.routeData[0] as GetPostsResponse); + let commentsRes = Some(this.isoData.routeData[1] as GetCommentsResponse); + let trendingRes = this.isoData.routeData[2] as ListCommunitiesResponse; + + postsRes.match({ + some: pvs => (this.state.posts = pvs.posts), + none: void 0, + }); + commentsRes.match({ + some: cvs => (this.state.comments = cvs.comments), + none: void 0, + }); + this.state.trendingCommunities = trendingRes.communities; + + if (isBrowser()) { + WebSocketService.Instance.send( + wsClient.communityJoin({ community_id: 0 }) + ); } - this.state.trendingCommunities = this.isoData.routeData[1].communities; this.state.loading = false; } else { this.fetchTrendingCommunities(); @@ -149,12 +171,13 @@ export class Home extends Component<any, HomeState> { } fetchTrendingCommunities() { - let listCommunitiesForm: ListCommunities = { - type_: ListingType.Local, - sort: SortType.Hot, - limit: 6, - auth: authField(false), - }; + let listCommunitiesForm = new ListCommunities({ + type_: Some(ListingType.Local), + sort: Some(SortType.Hot), + limit: Some(trendingFetchLimit), + page: None, + auth: auth(false).ok(), + }); WebSocketService.Instance.send( wsClient.listCommunities(listCommunitiesForm) ); @@ -165,8 +188,6 @@ export class Home extends Component<any, HomeState> { if (!this.state.siteRes.site_view) { this.context.router.history.push("/setup"); } - - WebSocketService.Instance.send(wsClient.communityJoin({ community_id: 0 })); setupTippy(); } @@ -192,58 +213,69 @@ export class Home extends Component<any, HomeState> { : DataType.Post; // TODO figure out auth default_listingType, default_sort_type - let type_: ListingType = pathSplit[5] - ? ListingType[pathSplit[5]] - : UserService.Instance.myUserInfo - ? Object.values(ListingType)[ - UserService.Instance.myUserInfo.local_user_view.local_user - .default_listing_type - ] - : null; - let sort: SortType = pathSplit[7] - ? SortType[pathSplit[7]] - : UserService.Instance.myUserInfo - ? Object.values(SortType)[ - UserService.Instance.myUserInfo.local_user_view.local_user - .default_sort_type - ] - : SortType.Active; - - let page = pathSplit[9] ? Number(pathSplit[9]) : 1; + let type_: Option<ListingType> = Some( + pathSplit[5] + ? ListingType[pathSplit[5]] + : UserService.Instance.myUserInfo.match({ + some: mui => + Object.values(ListingType)[ + mui.local_user_view.local_user.default_listing_type + ], + none: ListingType.Local, + }) + ); + let sort: Option<SortType> = Some( + pathSplit[7] + ? SortType[pathSplit[7]] + : UserService.Instance.myUserInfo.match({ + some: mui => + Object.values(SortType)[ + mui.local_user_view.local_user.default_sort_type + ], + none: SortType.Active, + }) + ); + + let page = Some(pathSplit[9] ? Number(pathSplit[9]) : 1); let promises: Promise<any>[] = []; if (dataType == DataType.Post) { - let getPostsForm: GetPosts = { + let getPostsForm = new GetPosts({ + community_id: None, + community_name: None, + type_, page, - limit: fetchLimit, + limit: Some(fetchLimit), sort, - saved_only: false, - }; - if (type_) { - getPostsForm.type_ = type_; - } + saved_only: Some(false), + auth: req.auth, + }); - setOptionalAuth(getPostsForm, req.auth); promises.push(req.client.getPosts(getPostsForm)); + promises.push(Promise.resolve()); } else { - let getCommentsForm: GetComments = { + let getCommentsForm = new GetComments({ + community_id: None, + community_name: None, page, - limit: fetchLimit, + limit: Some(fetchLimit), sort, - type_: type_ || ListingType.Local, - saved_only: false, - }; - setOptionalAuth(getCommentsForm, req.auth); + type_, + saved_only: Some(false), + auth: req.auth, + }); + promises.push(Promise.resolve()); promises.push(req.client.getComments(getCommentsForm)); } - let trendingCommunitiesForm: ListCommunities = { - type_: ListingType.Local, - sort: SortType.Hot, - limit: 6, - }; - setOptionalAuth(trendingCommunitiesForm, req.auth); + let trendingCommunitiesForm = new ListCommunities({ + type_: Some(ListingType.Local), + sort: Some(SortType.Hot), + limit: Some(trendingFetchLimit), + page: None, + auth: req.auth, + }); promises.push(req.client.listCommunities(trendingCommunitiesForm)); return promises; @@ -262,13 +294,14 @@ export class Home extends Component<any, HomeState> { } get documentTitle(): string { - return `${ - this.state.siteRes.site_view - ? this.state.siteRes.site_view.site.description - ? `${this.state.siteRes.site_view.site.name} - ${this.state.siteRes.site_view.site.description}` - : this.state.siteRes.site_view.site.name - : "Lemmy" - }`; + return this.state.siteRes.site_view.match({ + some: siteView => + siteView.site.description.match({ + some: desc => `${siteView.site.name} - ${desc}`, + none: siteView.site.name, + }), + none: "Lemmy", + }); } render() { @@ -277,8 +310,10 @@ export class Home extends Component<any, HomeState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> - {this.state.siteRes.site_view?.site && ( + {this.state.siteRes.site_view.isSome() && ( <div class="row"> <main role="main" class="col-12 col-md-8"> <div class="d-block d-md-none">{this.mobileView()}</div> @@ -291,28 +326,34 @@ export class Home extends Component<any, HomeState> { ); } + get hasFollows(): boolean { + return UserService.Instance.myUserInfo.match({ + some: mui => mui.follows.length > 0, + none: false, + }); + } + mobileView() { let siteRes = this.state.siteRes; return ( <div class="row"> <div class="col-12"> - {UserService.Instance.myUserInfo && - UserService.Instance.myUserInfo.follows.length > 0 && ( - <button - class="btn btn-secondary d-inline-block mb-2 mr-3" - onClick={linkEvent(this, this.handleShowSubscribedMobile)} - > - {i18n.t("subscribed")}{" "} - <Icon - icon={ - this.state.showSubscribedMobile - ? `minus-square` - : `plus-square` - } - classes="icon-inline" - /> - </button> - )} + {this.hasFollows && ( + <button + class="btn btn-secondary d-inline-block mb-2 mr-3" + onClick={linkEvent(this, this.handleShowSubscribedMobile)} + > + {i18n.t("subscribed")}{" "} + <Icon + icon={ + this.state.showSubscribedMobile + ? `minus-square` + : `plus-square` + } + classes="icon-inline" + /> + </button> + )} <button class="btn btn-secondary d-inline-block mb-2 mr-3" onClick={linkEvent(this, this.handleShowTrendingMobile)} @@ -337,15 +378,19 @@ export class Home extends Component<any, HomeState> { classes="icon-inline" /> </button> - {this.state.showSidebarMobile && ( - <SiteSidebar - site={siteRes.site_view.site} - admins={siteRes.admins} - counts={siteRes.site_view.counts} - online={siteRes.online} - showLocal={showLocal(this.isoData)} - /> - )} + {this.state.showSidebarMobile && + siteRes.site_view.match({ + some: siteView => ( + <SiteSidebar + site={siteView.site} + admins={Some(siteRes.admins)} + counts={Some(siteView.counts)} + online={Some(siteRes.online)} + showLocal={showLocal(this.isoData)} + /> + ), + none: <></>, + })} {this.state.showTrendingMobile && ( <div class="col-12 card border-secondary mb-3"> <div class="card-body">{this.trendingCommunities()}</div> @@ -374,21 +419,23 @@ export class Home extends Component<any, HomeState> { {this.exploreCommunitiesButton()} </div> </div> - - <SiteSidebar - site={siteRes.site_view.site} - admins={siteRes.admins} - counts={siteRes.site_view.counts} - online={siteRes.online} - showLocal={showLocal(this.isoData)} - /> - - {UserService.Instance.myUserInfo && - UserService.Instance.myUserInfo.follows.length > 0 && ( - <div class="card border-secondary mb-3"> - <div class="card-body">{this.subscribedCommunities()}</div> - </div> - )} + {siteRes.site_view.match({ + some: siteView => ( + <SiteSidebar + site={siteView.site} + admins={Some(siteRes.admins)} + counts={Some(siteView.counts)} + online={Some(siteRes.online)} + showLocal={showLocal(this.isoData)} + /> + ), + none: <></>, + })} + {this.hasFollows && ( + <div class="card border-secondary mb-3"> + <div class="card-body">{this.subscribedCommunities()}</div> + </div> + )} </div> )} </div> @@ -458,11 +505,14 @@ export class Home extends Component<any, HomeState> { </h5> {!this.state.subscribedCollapsed && ( <ul class="list-inline mb-0"> - {UserService.Instance.myUserInfo.follows.map(cfv => ( - <li class="list-inline-item d-inline-block"> - <CommunityLink community={cfv.community} /> - </li> - ))} + {UserService.Instance.myUserInfo + .map(m => m.follows) + .unwrapOr([]) + .map(cfv => ( + <li class="list-inline-item d-inline-block"> + <CommunityLink community={cfv.community} /> + </li> + ))} </ul> )} </div> @@ -501,22 +551,24 @@ export class Home extends Component<any, HomeState> { } listings() { - let site = this.state.siteRes.site_view.site; return this.state.dataType == DataType.Post ? ( <PostListings posts={this.state.posts} showCommunity removeDuplicates - enableDownvotes={site.enable_downvotes} - enableNsfw={site.enable_nsfw} + enableDownvotes={enableDownvotes(this.state.siteRes)} + enableNsfw={enableNsfw(this.state.siteRes)} /> ) : ( <CommentNodes nodes={commentsToFlatNodes(this.state.comments)} + moderators={None} + admins={None} + maxCommentsShown={None} noIndent showCommunity showContext - enableDownvotes={site.enable_downvotes} + enableDownvotes={enableDownvotes(this.state.siteRes)} /> ); } @@ -524,9 +576,9 @@ export class Home extends Component<any, HomeState> { selects() { let allRss = `/feeds/all.xml?sort=${this.state.sort}`; let localRss = `/feeds/local.xml?sort=${this.state.sort}`; - let frontRss = UserService.Instance.myUserInfo - ? `/feeds/front/${UserService.Instance.auth}.xml?sort=${this.state.sort}` - : ""; + let frontRss = auth(false) + .ok() + .map(auth => `/feeds/front/${auth}.xml?sort=${this.state.sort}`); return ( <div className="mb-3"> @@ -563,19 +615,18 @@ export class Home extends Component<any, HomeState> { <link rel="alternate" type="application/atom+xml" href={localRss} /> </> )} - {UserService.Instance.myUserInfo && - this.state.listingType == ListingType.Subscribed && ( - <> - <a href={frontRss} title="RSS" rel={relTags}> - <Icon icon="rss" classes="text-muted small" /> - </a> - <link - rel="alternate" - type="application/atom+xml" - href={frontRss} - /> - </> - )} + {this.state.listingType == ListingType.Subscribed && + frontRss.match({ + some: rss => ( + <> + <a href={rss} title="RSS" rel={relTags}> + <Icon icon="rss" classes="text-muted small" /> + </a> + <link rel="alternate" type="application/atom+xml" href={rss} /> + </> + ), + none: <></>, + })} </div> ); } @@ -622,27 +673,29 @@ export class Home extends Component<any, HomeState> { fetchData() { if (this.state.dataType == DataType.Post) { - let getPostsForm: GetPosts = { - page: this.state.page, - limit: fetchLimit, - sort: this.state.sort, - saved_only: false, - auth: authField(false), - }; - if (this.state.listingType) { - getPostsForm.type_ = this.state.listingType; - } + let getPostsForm = new GetPosts({ + community_id: None, + community_name: None, + page: Some(this.state.page), + limit: Some(fetchLimit), + sort: Some(this.state.sort), + saved_only: Some(false), + auth: auth(false).ok(), + type_: Some(this.state.listingType), + }); WebSocketService.Instance.send(wsClient.getPosts(getPostsForm)); } else { - let getCommentsForm: GetComments = { - page: this.state.page, - limit: fetchLimit, - sort: this.state.sort, - type_: this.state.listingType, - saved_only: false, - auth: authField(false), - }; + let getCommentsForm = new GetComments({ + community_id: None, + community_name: None, + page: Some(this.state.page), + limit: Some(fetchLimit), + sort: Some(this.state.sort), + saved_only: Some(false), + auth: auth(false).ok(), + type_: Some(this.state.listingType), + }); WebSocketService.Instance.send(wsClient.getComments(getCommentsForm)); } } @@ -659,46 +712,55 @@ export class Home extends Component<any, HomeState> { ); this.fetchData(); } else if (op == UserOperation.ListCommunities) { - let data = wsJsonToRes<ListCommunitiesResponse>(msg).data; + let data = wsJsonToRes<ListCommunitiesResponse>( + msg, + ListCommunitiesResponse + ); this.state.trendingCommunities = data.communities; this.setState(this.state); } else if (op == UserOperation.EditSite) { - let data = wsJsonToRes<SiteResponse>(msg).data; - this.state.siteRes.site_view = data.site_view; + let data = wsJsonToRes<SiteResponse>(msg, SiteResponse); + this.state.siteRes.site_view = Some(data.site_view); this.setState(this.state); toast(i18n.t("site_saved")); } else if (op == UserOperation.GetPosts) { - let data = wsJsonToRes<GetPostsResponse>(msg).data; + let data = wsJsonToRes<GetPostsResponse>(msg, GetPostsResponse); this.state.posts = data.posts; this.state.loading = false; this.setState(this.state); + WebSocketService.Instance.send( + wsClient.communityJoin({ community_id: 0 }) + ); restoreScrollPosition(this.context); setupTippy(); } else if (op == UserOperation.CreatePost) { - let data = wsJsonToRes<PostResponse>(msg).data; - + let data = wsJsonToRes<PostResponse>(msg, PostResponse); // NSFW check let nsfw = data.post_view.post.nsfw || data.post_view.community.nsfw; let nsfwCheck = !nsfw || (nsfw && - UserService.Instance.myUserInfo && - UserService.Instance.myUserInfo.local_user_view.local_user.show_nsfw); + UserService.Instance.myUserInfo + .map(m => m.local_user_view.local_user.show_nsfw) + .unwrapOr(false)); + + let showPostNotifs = UserService.Instance.myUserInfo + .map(m => m.local_user_view.local_user.show_new_post_notifs) + .unwrapOr(false); // Only push these if you're on the first page, and you pass the nsfw check if (this.state.page == 1 && nsfwCheck) { // If you're on subscribed, only push it if you're subscribed. if (this.state.listingType == ListingType.Subscribed) { if ( - UserService.Instance.myUserInfo.follows + UserService.Instance.myUserInfo + .map(m => m.follows) + .unwrapOr([]) .map(c => c.community.id) .includes(data.post_view.community.id) ) { this.state.posts.unshift(data.post_view); - if ( - UserService.Instance.myUserInfo?.local_user_view.local_user - .show_new_post_notifs - ) { + if (showPostNotifs) { notifyPost(data.post_view, this.context.router); } } @@ -706,19 +768,13 @@ export class Home extends Component<any, HomeState> { // If you're on the local view, only push it if its local if (data.post_view.post.local) { this.state.posts.unshift(data.post_view); - if ( - UserService.Instance.myUserInfo?.local_user_view.local_user - .show_new_post_notifs - ) { + if (showPostNotifs) { notifyPost(data.post_view, this.context.router); } } } else { this.state.posts.unshift(data.post_view); - if ( - UserService.Instance.myUserInfo?.local_user_view.local_user - .show_new_post_notifs - ) { + if (showPostNotifs) { notifyPost(data.post_view, this.context.router); } } @@ -732,26 +788,26 @@ export class Home extends Component<any, HomeState> { op == UserOperation.StickyPost || op == UserOperation.SavePost ) { - let data = wsJsonToRes<PostResponse>(msg).data; + let data = wsJsonToRes<PostResponse>(msg, PostResponse); editPostFindRes(data.post_view, this.state.posts); this.setState(this.state); } else if (op == UserOperation.CreatePostLike) { - let data = wsJsonToRes<PostResponse>(msg).data; + let data = wsJsonToRes<PostResponse>(msg, PostResponse); createPostLikeFindRes(data.post_view, this.state.posts); this.setState(this.state); } else if (op == UserOperation.AddAdmin) { - let data = wsJsonToRes<AddAdminResponse>(msg).data; + let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse); this.state.siteRes.admins = data.admins; this.setState(this.state); } else if (op == UserOperation.BanPerson) { - let data = wsJsonToRes<BanPersonResponse>(msg).data; + let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse); this.state.posts .filter(p => p.creator.id == data.person_view.person.id) .forEach(p => (p.creator.banned = data.banned)); this.setState(this.state); } else if (op == UserOperation.GetComments) { - let data = wsJsonToRes<GetCommentsResponse>(msg).data; + let data = wsJsonToRes<GetCommentsResponse>(msg, GetCommentsResponse); this.state.comments = data.comments; this.state.loading = false; this.setState(this.state); @@ -760,18 +816,20 @@ export class Home extends Component<any, HomeState> { op == UserOperation.DeleteComment || op == UserOperation.RemoveComment ) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); editCommentRes(data.comment_view, this.state.comments); this.setState(this.state); } else if (op == UserOperation.CreateComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); // Necessary since it might be a user reply if (data.form_id) { // If you're on subscribed, only push it if you're subscribed. if (this.state.listingType == ListingType.Subscribed) { if ( - UserService.Instance.myUserInfo.follows + UserService.Instance.myUserInfo + .map(m => m.follows) + .unwrapOr([]) .map(c => c.community.id) .includes(data.comment_view.community.id) ) { @@ -783,23 +841,23 @@ export class Home extends Component<any, HomeState> { this.setState(this.state); } } else if (op == UserOperation.SaveComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); saveCommentRes(data.comment_view, this.state.comments); this.setState(this.state); } else if (op == UserOperation.CreateCommentLike) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); createCommentLikeRes(data.comment_view, this.state.comments); this.setState(this.state); } else if (op == UserOperation.BlockPerson) { - let data = wsJsonToRes<BlockPersonResponse>(msg).data; + let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse); updatePersonBlock(data); } else if (op == UserOperation.CreatePostReport) { - let data = wsJsonToRes<PostReportResponse>(msg).data; + let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse); if (data) { toast(i18n.t("report_created")); } } else if (op == UserOperation.CreateCommentReport) { - let data = wsJsonToRes<CommentReportResponse>(msg).data; + let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse); if (data) { toast(i18n.t("report_created")); } diff --git a/src/shared/components/home/instances.tsx b/src/shared/components/home/instances.tsx index c713383..fe4064d 100644 --- a/src/shared/components/home/instances.tsx +++ b/src/shared/components/home/instances.tsx @@ -1,3 +1,4 @@ +import { None } from "@sniptt/monads"; import { Component } from "inferno"; import { GetSiteResponse } from "lemmy-js-client"; import { i18n } from "../../i18next"; @@ -20,42 +21,56 @@ export class Instances extends Component<any, InstancesState> { } get documentTitle(): string { - return `${i18n.t("instances")} - ${this.state.siteRes.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `${i18n.t("instances")} - ${siteView.site.name}`, + none: "", + }); } render() { - let federated_instances = this.state.siteRes?.federated_instances; - return ( - federated_instances && ( + return this.state.siteRes.federated_instances.match({ + some: federated_instances => ( <div class="container"> <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <div class="row"> <div class="col-md-6"> <h5>{i18n.t("linked_instances")}</h5> {this.itemList(federated_instances.linked)} </div> - {federated_instances.allowed?.length > 0 && ( - <div class="col-md-6"> - <h5>{i18n.t("allowed_instances")}</h5> - {this.itemList(federated_instances.allowed)} - </div> - )} - {federated_instances.blocked?.length > 0 && ( - <div class="col-md-6"> - <h5>{i18n.t("blocked_instances")}</h5> - {this.itemList(federated_instances.blocked)} - </div> - )} + {federated_instances.allowed.match({ + some: allowed => + allowed.length > 0 && ( + <div class="col-md-6"> + <h5>{i18n.t("allowed_instances")}</h5> + {this.itemList(allowed)} + </div> + ), + none: <></>, + })} + {federated_instances.blocked.match({ + some: blocked => + blocked.length > 0 && ( + <div class="col-md-6"> + <h5>{i18n.t("blocked_instances")}</h5> + {this.itemList(blocked)} + </div> + ), + none: <></>, + })} </div> </div> - ) - ); + ), + none: <></>, + }); } itemList(items: string[]) { + let noneFound = <div>{i18n.t("none_found")}</div>; return items.length > 0 ? ( <ul> {items.map(i => ( @@ -67,7 +82,7 @@ export class Instances extends Component<any, InstancesState> { ))} </ul> ) : ( - <div>{i18n.t("none_found")}</div> + noneFound ); } } diff --git a/src/shared/components/home/legal.tsx b/src/shared/components/home/legal.tsx index b2904c8..590d7d8 100644 --- a/src/shared/components/home/legal.tsx +++ b/src/shared/components/home/legal.tsx @@ -1,3 +1,4 @@ +import { None } from "@sniptt/monads"; import { Component } from "inferno"; import { GetSiteResponse } from "lemmy-js-client"; import { i18n } from "../../i18next"; @@ -29,13 +30,22 @@ export class Legal extends Component<any, LegalState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> - <div - className="md-div" - dangerouslySetInnerHTML={mdToHtml( - this.state.siteRes.site_view.site.legal_information - )} - /> + {this.state.siteRes.site_view.match({ + some: siteView => + siteView.site.legal_information.match({ + some: legal => ( + <div + className="md-div" + dangerouslySetInnerHTML={mdToHtml(legal)} + /> + ), + none: <></>, + }), + none: <></>, + })} </div> ); } diff --git a/src/shared/components/home/login.tsx b/src/shared/components/home/login.tsx index 5a61d13..7383a56 100644 --- a/src/shared/components/home/login.tsx +++ b/src/shared/components/home/login.tsx @@ -1,25 +1,25 @@ +import { None } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { GetSiteResponse, Login as LoginForm, LoginResponse, PasswordReset, - SiteView, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, isBrowser, setIsoData, toast, validEmail, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; @@ -27,7 +27,7 @@ import { Spinner } from "../common/icon"; interface State { loginForm: LoginForm; loginLoading: boolean; - site_view: SiteView; + siteRes: GetSiteResponse; } export class Login extends Component<any, State> { @@ -35,12 +35,12 @@ export class Login extends Component<any, State> { private subscription: Subscription; emptyState: State = { - loginForm: { + loginForm: new LoginForm({ username_or_email: undefined, password: undefined, - }, + }), loginLoading: false, - site_view: this.isoData.site_res.site_view, + siteRes: this.isoData.site_res, }; constructor(props: any, context: any) { @@ -58,7 +58,7 @@ export class Login extends Component<any, State> { componentDidMount() { // Navigate to home if already logged in - if (UserService.Instance.myUserInfo) { + if (UserService.Instance.myUserInfo.isSome()) { this.context.router.history.push("/"); } } @@ -70,7 +70,10 @@ export class Login extends Component<any, State> { } get documentTitle(): string { - return `${i18n.t("login")} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `${i18n.t("login")} - ${siteView.site.name}`, + none: "", + }); } get isLemmyMl(): boolean { @@ -83,6 +86,8 @@ export class Login extends Component<any, State> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <div class="row"> <div class="col-12 col-lg-6 offset-lg-3">{this.loginForm()}</div> @@ -173,9 +178,9 @@ export class Login extends Component<any, State> { handlePasswordReset(i: Login, event: any) { event.preventDefault(); - let resetForm: PasswordReset = { + let resetForm = new PasswordReset({ email: i.state.loginForm.username_or_email, - }; + }); WebSocketService.Instance.send(wsClient.passwordReset(resetForm)); } @@ -189,13 +194,13 @@ export class Login extends Component<any, State> { return; } else { if (op == UserOperation.Login) { - let data = wsJsonToRes<LoginResponse>(msg).data; + let data = wsJsonToRes<LoginResponse>(msg, LoginResponse); this.state = this.emptyState; this.setState(this.state); UserService.Instance.login(data); WebSocketService.Instance.send( wsClient.userJoin({ - auth: authField(), + auth: auth().unwrap(), }) ); toast(i18n.t("logged_in")); @@ -203,8 +208,8 @@ export class Login extends Component<any, State> { } else if (op == UserOperation.PasswordReset) { toast(i18n.t("reset_password_mail_sent")); } else if (op == UserOperation.GetSite) { - let data = wsJsonToRes<GetSiteResponse>(msg).data; - this.state.site_view = data.site_view; + let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse); + this.state.siteRes = data; this.setState(this.state); } } diff --git a/src/shared/components/home/setup.tsx b/src/shared/components/home/setup.tsx index 30c6e35..f524f4c 100644 --- a/src/shared/components/home/setup.tsx +++ b/src/shared/components/home/setup.tsx @@ -1,11 +1,19 @@ +import { None, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { Helmet } from "inferno-helmet"; -import { LoginResponse, Register, UserOperation } from "lemmy-js-client"; +import { + LoginResponse, + Register, + toUndefined, + UserOperation, + wsJsonToRes, + wsUserOp, +} from "lemmy-js-client"; import { Subscription } from "rxjs"; import { delay, retryWhen, take } from "rxjs/operators"; import { i18n } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; -import { toast, wsClient, wsJsonToRes, wsUserOp } from "../../utils"; +import { toast, wsClient } from "../../utils"; import { Spinner } from "../common/icon"; import { SiteForm } from "./site-form"; @@ -19,15 +27,18 @@ export class Setup extends Component<any, State> { private subscription: Subscription; private emptyState: State = { - userForm: { + userForm: new Register({ username: undefined, password: undefined, password_verify: undefined, show_nsfw: true, // The first admin signup doesn't need a captcha - captcha_uuid: "", - captcha_answer: "", - }, + captcha_uuid: None, + captcha_answer: None, + email: None, + honeypot: None, + answer: None, + }), doneRegisteringUser: false, userLoading: false, }; @@ -64,7 +75,7 @@ export class Setup extends Component<any, State> { {!this.state.doneRegisteringUser ? ( this.registerUser() ) : ( - <SiteForm showLocal /> + <SiteForm site={None} showLocal /> )} </div> </div> @@ -104,7 +115,7 @@ export class Setup extends Component<any, State> { id="email" class="form-control" placeholder={i18n.t("optional")} - value={this.state.userForm.email} + value={toUndefined(this.state.userForm.email)} onInput={linkEvent(this, this.handleRegisterEmailChange)} minLength={3} /> @@ -171,7 +182,7 @@ export class Setup extends Component<any, State> { } handleRegisterEmailChange(i: Setup, event: any) { - i.state.userForm.email = event.target.value; + i.state.userForm.email = Some(event.target.value); i.setState(i.state); } @@ -193,7 +204,7 @@ export class Setup extends Component<any, State> { this.setState(this.state); return; } else if (op == UserOperation.Register) { - let data = wsJsonToRes<LoginResponse>(msg).data; + let data = wsJsonToRes<LoginResponse>(msg, LoginResponse); this.state.userLoading = false; this.state.doneRegisteringUser = true; UserService.Instance.login(data); diff --git a/src/shared/components/home/signup.tsx b/src/shared/components/home/signup.tsx index 14b6aa9..31d1e91 100644 --- a/src/shared/components/home/signup.tsx +++ b/src/shared/components/home/signup.tsx @@ -1,20 +1,25 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Options, passwordStrength } from "check-password-strength"; import { I18nKeys } from "i18next"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { + CaptchaResponse, GetCaptchaResponse, GetSiteResponse, LoginResponse, Register, SiteView, + toUndefined, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, isBrowser, joinLemmyUrl, mdToHtml, @@ -22,9 +27,7 @@ import { toast, validEmail, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Icon, Spinner } from "../common/icon"; @@ -60,9 +63,9 @@ const passwordStrengthOptions: Options<string> = [ interface State { registerForm: Register; registerLoading: boolean; - captcha: GetCaptchaResponse; + captcha: Option<GetCaptchaResponse>; captchaPlaying: boolean; - site_view: SiteView; + siteRes: GetSiteResponse; } export class Signup extends Component<any, State> { @@ -71,20 +74,21 @@ export class Signup extends Component<any, State> { private audio: HTMLAudioElement; emptyState: State = { - registerForm: { + registerForm: new Register({ username: undefined, password: undefined, password_verify: undefined, show_nsfw: false, - captcha_uuid: undefined, - captcha_answer: undefined, - honeypot: undefined, - answer: undefined, - }, + captcha_uuid: None, + captcha_answer: None, + honeypot: None, + answer: None, + email: None, + }), registerLoading: false, - captcha: undefined, + captcha: None, captchaPlaying: false, - site_view: this.isoData.site_res.site_view, + siteRes: this.isoData.site_res, }; constructor(props: any, context: any) { @@ -108,13 +112,14 @@ export class Signup extends Component<any, State> { } get documentTitle(): string { - return `${this.titleName} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `${this.titleName(siteView)} - ${siteView.site.name}`, + none: "", + }); } - get titleName(): string { - return `${i18n.t( - this.state.site_view.site.private_instance ? "apply_to_join" : "sign_up" - )}`; + titleName(siteView: SiteView): string { + return i18n.t(siteView.site.private_instance ? "apply_to_join" : "sign_up"); } get isLemmyMl(): boolean { @@ -127,6 +132,8 @@ export class Signup extends Component<any, State> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <div class="row"> <div class="col-12 col-lg-6 offset-lg-3">{this.registerForm()}</div> @@ -136,244 +143,272 @@ export class Signup extends Component<any, State> { } registerForm() { - return ( - <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}> - <h5>{this.titleName}</h5> + return this.state.siteRes.site_view.match({ + some: siteView => ( + <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}> + <h5>{this.titleName(siteView)}</h5> - {this.isLemmyMl && ( - <div class="form-group row"> - <div class="mt-2 mb-0 alert alert-warning" role="alert"> - <T i18nKey="lemmy_ml_registration_message"> - #<a href={joinLemmyUrl}>#</a> - </T> + {this.isLemmyMl && ( + <div class="form-group row"> + <div class="mt-2 mb-0 alert alert-warning" role="alert"> + <T i18nKey="lemmy_ml_registration_message"> + #<a href={joinLemmyUrl}>#</a> + </T> + </div> </div> - </div> - )} - - <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} - pattern="[a-zA-Z0-9_]+" - title={i18n.t("community_reqs")} - /> - </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={ - this.state.site_view.site.require_email_verification - ? i18n.t("required") - : i18n.t("optional") - } - value={this.state.registerForm.email} - autoComplete="email" - onInput={linkEvent(this, this.handleRegisterEmailChange)} - required={this.state.site_view.site.require_email_verification} - minLength={3} - /> - {!this.state.site_view.site.require_email_verification && - !validEmail(this.state.registerForm.email) && ( - <div class="mt-2 mb-0 alert alert-warning" role="alert"> - <Icon icon="alert-triangle" classes="icon-inline mr-2" /> - {i18n.t("no_password_reset")} - </div> - )} - </div> - </div> + <div class="form-group row"> + <label class="col-sm-2 col-form-label" htmlFor="register-username"> + {i18n.t("username")} + </label> - <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)} - minLength={10} - maxLength={60} - class="form-control" - required - /> - {this.state.registerForm.password && ( - <div class={this.passwordColorClass}> - {i18n.t(this.passwordStrength as I18nKeys)} - </div> - )} + <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} + pattern="[a-zA-Z0-9_]+" + title={i18n.t("community_reqs")} + /> + </div> </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)} - maxLength={60} - class="form-control" - required - /> + <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={ + siteView.site.require_email_verification + ? i18n.t("required") + : i18n.t("optional") + } + value={toUndefined(this.state.registerForm.email)} + autoComplete="email" + onInput={linkEvent(this, this.handleRegisterEmailChange)} + required={siteView.site.require_email_verification} + minLength={3} + /> + {!siteView.site.require_email_verification && + !this.state.registerForm.email + .map(validEmail) + .unwrapOr(true) && ( + <div class="mt-2 mb-0 alert alert-warning" role="alert"> + <Icon icon="alert-triangle" classes="icon-inline mr-2" /> + {i18n.t("no_password_reset")} + </div> + )} + </div> </div> - </div> - {this.state.site_view.site.require_application && ( - <> - <div class="form-group row"> - <div class="offset-sm-2 col-sm-10"> - <div class="mt-2 alert alert-warning" role="alert"> - <Icon icon="alert-triangle" classes="icon-inline mr-2" /> - {i18n.t("fill_out_application")} + <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)} + minLength={10} + maxLength={60} + class="form-control" + required + /> + {this.state.registerForm.password && ( + <div class={this.passwordColorClass}> + {i18n.t(this.passwordStrength as I18nKeys)} </div> - <div - className="md-div" - dangerouslySetInnerHTML={mdToHtml( - this.state.site_view.site.application_question || "" - )} - /> - </div> - </div> - - <div class="form-group row"> - <label - class="col-sm-2 col-form-label" - htmlFor="application_answer" - > - {i18n.t("answer")} - </label> - <div class="col-sm-10"> - <MarkdownTextArea - onContentChange={this.handleAnswerChange} - hideNavigationWarnings - /> - </div> + )} </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)} - aria-label={i18n.t("captcha")} - > - <Icon icon="refresh-cw" classes="icon-refresh-cw" /> - </button> + <label + class="col-sm-2 col-form-label" + htmlFor="register-verify-password" + > + {i18n.t("verify_password")} </label> - {this.showCaptcha()} - <div class="col-sm-6"> + <div class="col-sm-10"> <input - type="text" - class="form-control" - id="register-captcha" - value={this.state.registerForm.captcha_answer} + type="password" + id="register-verify-password" + value={this.state.registerForm.password_verify} + autoComplete="new-password" onInput={linkEvent( this, - this.handleRegisterCaptchaAnswerChange + this.handleRegisterPasswordVerifyChange )} + maxLength={60} + class="form-control" required /> </div> </div> - )} - {this.state.site_view.site.enable_nsfw && ( - <div class="form-group row"> - <div class="col-sm-10"> - <div class="form-check"> + + {siteView.site.require_application && ( + <> + <div class="form-group row"> + <div class="offset-sm-2 col-sm-10"> + <div class="mt-2 alert alert-warning" role="alert"> + <Icon icon="alert-triangle" classes="icon-inline mr-2" /> + {i18n.t("fill_out_application")} + </div> + {siteView.site.application_question.match({ + some: question => ( + <div + className="md-div" + dangerouslySetInnerHTML={mdToHtml(question)} + /> + ), + none: <></>, + })} + </div> + </div> + + <div class="form-group row"> + <label + class="col-sm-2 col-form-label" + htmlFor="application_answer" + > + {i18n.t("answer")} + </label> + <div class="col-sm-10"> + <MarkdownTextArea + initialContent={None} + placeholder={None} + buttonTitle={None} + maxLength={None} + onContentChange={this.handleAnswerChange} + hideNavigationWarnings + /> + </div> + </div> + </> + )} + + {this.state.captcha.isSome() && ( + <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)} + aria-label={i18n.t("captcha")} + > + <Icon icon="refresh-cw" classes="icon-refresh-cw" /> + </button> + </label> + {this.showCaptcha()} + <div class="col-sm-6"> <input - class="form-check-input" - id="register-show-nsfw" - type="checkbox" - checked={this.state.registerForm.show_nsfw} - onChange={linkEvent(this, this.handleRegisterShowNsfwChange)} + type="text" + class="form-control" + id="register-captcha" + value={toUndefined(this.state.registerForm.captcha_answer)} + onInput={linkEvent( + this, + this.handleRegisterCaptchaAnswerChange + )} + required /> - <label class="form-check-label" htmlFor="register-show-nsfw"> - {i18n.t("show_nsfw")} - </label> </div> </div> + )} + {siteView.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> + )} + <input + tabIndex={-1} + autoComplete="false" + name="a_password" + type="text" + class="form-control honeypot" + id="register-honey" + value={toUndefined(this.state.registerForm.honeypot)} + onInput={linkEvent(this, this.handleHoneyPotChange)} + /> + <div class="form-group row"> + <div class="col-sm-10"> + <button type="submit" class="btn btn-secondary"> + {this.state.registerLoading ? ( + <Spinner /> + ) : ( + this.titleName(siteView) + )} + </button> + </div> </div> - )} - <input - tabIndex={-1} - autoComplete="false" - name="a_password" - type="text" - class="form-control honeypot" - id="register-honey" - value={this.state.registerForm.honeypot} - onInput={linkEvent(this, this.handleHoneyPotChange)} - /> - <div class="form-group row"> - <div class="col-sm-10"> - <button type="submit" class="btn btn-secondary"> - {this.state.registerLoading ? <Spinner /> : this.titleName} - </button> - </div> - </div> - </form> - ); + </form> + ), + none: <></>, + }); } 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;" - alt={i18n.t("captcha")} - /> - {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} - > - <Icon icon="play" classes="icon-play" /> - </button> - )} - </> - )} - </div> - ); + return this.state.captcha.match({ + some: captcha => ( + <div class="col-sm-4"> + {captcha.ok.match({ + some: res => ( + <> + <img + class="rounded-top img-fluid" + src={this.captchaPngSrc(res)} + style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;" + alt={i18n.t("captcha")} + /> + {res.wav.isSome() && ( + <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} + > + <Icon icon="play" classes="icon-play" /> + </button> + )} + </> + ), + none: <></>, + })} + </div> + ), + none: <></>, + }); } get passwordStrength() { @@ -408,9 +443,9 @@ export class Signup extends Component<any, State> { } handleRegisterEmailChange(i: Signup, event: any) { - i.state.registerForm.email = event.target.value; - if (i.state.registerForm.email == "") { - i.state.registerForm.email = undefined; + i.state.registerForm.email = Some(event.target.value); + if (i.state.registerForm.email.unwrap() == "") { + i.state.registerForm.email = None; } i.setState(i.state); } @@ -431,17 +466,17 @@ export class Signup extends Component<any, State> { } handleRegisterCaptchaAnswerChange(i: Signup, event: any) { - i.state.registerForm.captcha_answer = event.target.value; + i.state.registerForm.captcha_answer = Some(event.target.value); i.setState(i.state); } handleAnswerChange(val: string) { - this.state.registerForm.answer = val; + this.state.registerForm.answer = Some(val); this.setState(this.state); } handleHoneyPotChange(i: Signup, event: any) { - i.state.registerForm.honeypot = event.target.value; + i.state.registerForm.honeypot = Some(event.target.value); i.setState(i.state); } @@ -455,25 +490,34 @@ export class Signup extends Component<any, State> { handleCaptchaPlay(i: Signup) { // This was a bad bug, it should only build the new audio on a new file. // Replays would stop prematurely if this was rebuilt every time. - if (i.audio == null) { - let base64 = `data:audio/wav;base64,${i.state.captcha.ok.wav}`; - i.audio = new Audio(base64); - } - - i.audio.play(); - - i.state.captchaPlaying = true; - i.setState(i.state); - - i.audio.addEventListener("ended", () => { - i.audio.currentTime = 0; - i.state.captchaPlaying = false; - i.setState(i.state); + i.state.captcha.match({ + some: captcha => + captcha.ok.match({ + some: res => { + if (i.audio == null) { + let base64 = `data:audio/wav;base64,${res.wav}`; + i.audio = new Audio(base64); + } + + i.audio.play(); + + i.state.captchaPlaying = true; + i.setState(i.state); + + i.audio.addEventListener("ended", () => { + i.audio.currentTime = 0; + i.state.captchaPlaying = false; + i.setState(i.state); + }); + }, + none: void 0, + }), + none: void 0, }); } - captchaPngSrc() { - return `data:image/png;base64,${this.state.captcha.ok.png}`; + captchaPngSrc(captcha: CaptchaResponse) { + return `data:image/png;base64,${captcha.png}`; } parseMessage(msg: any) { @@ -489,7 +533,7 @@ export class Signup extends Component<any, State> { return; } else { if (op == UserOperation.Register) { - let data = wsJsonToRes<LoginResponse>(msg).data; + let data = wsJsonToRes<LoginResponse>(msg, LoginResponse); this.state = this.emptyState; this.setState(this.state); // Only log them in if a jwt was set @@ -497,7 +541,7 @@ export class Signup extends Component<any, State> { UserService.Instance.login(data); WebSocketService.Instance.send( wsClient.userJoin({ - auth: authField(), + auth: auth().unwrap(), }) ); this.props.history.push("/communities"); @@ -511,17 +555,20 @@ export class Signup extends Component<any, State> { this.props.history.push("/"); } } else if (op == UserOperation.GetCaptcha) { - let data = wsJsonToRes<GetCaptchaResponse>(msg).data; - if (data.ok) { - this.state.captcha = data; - this.state.registerForm.captcha_uuid = data.ok.uuid; - this.setState(this.state); - } + let data = wsJsonToRes<GetCaptchaResponse>(msg, GetCaptchaResponse); + data.ok.match({ + some: res => { + this.state.captcha = Some(data); + this.state.registerForm.captcha_uuid = Some(res.uuid); + this.setState(this.state); + }, + none: void 0, + }); } else if (op == UserOperation.PasswordReset) { toast(i18n.t("reset_password_mail_sent")); } else if (op == UserOperation.GetSite) { - let data = wsJsonToRes<GetSiteResponse>(msg).data; - this.state.site_view = data.site_view; + let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse); + this.state.siteRes = data; this.setState(this.state); } } diff --git a/src/shared/components/home/site-form.tsx b/src/shared/components/home/site-form.tsx index 7016e6f..c653371 100644 --- a/src/shared/components/home/site-form.tsx +++ b/src/shared/components/home/site-form.tsx @@ -1,10 +1,17 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { Prompt } from "inferno-router"; -import { CreateSite, EditSite, ListingType, Site } from "lemmy-js-client"; +import { + CreateSite, + EditSite, + ListingType, + Site, + toUndefined, +} from "lemmy-js-client"; import { i18n } from "../../i18next"; import { WebSocketService } from "../../services"; import { - authField, + auth, capitalizeFirstLetter, fetchThemeList, wsClient, @@ -15,38 +22,41 @@ import { ListingTypeSelect } from "../common/listing-type-select"; import { MarkdownTextArea } from "../common/markdown-textarea"; interface SiteFormProps { - site?: Site; // If a site is given, that means this is an edit - showLocal: boolean; - onCancel?(): any; - onEdit?(): any; + site: Option<Site>; // If a site is given, that means this is an edit + showLocal?: boolean; + onCancel?(): void; + onEdit?(): void; } interface SiteFormState { siteForm: EditSite; loading: boolean; - themeList: string[]; + themeList: Option<string[]>; } export class SiteForm extends Component<SiteFormProps, SiteFormState> { private emptyState: SiteFormState = { - siteForm: { - enable_downvotes: true, - open_registration: true, - enable_nsfw: true, - name: null, - icon: null, - banner: null, - require_email_verification: null, - require_application: null, - application_question: null, - private_instance: null, - default_theme: null, - default_post_listing_type: null, - legal_information: null, - auth: authField(false), - }, + siteForm: new EditSite({ + enable_downvotes: Some(true), + open_registration: Some(true), + enable_nsfw: Some(true), + name: None, + icon: None, + banner: None, + require_email_verification: None, + require_application: None, + application_question: None, + private_instance: None, + default_theme: None, + sidebar: None, + default_post_listing_type: None, + legal_information: None, + description: None, + community_creation_admin_only: None, + auth: undefined, + }), loading: false, - themeList: [], + themeList: None, }; constructor(props: any, context: any) { @@ -67,32 +77,36 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { this.handleDefaultPostListingTypeChange = this.handleDefaultPostListingTypeChange.bind(this); - if (this.props.site) { - let site = this.props.site; - this.state.siteForm = { - name: site.name, - sidebar: site.sidebar, - description: site.description, - enable_downvotes: site.enable_downvotes, - open_registration: site.open_registration, - enable_nsfw: site.enable_nsfw, - community_creation_admin_only: site.community_creation_admin_only, - icon: site.icon, - banner: site.banner, - require_email_verification: site.require_email_verification, - require_application: site.require_application, - application_question: site.application_question, - private_instance: site.private_instance, - default_theme: site.default_theme, - default_post_listing_type: site.default_post_listing_type, - legal_information: site.legal_information, - auth: authField(false), - }; - } + this.props.site.match({ + some: site => { + this.state.siteForm = new EditSite({ + name: Some(site.name), + sidebar: site.sidebar, + description: site.description, + enable_downvotes: Some(site.enable_downvotes), + open_registration: Some(site.open_registration), + enable_nsfw: Some(site.enable_nsfw), + community_creation_admin_only: Some( + site.community_creation_admin_only + ), + icon: site.icon, + banner: site.banner, + require_email_verification: Some(site.require_email_verification), + require_application: Some(site.require_application), + application_question: site.application_question, + private_instance: Some(site.private_instance), + default_theme: Some(site.default_theme), + default_post_listing_type: Some(site.default_post_listing_type), + legal_information: site.legal_information, + auth: auth(false).unwrap(), + }); + }, + none: void 0, + }); } async componentDidMount() { - this.state.themeList = await fetchThemeList(); + this.state.themeList = Some(await fetchThemeList()); this.setState(this.state); } @@ -105,7 +119,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { componentDidUpdate() { if ( !this.state.loading && - !this.props.site && + this.props.site.isNone() && (this.state.siteForm.name || this.state.siteForm.sidebar || this.state.siteForm.application_question || @@ -127,7 +141,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { <Prompt when={ !this.state.loading && - !this.props.site && + this.props.site.isNone() && (this.state.siteForm.name || this.state.siteForm.sidebar || this.state.siteForm.application_question || @@ -137,7 +151,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { /> <form onSubmit={linkEvent(this, this.handleCreateSiteSubmit)}> <h5>{`${ - this.props.site + this.props.site.isSome() ? capitalizeFirstLetter(i18n.t("save")) : capitalizeFirstLetter(i18n.t("name")) } ${i18n.t("your_site")}`}</h5> @@ -150,7 +164,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { type="text" id="create-site-name" class="form-control" - value={this.state.siteForm.name} + value={toUndefined(this.state.siteForm.name)} onInput={linkEvent(this, this.handleSiteNameChange)} required minLength={3} @@ -186,7 +200,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { type="text" class="form-control" id="site-desc" - value={this.state.siteForm.description} + value={toUndefined(this.state.siteForm.description)} onInput={linkEvent(this, this.handleSiteDescChange)} maxLength={150} /> @@ -197,6 +211,9 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { <div class="col-12"> <MarkdownTextArea initialContent={this.state.siteForm.sidebar} + placeholder={None} + buttonTitle={None} + maxLength={None} onContentChange={this.handleSiteSidebarChange} hideNavigationWarnings /> @@ -209,12 +226,15 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { <div class="col-12"> <MarkdownTextArea initialContent={this.state.siteForm.legal_information} + placeholder={None} + buttonTitle={None} + maxLength={None} onContentChange={this.handleSiteLegalInfoChange} hideNavigationWarnings /> </div> </div> - {this.state.siteForm.require_application && ( + {this.state.siteForm.require_application.unwrapOr(false) && ( <div class="form-group row"> <label class="col-12 col-form-label"> {i18n.t("application_questionnaire")} @@ -222,6 +242,9 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { <div class="col-12"> <MarkdownTextArea initialContent={this.state.siteForm.application_question} + placeholder={None} + buttonTitle={None} + maxLength={None} onContentChange={this.handleSiteApplicationQuestionChange} hideNavigationWarnings /> @@ -235,7 +258,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { class="form-check-input" id="create-site-downvotes" type="checkbox" - checked={this.state.siteForm.enable_downvotes} + checked={toUndefined(this.state.siteForm.enable_downvotes)} onChange={linkEvent( this, this.handleSiteEnableDownvotesChange @@ -254,7 +277,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { class="form-check-input" id="create-site-enable-nsfw" type="checkbox" - checked={this.state.siteForm.enable_nsfw} + checked={toUndefined(this.state.siteForm.enable_nsfw)} onChange={linkEvent(this, this.handleSiteEnableNsfwChange)} /> <label @@ -273,7 +296,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { class="form-check-input" id="create-site-open-registration" type="checkbox" - checked={this.state.siteForm.open_registration} + checked={toUndefined(this.state.siteForm.open_registration)} onChange={linkEvent( this, this.handleSiteOpenRegistrationChange @@ -295,7 +318,9 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { class="form-check-input" id="create-site-community-creation-admin-only" type="checkbox" - checked={this.state.siteForm.community_creation_admin_only} + checked={toUndefined( + this.state.siteForm.community_creation_admin_only + )} onChange={linkEvent( this, this.handleSiteCommunityCreationAdminOnly @@ -317,7 +342,9 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { class="form-check-input" id="create-site-require-email-verification" type="checkbox" - checked={this.state.siteForm.require_email_verification} + checked={toUndefined( + this.state.siteForm.require_email_verification + )} onChange={linkEvent( this, this.handleSiteRequireEmailVerification @@ -339,7 +366,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { class="form-check-input" id="create-site-require-application" type="checkbox" - checked={this.state.siteForm.require_application} + checked={toUndefined(this.state.siteForm.require_application)} onChange={linkEvent(this, this.handleSiteRequireApplication)} /> <label @@ -361,12 +388,12 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { </label> <select id="create-site-default-theme" - value={this.state.siteForm.default_theme} + value={toUndefined(this.state.siteForm.default_theme)} onChange={linkEvent(this, this.handleSiteDefaultTheme)} class="custom-select w-auto" > <option value="browser">{i18n.t("browser_default")}</option> - {this.state.themeList.map(theme => ( + {this.state.themeList.unwrapOr([]).map(theme => ( <option value={theme}>{theme}</option> ))} </select> @@ -378,7 +405,11 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { <div class="col-sm-9"> <ListingTypeSelect type_={ - ListingType[this.state.siteForm.default_post_listing_type] + ListingType[ + this.state.siteForm.default_post_listing_type.unwrapOr( + "Local" + ) + ] } showLocal showSubscribed={false} @@ -394,7 +425,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { class="form-check-input" id="create-site-private-instance" type="checkbox" - value={this.state.siteForm.default_theme} + value={toUndefined(this.state.siteForm.default_theme)} onChange={linkEvent(this, this.handleSitePrivateInstance)} /> <label @@ -415,13 +446,13 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { > {this.state.loading ? ( <Spinner /> - ) : this.props.site ? ( + ) : this.props.site.isSome() ? ( capitalizeFirstLetter(i18n.t("save")) ) : ( capitalizeFirstLetter(i18n.t("create")) )} </button> - {this.props.site && ( + {this.props.site.isSome() && ( <button type="button" class="btn btn-secondary" @@ -440,81 +471,98 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { handleCreateSiteSubmit(i: SiteForm, event: any) { event.preventDefault(); i.state.loading = true; - if (i.props.site) { + i.state.siteForm.auth = auth().unwrap(); + + if (i.props.site.isSome()) { WebSocketService.Instance.send(wsClient.editSite(i.state.siteForm)); i.props.onEdit(); } else { - let form: CreateSite = { - name: i.state.siteForm.name || "My site", - ...i.state.siteForm, - }; + let sForm = i.state.siteForm; + let form = new CreateSite({ + name: sForm.name.unwrapOr("My site"), + sidebar: sForm.sidebar, + description: sForm.description, + icon: sForm.icon, + banner: sForm.banner, + community_creation_admin_only: sForm.community_creation_admin_only, + enable_nsfw: sForm.enable_nsfw, + enable_downvotes: sForm.enable_downvotes, + require_application: sForm.require_application, + application_question: sForm.application_question, + open_registration: sForm.open_registration, + require_email_verification: sForm.require_email_verification, + private_instance: sForm.private_instance, + default_theme: sForm.default_theme, + default_post_listing_type: sForm.default_post_listing_type, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.createSite(form)); } i.setState(i.state); } handleSiteNameChange(i: SiteForm, event: any) { - i.state.siteForm.name = event.target.value; + i.state.siteForm.name = Some(event.target.value); i.setState(i.state); } handleSiteSidebarChange(val: string) { - this.state.siteForm.sidebar = val; + this.state.siteForm.sidebar = Some(val); this.setState(this.state); } handleSiteLegalInfoChange(val: string) { - this.state.siteForm.legal_information = val; + this.state.siteForm.legal_information = Some(val); this.setState(this.state); } handleSiteApplicationQuestionChange(val: string) { - this.state.siteForm.application_question = val; + this.state.siteForm.application_question = Some(val); this.setState(this.state); } handleSiteDescChange(i: SiteForm, event: any) { - i.state.siteForm.description = event.target.value; + i.state.siteForm.description = Some(event.target.value); i.setState(i.state); } handleSiteEnableNsfwChange(i: SiteForm, event: any) { - i.state.siteForm.enable_nsfw = event.target.checked; + i.state.siteForm.enable_nsfw = Some(event.target.checked); i.setState(i.state); } handleSiteOpenRegistrationChange(i: SiteForm, event: any) { - i.state.siteForm.open_registration = event.target.checked; + i.state.siteForm.open_registration = Some(event.target.checked); i.setState(i.state); } handleSiteCommunityCreationAdminOnly(i: SiteForm, event: any) { - i.state.siteForm.community_creation_admin_only = event.target.checked; + i.state.siteForm.community_creation_admin_only = Some(event.target.checked); i.setState(i.state); } handleSiteEnableDownvotesChange(i: SiteForm, event: any) { - i.state.siteForm.enable_downvotes = event.target.checked; + i.state.siteForm.enable_downvotes = Some(event.target.checked); i.setState(i.state); } handleSiteRequireApplication(i: SiteForm, event: any) { - i.state.siteForm.require_application = event.target.checked; + i.state.siteForm.require_application = Some(event.target.checked); i.setState(i.state); } handleSiteRequireEmailVerification(i: SiteForm, event: any) { - i.state.siteForm.require_email_verification = event.target.checked; + i.state.siteForm.require_email_verification = Some(event.target.checked); i.setState(i.state); } handleSitePrivateInstance(i: SiteForm, event: any) { - i.state.siteForm.private_instance = event.target.checked; + i.state.siteForm.private_instance = Some(event.target.checked); i.setState(i.state); } handleSiteDefaultTheme(i: SiteForm, event: any) { - i.state.siteForm.default_theme = event.target.value; + i.state.siteForm.default_theme = Some(event.target.value); i.setState(i.state); } @@ -523,28 +571,29 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { } handleIconUpload(url: string) { - this.state.siteForm.icon = url; + this.state.siteForm.icon = Some(url); this.setState(this.state); } handleIconRemove() { - this.state.siteForm.icon = ""; + this.state.siteForm.icon = Some(""); this.setState(this.state); } handleBannerUpload(url: string) { - this.state.siteForm.banner = url; + this.state.siteForm.banner = Some(url); this.setState(this.state); } handleBannerRemove() { - this.state.siteForm.banner = ""; + this.state.siteForm.banner = Some(""); this.setState(this.state); } handleDefaultPostListingTypeChange(val: ListingType) { - this.state.siteForm.default_post_listing_type = - ListingType[ListingType[val]]; + this.state.siteForm.default_post_listing_type = Some( + ListingType[ListingType[val]] + ); this.setState(this.state); } } diff --git a/src/shared/components/home/site-sidebar.tsx b/src/shared/components/home/site-sidebar.tsx index 0e7e73c..79a8a43 100644 --- a/src/shared/components/home/site-sidebar.tsx +++ b/src/shared/components/home/site-sidebar.tsx @@ -1,9 +1,9 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { Link } from "inferno-router"; import { PersonViewSafe, Site, SiteAggregates } from "lemmy-js-client"; import { i18n } from "../../i18next"; -import { UserService } from "../../services"; -import { mdToHtml, numToSI } from "../../utils"; +import { amAdmin, mdToHtml, numToSI } from "../../utils"; import { BannerIconHeader } from "../common/banner-icon-header"; import { Icon } from "../common/icon"; import { PersonListing } from "../person/person-listing"; @@ -12,9 +12,9 @@ import { SiteForm } from "./site-form"; interface SiteSidebarProps { site: Site; showLocal: boolean; - counts?: SiteAggregates; - admins?: PersonViewSafe[]; - online?: number; + counts: Option<SiteAggregates>; + admins: Option<PersonViewSafe[]>; + online: Option<number>; } interface SiteSidebarState { @@ -44,18 +44,18 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> { <div> <div class="mb-2"> {this.siteName()} - {this.props.admins && this.adminButtons()} + {this.props.admins.isSome() && this.adminButtons()} </div> {!this.state.collapsed && ( <> - <BannerIconHeader banner={site.banner} /> + <BannerIconHeader banner={site.banner} icon={None} /> {this.siteInfo()} </> )} </div> ) : ( <SiteForm - site={site} + site={Some(site)} showLocal={this.props.showLocal} onEdit={this.handleEditSite} onCancel={this.handleEditCancel} @@ -69,23 +69,21 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> { siteName() { let site = this.props.site; return ( - site.name && ( - <h5 class="mb-0 d-inline"> - {site.name} - <button - class="btn btn-sm text-muted" - onClick={linkEvent(this, this.handleCollapseSidebar)} - aria-label={i18n.t("collapse")} - data-tippy-content={i18n.t("collapse")} - > - {this.state.collapsed ? ( - <Icon icon="plus-square" classes="icon-inline" /> - ) : ( - <Icon icon="minus-square" classes="icon-inline" /> - )} - </button> - </h5> - ) + <h5 class="mb-0 d-inline"> + {site.name} + <button + class="btn btn-sm text-muted" + onClick={linkEvent(this, this.handleCollapseSidebar)} + aria-label={i18n.t("collapse")} + data-tippy-content={i18n.t("collapse")} + > + {this.state.collapsed ? ( + <Icon icon="plus-square" classes="icon-inline" /> + ) : ( + <Icon icon="minus-square" classes="icon-inline" /> + )} + </button> + </h5> ); } @@ -93,17 +91,29 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> { let site = this.props.site; return ( <div> - {site.description && <h6>{site.description}</h6>} - {site.sidebar && this.siteSidebar()} - {this.props.counts && this.badges()} - {this.props.admins && this.admins()} + {site.description.match({ + some: description => <h6>{description}</h6>, + none: <></>, + })} + {site.sidebar.match({ + some: sidebar => this.siteSidebar(sidebar), + none: <></>, + })} + {this.props.counts.match({ + some: counts => this.badges(counts), + none: <></>, + })} + {this.props.admins.match({ + some: admins => this.admins(admins), + none: <></>, + })} </div> ); } adminButtons() { return ( - this.canAdmin && ( + amAdmin(this.props.admins) && ( <ul class="list-inline mb-1 text-muted font-weight-bold"> <li className="list-inline-item-action"> <button @@ -120,20 +130,17 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> { ); } - siteSidebar() { + siteSidebar(sidebar: string) { return ( - <div - className="md-div" - dangerouslySetInnerHTML={mdToHtml(this.props.site.sidebar)} - /> + <div className="md-div" dangerouslySetInnerHTML={mdToHtml(sidebar)} /> ); } - admins() { + admins(admins: PersonViewSafe[]) { return ( <ul class="mt-1 list-inline small mb-0"> <li class="list-inline-item">{i18n.t("admins")}:</li> - {this.props.admins?.map(av => ( + {admins.map(av => ( <li class="list-inline-item"> <PersonListing person={av.person} /> </li> @@ -142,9 +149,9 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> { ); } - badges() { - let counts = this.props.counts; - let online = this.props.online; + badges(siteAggregates: SiteAggregates) { + let counts = siteAggregates; + let online = this.props.online.unwrapOr(1); return ( <ul class="my-2 list-inline"> <li className="list-inline-item badge badge-secondary"> @@ -238,15 +245,6 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> { ); } - get canAdmin(): boolean { - return ( - UserService.Instance.myUserInfo && - this.props.admins - .map(a => a.person.id) - .includes(UserService.Instance.myUserInfo.local_user_view.person.id) - ); - } - handleCollapseSidebar(i: SiteSidebar) { i.state.collapsed = !i.state.collapsed; i.setState(i.state); diff --git a/src/shared/components/modlog.tsx b/src/shared/components/modlog.tsx index e01b81e..0003e54 100644 --- a/src/shared/components/modlog.tsx +++ b/src/shared/components/modlog.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component } from "inferno"; import { Link } from "inferno-router"; import { @@ -6,6 +7,7 @@ import { GetCommunityResponse, GetModlog, GetModlogResponse, + GetSiteResponse, ModAddCommunityView, ModAddView, ModBanFromCommunityView, @@ -17,25 +19,25 @@ import { ModStickyPostView, ModTransferCommunityView, PersonSafe, - SiteView, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import moment from "moment"; import { Subscription } from "rxjs"; import { i18n } from "../i18next"; import { InitialFetchRequest } from "../interfaces"; -import { UserService, WebSocketService } from "../services"; +import { WebSocketService } from "../services"; import { - authField, + amAdmin, + amMod, + auth, fetchLimit, isBrowser, setIsoData, - setOptionalAuth, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../utils"; import { HtmlTags } from "./common/html-tags"; import { Spinner } from "./common/icon"; @@ -75,34 +77,28 @@ type ModlogType = { }; interface ModlogState { - res: GetModlogResponse; - communityId?: number; - communityName?: string; - communityMods?: CommunityModeratorView[]; + res: Option<GetModlogResponse>; + communityId: Option<number>; + communityMods: Option<CommunityModeratorView[]>; page: number; - site_view: SiteView; + siteRes: GetSiteResponse; loading: boolean; } export class Modlog extends Component<any, ModlogState> { - private isoData = setIsoData(this.context); + private isoData = setIsoData( + this.context, + GetModlogResponse, + GetCommunityResponse + ); private subscription: Subscription; private emptyState: ModlogState = { - res: { - removed_posts: [], - locked_posts: [], - stickied_posts: [], - removed_comments: [], - removed_communities: [], - banned_from_community: [], - banned: [], - added_to_community: [], - transferred_to_community: [], - added: [], - }, + res: None, + communityId: None, + communityMods: None, page: 1, loading: true, - site_view: this.isoData.site_res.site_view, + siteRes: this.isoData.site_res, }; constructor(props: any, context: any) { @@ -112,22 +108,23 @@ export class Modlog extends Component<any, ModlogState> { this.handlePageChange = this.handlePageChange.bind(this); this.state.communityId = this.props.match.params.community_id - ? Number(this.props.match.params.community_id) - : undefined; + ? Some(Number(this.props.match.params.community_id)) + : None; this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - let data = this.isoData.routeData[0]; - this.state.res = data; - this.state.loading = false; + this.state.res = Some(this.isoData.routeData[0] as GetModlogResponse); // Getting the moderators - if (this.isoData.routeData[1]) { - this.state.communityMods = this.isoData.routeData[1].moderators; - } + let communityRes = Some( + this.isoData.routeData[1] as GetCommunityResponse + ); + this.state.communityMods = communityRes.map(c => c.moderators); + + this.state.loading = false; } else { this.refetch(); } @@ -226,12 +223,6 @@ export class Modlog extends Component<any, ModlogState> { combined.push(...added); combined.push(...banned); - if (this.state.communityId && combined.length > 0) { - this.state.communityName = ( - combined[0].view as ModRemovePostView - ).community.name; - } - // Sort them by time combined.sort((a, b) => b.when_.localeCompare(a.when_)); @@ -294,11 +285,11 @@ export class Modlog extends Component<any, ModlogState> { <span> Community <CommunityLink community={mrco.community} /> </span>, - mrco.mod_remove_community.reason && - ` reason: ${mrco.mod_remove_community.reason}`, - mrco.mod_remove_community.expires && + mrco.mod_remove_community.reason.isSome() && + ` reason: ${mrco.mod_remove_community.reason.unwrap()}`, + mrco.mod_remove_community.expires.isSome() && ` expires: ${moment - .utc(mrco.mod_remove_community.expires) + .utc(mrco.mod_remove_community.expires.unwrap()) .fromNow()}`, ]; } @@ -316,13 +307,13 @@ export class Modlog extends Component<any, ModlogState> { <CommunityLink community={mbfc.community} /> </span>, <div> - {mbfc.mod_ban_from_community.reason && - ` reason: ${mbfc.mod_ban_from_community.reason}`} + {mbfc.mod_ban_from_community.reason.isSome() && + ` reason: ${mbfc.mod_ban_from_community.reason.unwrap()}`} </div>, <div> - {mbfc.mod_ban_from_community.expires && + {mbfc.mod_ban_from_community.expires.isSome() && ` expires: ${moment - .utc(mbfc.mod_ban_from_community.expires) + .utc(mbfc.mod_ban_from_community.expires.unwrap()) .fromNow()}`} </div>, ]; @@ -364,17 +355,24 @@ export class Modlog extends Component<any, ModlogState> { <span> <PersonListing person={mb.banned_person} /> </span>, - <div>{mb.mod_ban.reason && ` reason: ${mb.mod_ban.reason}`}</div>, <div> - {mb.mod_ban.expires && - ` expires: ${moment.utc(mb.mod_ban.expires).fromNow()}`} + {mb.mod_ban.reason.isSome() && + ` reason: ${mb.mod_ban.reason.unwrap()}`} + </div>, + <div> + {mb.mod_ban.expires.isSome() && + ` expires: ${moment.utc(mb.mod_ban.expires.unwrap()).fromNow()}`} </div>, ]; } case ModlogEnum.ModAdd: { let ma = i.view as ModAddView; return [ - <span>{ma.mod_add.removed ? "Removed " : "Appointed "} </span>, + <span> + {ma.mod_add.removed.isSome() && ma.mod_add.removed.unwrap() + ? "Removed " + : "Appointed "}{" "} + </span>, <span> <PersonListing person={ma.modded_person} /> </span>, @@ -387,17 +385,17 @@ export class Modlog extends Component<any, ModlogState> { } combined() { - let combined = this.buildCombined(this.state.res); + let combined = this.state.res.map(this.buildCombined).unwrapOr([]); return ( <tbody> {combined.map(i => ( <tr> <td> - <MomentTime data={i} /> + <MomentTime published={i.when_} updated={None} /> </td> <td> - {this.isAdminOrMod ? ( + {this.amAdminOrMod ? ( <PersonListing person={i.view.moderator} /> ) : ( <div>{this.modOrAdminText(i.view.moderator)}</div> @@ -410,19 +408,11 @@ export class Modlog extends Component<any, ModlogState> { ); } - get isAdminOrMod(): boolean { - let isAdmin = - UserService.Instance.myUserInfo && - this.isoData.site_res.admins - .map(a => a.person.id) - .includes(UserService.Instance.myUserInfo.local_user_view.person.id); - let isMod = - UserService.Instance.myUserInfo && - this.state.communityMods && - this.state.communityMods - .map(m => m.moderator.id) - .includes(UserService.Instance.myUserInfo.local_user_view.person.id); - return isAdmin || isMod; + get amAdminOrMod(): boolean { + return ( + amAdmin(Some(this.state.siteRes.admins)) || + amMod(this.state.communityMods) + ); } modOrAdminText(person: PersonSafe): Text { @@ -436,7 +426,10 @@ export class Modlog extends Component<any, ModlogState> { } get documentTitle(): string { - return `Modlog - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `Modlog - ${siteView.site.name}`, + none: "", + }); } render() { @@ -445,6 +438,8 @@ export class Modlog extends Component<any, ModlogState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> {this.state.loading ? ( <h5> @@ -452,17 +447,6 @@ export class Modlog extends Component<any, ModlogState> { </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"> @@ -491,46 +475,52 @@ export class Modlog extends Component<any, ModlogState> { } refetch() { - let modlogForm: GetModlog = { + let modlogForm = new GetModlog({ community_id: this.state.communityId, - page: this.state.page, - limit: fetchLimit, - auth: authField(false), - }; + mod_person_id: None, + page: Some(this.state.page), + limit: Some(fetchLimit), + auth: auth(false).ok(), + }); WebSocketService.Instance.send(wsClient.getModlog(modlogForm)); - if (this.state.communityId) { - let communityForm: GetCommunity = { - id: this.state.communityId, - name: this.state.communityName, - }; - WebSocketService.Instance.send(wsClient.getCommunity(communityForm)); - } + this.state.communityId.match({ + some: id => { + let communityForm = new GetCommunity({ + id: Some(id), + name: None, + auth: auth(false).ok(), + }); + WebSocketService.Instance.send(wsClient.getCommunity(communityForm)); + }, + none: void 0, + }); } static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let pathSplit = req.path.split("/"); - let communityId = pathSplit[3]; + let communityId = Some(pathSplit[3]).map(Number); let promises: Promise<any>[] = []; - let modlogForm: GetModlog = { - page: 1, - limit: fetchLimit, - }; - - if (communityId) { - modlogForm.community_id = Number(communityId); - } - setOptionalAuth(modlogForm, req.auth); + let modlogForm = new GetModlog({ + page: Some(1), + limit: Some(fetchLimit), + community_id: communityId, + mod_person_id: None, + auth: req.auth, + }); promises.push(req.client.getModlog(modlogForm)); - if (communityId) { - let communityForm: GetCommunity = { - id: Number(communityId), - }; - setOptionalAuth(communityForm, req.auth); + if (communityId.isSome()) { + let communityForm = new GetCommunity({ + id: communityId, + name: None, + auth: req.auth, + }); promises.push(req.client.getCommunity(communityForm)); + } else { + promises.push(Promise.resolve()); } return promises; } @@ -542,14 +532,14 @@ export class Modlog extends Component<any, ModlogState> { toast(i18n.t(msg.error), "danger"); return; } else if (op == UserOperation.GetModlog) { - let data = wsJsonToRes<GetModlogResponse>(msg).data; + let data = wsJsonToRes<GetModlogResponse>(msg, GetModlogResponse); this.state.loading = false; window.scrollTo(0, 0); - this.state.res = data; + this.state.res = Some(data); this.setState(this.state); } else if (op == UserOperation.GetCommunity) { - let data = wsJsonToRes<GetCommunityResponse>(msg).data; - this.state.communityMods = data.moderators; + let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse); + this.state.communityMods = Some(data.moderators); } } } diff --git a/src/shared/components/person/inbox.tsx b/src/shared/components/person/inbox.tsx index 7995baf..e2faffc 100644 --- a/src/shared/components/person/inbox.tsx +++ b/src/shared/components/person/inbox.tsx @@ -1,3 +1,4 @@ +import { None, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { BlockPersonResponse, @@ -9,25 +10,28 @@ import { GetPrivateMessages, GetReplies, GetRepliesResponse, + GetSiteResponse, PersonMentionResponse, PersonMentionView, PostReportResponse, PrivateMessageResponse, PrivateMessagesResponse, PrivateMessageView, - SiteView, SortType, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, commentsToFlatNodes, createCommentLikeRes, editCommentRes, + enableDownvotes, fetchLimit, isBrowser, relTags, @@ -37,9 +41,7 @@ import { toast, updatePersonBlock, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { CommentNodes } from "../comment/comment-nodes"; import { HtmlTags } from "../common/html-tags"; @@ -81,12 +83,17 @@ interface InboxState { combined: ReplyType[]; sort: SortType; page: number; - site_view: SiteView; + siteRes: GetSiteResponse; loading: boolean; } export class Inbox extends Component<any, InboxState> { - private isoData = setIsoData(this.context); + private isoData = setIsoData( + this.context, + GetRepliesResponse, + GetPersonMentionsResponse, + PrivateMessagesResponse + ); private subscription: Subscription; private emptyState: InboxState = { unreadOrAll: UnreadOrAll.Unread, @@ -97,7 +104,7 @@ export class Inbox extends Component<any, InboxState> { combined: [], sort: SortType.New, page: 1, - site_view: this.isoData.site_res.site_view, + siteRes: this.isoData.site_res, loading: true, }; @@ -108,7 +115,7 @@ export class Inbox extends Component<any, InboxState> { this.handleSortChange = this.handleSortChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this); - if (!UserService.Instance.myUserInfo && isBrowser()) { + if (UserService.Instance.myUserInfo.isNone() && isBrowser()) { toast(i18n.t("not_logged_in"), "danger"); this.context.router.history.push(`/login`); } @@ -118,9 +125,13 @@ export class Inbox extends Component<any, InboxState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.replies = this.isoData.routeData[0].replies || []; - this.state.mentions = this.isoData.routeData[1].mentions || []; - this.state.messages = this.isoData.routeData[2].messages || []; + this.state.replies = + (this.isoData.routeData[0] as GetRepliesResponse).replies || []; + this.state.mentions = + (this.isoData.routeData[1] as GetPersonMentionsResponse).mentions || []; + this.state.messages = + (this.isoData.routeData[2] as PrivateMessagesResponse) + .private_messages || []; this.state.combined = this.buildCombined(); this.state.loading = false; } else { @@ -135,13 +146,23 @@ export class Inbox extends Component<any, InboxState> { } get documentTitle(): string { - return `@${ - UserService.Instance.myUserInfo.local_user_view.person.name - } ${i18n.t("inbox")} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => + UserService.Instance.myUserInfo.match({ + some: mui => + `@${mui.local_user_view.person.name} ${i18n.t("inbox")} - ${ + siteView.site.name + }`, + none: "", + }), + none: "", + }); } render() { - let inboxRss = `/feeds/inbox/${UserService.Instance.auth}.xml`; + let inboxRss = auth() + .ok() + .map(a => `/feeds/inbox/${a}.xml`); return ( <div class="container"> {this.state.loading ? ( @@ -154,19 +175,26 @@ export class Inbox extends Component<any, InboxState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <h5 class="mb-2"> {i18n.t("inbox")} - <small> - <a href={inboxRss} title="RSS" rel={relTags}> - <Icon icon="rss" classes="ml-2 text-muted small" /> - </a> - <link - rel="alternate" - type="application/atom+xml" - href={inboxRss} - /> - </small> + {inboxRss.match({ + some: rss => ( + <small> + <a href={rss} title="RSS" rel={relTags}> + <Icon icon="rss" classes="ml-2 text-muted small" /> + </a> + <link + rel="alternate" + type="application/atom+xml" + href={rss} + /> + </small> + ), + none: <></>, + })} </h5> {this.state.replies.length + this.state.mentions.length + @@ -355,11 +383,14 @@ export class Inbox extends Component<any, InboxState> { <CommentNodes key={i.id} nodes={[{ comment_view: i.view as CommentView }]} + moderators={None} + admins={None} + maxCommentsShown={None} noIndent markable showCommunity showContext - enableDownvotes={this.state.site_view.site.enable_downvotes} + enableDownvotes={enableDownvotes(this.state.siteRes)} /> ); case ReplyEnum.Mention: @@ -367,11 +398,14 @@ export class Inbox extends Component<any, InboxState> { <CommentNodes key={i.id} nodes={[{ comment_view: i.view as PersonMentionView }]} + moderators={None} + admins={None} + maxCommentsShown={None} noIndent markable showCommunity showContext - enableDownvotes={this.state.site_view.site.enable_downvotes} + enableDownvotes={enableDownvotes(this.state.siteRes)} /> ); case ReplyEnum.Message: @@ -395,11 +429,14 @@ export class Inbox extends Component<any, InboxState> { <div> <CommentNodes nodes={commentsToFlatNodes(this.state.replies)} + moderators={None} + admins={None} + maxCommentsShown={None} noIndent markable showCommunity showContext - enableDownvotes={this.state.site_view.site.enable_downvotes} + enableDownvotes={enableDownvotes(this.state.siteRes)} /> </div> ); @@ -412,11 +449,14 @@ export class Inbox extends Component<any, InboxState> { <CommentNodes key={umv.person_mention.id} nodes={[{ comment_view: umv }]} + moderators={None} + admins={None} + maxCommentsShown={None} noIndent markable showCommunity showContext - enableDownvotes={this.state.site_view.site.enable_downvotes} + enableDownvotes={enableDownvotes(this.state.siteRes)} /> ))} </div> @@ -459,62 +499,67 @@ export class Inbox extends Component<any, InboxState> { let promises: Promise<any>[] = []; // It can be /u/me, or /username/1 - let repliesForm: GetReplies = { - sort: SortType.New, - unread_only: true, - page: 1, - limit: fetchLimit, - auth: req.auth, - }; + let repliesForm = new GetReplies({ + sort: Some(SortType.New), + unread_only: Some(true), + page: Some(1), + limit: Some(fetchLimit), + auth: req.auth.unwrap(), + }); promises.push(req.client.getReplies(repliesForm)); - let personMentionsForm: GetPersonMentions = { - sort: SortType.New, - unread_only: true, - page: 1, - limit: fetchLimit, - auth: req.auth, - }; + let personMentionsForm = new GetPersonMentions({ + sort: Some(SortType.New), + unread_only: Some(true), + page: Some(1), + limit: Some(fetchLimit), + auth: req.auth.unwrap(), + }); promises.push(req.client.getPersonMentions(personMentionsForm)); - let privateMessagesForm: GetPrivateMessages = { - unread_only: true, - page: 1, - limit: fetchLimit, - auth: req.auth, - }; + let privateMessagesForm = new GetPrivateMessages({ + unread_only: Some(true), + page: Some(1), + limit: Some(fetchLimit), + auth: req.auth.unwrap(), + }); promises.push(req.client.getPrivateMessages(privateMessagesForm)); return promises; } refetch() { - let repliesForm: GetReplies = { - sort: this.state.sort, - unread_only: this.state.unreadOrAll == UnreadOrAll.Unread, - page: this.state.page, - limit: fetchLimit, - auth: authField(), - }; + let sort = Some(this.state.sort); + let unread_only = Some(this.state.unreadOrAll == UnreadOrAll.Unread); + let page = Some(this.state.page); + let limit = Some(fetchLimit); + + let repliesForm = new GetReplies({ + sort, + unread_only, + page, + limit, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.getReplies(repliesForm)); - let personMentionsForm: GetPersonMentions = { - sort: this.state.sort, - unread_only: this.state.unreadOrAll == UnreadOrAll.Unread, - page: this.state.page, - limit: fetchLimit, - auth: authField(), - }; + let personMentionsForm = new GetPersonMentions({ + sort, + unread_only, + page, + limit, + auth: auth().unwrap(), + }); WebSocketService.Instance.send( wsClient.getPersonMentions(personMentionsForm) ); - let privateMessagesForm: GetPrivateMessages = { - unread_only: this.state.unreadOrAll == UnreadOrAll.Unread, - page: this.state.page, - limit: fetchLimit, - auth: authField(), - }; + let privateMessagesForm = new GetPrivateMessages({ + unread_only, + page, + limit, + auth: auth().unwrap(), + }); WebSocketService.Instance.send( wsClient.getPrivateMessages(privateMessagesForm) ); @@ -530,7 +575,7 @@ export class Inbox extends Component<any, InboxState> { markAllAsRead(i: Inbox) { WebSocketService.Instance.send( wsClient.markAllAsRead({ - auth: authField(), + auth: auth().unwrap(), }) ); i.state.replies = []; @@ -559,7 +604,7 @@ export class Inbox extends Component<any, InboxState> { } else if (msg.reconnect) { this.refetch(); } else if (op == UserOperation.GetReplies) { - let data = wsJsonToRes<GetRepliesResponse>(msg).data; + let data = wsJsonToRes<GetRepliesResponse>(msg, GetRepliesResponse); this.state.replies = data.replies; this.state.combined = this.buildCombined(); this.state.loading = false; @@ -567,21 +612,30 @@ export class Inbox extends Component<any, InboxState> { this.setState(this.state); setupTippy(); } else if (op == UserOperation.GetPersonMentions) { - let data = wsJsonToRes<GetPersonMentionsResponse>(msg).data; + let data = wsJsonToRes<GetPersonMentionsResponse>( + msg, + GetPersonMentionsResponse + ); this.state.mentions = data.mentions; this.state.combined = this.buildCombined(); window.scrollTo(0, 0); this.setState(this.state); setupTippy(); } else if (op == UserOperation.GetPrivateMessages) { - let data = wsJsonToRes<PrivateMessagesResponse>(msg).data; + let data = wsJsonToRes<PrivateMessagesResponse>( + msg, + PrivateMessagesResponse + ); this.state.messages = data.private_messages; this.state.combined = this.buildCombined(); window.scrollTo(0, 0); this.setState(this.state); setupTippy(); } else if (op == UserOperation.EditPrivateMessage) { - let data = wsJsonToRes<PrivateMessageResponse>(msg).data; + let data = wsJsonToRes<PrivateMessageResponse>( + msg, + PrivateMessageResponse + ); let found: PrivateMessageView = this.state.messages.find( m => m.private_message.id === data.private_message_view.private_message.id @@ -597,7 +651,10 @@ export class Inbox extends Component<any, InboxState> { } this.setState(this.state); } else if (op == UserOperation.DeletePrivateMessage) { - let data = wsJsonToRes<PrivateMessageResponse>(msg).data; + let data = wsJsonToRes<PrivateMessageResponse>( + msg, + PrivateMessageResponse + ); let found: PrivateMessageView = this.state.messages.find( m => m.private_message.id === data.private_message_view.private_message.id @@ -613,7 +670,10 @@ export class Inbox extends Component<any, InboxState> { } this.setState(this.state); } else if (op == UserOperation.MarkPrivateMessageAsRead) { - let data = wsJsonToRes<PrivateMessageResponse>(msg).data; + let data = wsJsonToRes<PrivateMessageResponse>( + msg, + PrivateMessageResponse + ); let found: PrivateMessageView = this.state.messages.find( m => m.private_message.id === data.private_message_view.private_message.id @@ -653,11 +713,11 @@ export class Inbox extends Component<any, InboxState> { op == UserOperation.DeleteComment || op == UserOperation.RemoveComment ) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); editCommentRes(data.comment_view, this.state.replies); this.setState(this.state); } else if (op == UserOperation.MarkCommentAsRead) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); // If youre in the unread view, just remove it from the list if ( @@ -685,7 +745,7 @@ export class Inbox extends Component<any, InboxState> { this.setState(this.state); setupTippy(); } else if (op == UserOperation.MarkPersonMentionAsRead) { - let data = wsJsonToRes<PersonMentionResponse>(msg).data; + let data = wsJsonToRes<PersonMentionResponse>(msg, PersonMentionResponse); // TODO this might not be correct, it might need to use the comment id let found = this.state.mentions.find( @@ -732,85 +792,109 @@ export class Inbox extends Component<any, InboxState> { this.sendUnreadCount(data.person_mention_view.person_mention.read); this.setState(this.state); } else if (op == UserOperation.CreateComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; - - if ( - data.recipient_ids.includes( - UserService.Instance.myUserInfo.local_user_view.local_user.id - ) - ) { - this.state.replies.unshift(data.comment_view); - this.state.combined.unshift(this.replyToReplyType(data.comment_view)); - this.setState(this.state); - } else if ( - data.comment_view.creator.id == - UserService.Instance.myUserInfo.local_user_view.person.id - ) { - // If youre in the unread view, just remove it from the list - if (this.state.unreadOrAll == UnreadOrAll.Unread) { - this.state.replies = this.state.replies.filter( - r => r.comment.id !== data.comment_view.comment.parent_id - ); - this.state.mentions = this.state.mentions.filter( - m => m.comment.id !== data.comment_view.comment.parent_id - ); - this.state.combined = this.state.combined.filter(r => { - if (this.isMention(r.view)) - return r.view.comment.id !== data.comment_view.comment.parent_id; - else return r.id !== data.comment_view.comment.parent_id; - }); - } else { - let mention_found = this.state.mentions.find( - i => i.comment.id == data.comment_view.comment.parent_id - ); - if (mention_found) { - mention_found.person_mention.read = true; - } - let reply_found = this.state.replies.find( - i => i.comment.id == data.comment_view.comment.parent_id - ); - if (reply_found) { - reply_found.comment.read = true; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + + UserService.Instance.myUserInfo.match({ + some: mui => { + if (data.recipient_ids.includes(mui.local_user_view.local_user.id)) { + this.state.replies.unshift(data.comment_view); + this.state.combined.unshift( + this.replyToReplyType(data.comment_view) + ); + this.setState(this.state); + } else if ( + data.comment_view.creator.id == mui.local_user_view.person.id + ) { + // If youre in the unread view, just remove it from the list + if (this.state.unreadOrAll == UnreadOrAll.Unread) { + this.state.replies = this.state.replies.filter( + r => + r.comment.id !== + data.comment_view.comment.parent_id.unwrapOr(0) + ); + this.state.mentions = this.state.mentions.filter( + m => + m.comment.id !== + data.comment_view.comment.parent_id.unwrapOr(0) + ); + this.state.combined = this.state.combined.filter(r => { + if (this.isMention(r.view)) + return ( + r.view.comment.id !== + data.comment_view.comment.parent_id.unwrapOr(0) + ); + else + return ( + r.id !== data.comment_view.comment.parent_id.unwrapOr(0) + ); + }); + } else { + let mention_found = this.state.mentions.find( + i => + i.comment.id == + data.comment_view.comment.parent_id.unwrapOr(0) + ); + if (mention_found) { + mention_found.person_mention.read = true; + } + let reply_found = this.state.replies.find( + i => + i.comment.id == + data.comment_view.comment.parent_id.unwrapOr(0) + ); + if (reply_found) { + reply_found.comment.read = true; + } + this.state.combined = this.buildCombined(); + } + this.sendUnreadCount(true); + this.setState(this.state); + setupTippy(); + // TODO this seems wrong, you should be using form_id + toast(i18n.t("reply_sent")); } - this.state.combined = this.buildCombined(); - } - this.sendUnreadCount(true); - this.setState(this.state); - setupTippy(); - // TODO this seems wrong, you should be using form_id - toast(i18n.t("reply_sent")); - } + }, + none: void 0, + }); } else if (op == UserOperation.CreatePrivateMessage) { - let data = wsJsonToRes<PrivateMessageResponse>(msg).data; - if ( - data.private_message_view.recipient.id == - UserService.Instance.myUserInfo.local_user_view.person.id - ) { - this.state.messages.unshift(data.private_message_view); - this.state.combined.unshift( - this.messageToReplyType(data.private_message_view) - ); - this.setState(this.state); - } + let data = wsJsonToRes<PrivateMessageResponse>( + msg, + PrivateMessageResponse + ); + UserService.Instance.myUserInfo.match({ + some: mui => { + if ( + data.private_message_view.recipient.id == + mui.local_user_view.person.id + ) { + this.state.messages.unshift(data.private_message_view); + this.state.combined.unshift( + this.messageToReplyType(data.private_message_view) + ); + this.setState(this.state); + } + }, + none: void 0, + }); } else if (op == UserOperation.SaveComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); saveCommentRes(data.comment_view, this.state.replies); this.setState(this.state); setupTippy(); } else if (op == UserOperation.CreateCommentLike) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); createCommentLikeRes(data.comment_view, this.state.replies); this.setState(this.state); } else if (op == UserOperation.BlockPerson) { - let data = wsJsonToRes<BlockPersonResponse>(msg).data; + let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse); updatePersonBlock(data); } else if (op == UserOperation.CreatePostReport) { - let data = wsJsonToRes<PostReportResponse>(msg).data; + let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse); if (data) { toast(i18n.t("report_created")); } } else if (op == UserOperation.CreateCommentReport) { - let data = wsJsonToRes<CommentReportResponse>(msg).data; + let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse); if (data) { toast(i18n.t("report_created")); } diff --git a/src/shared/components/person/password-change.tsx b/src/shared/components/person/password-change.tsx index 2b23ddc..8120fe9 100644 --- a/src/shared/components/person/password-change.tsx +++ b/src/shared/components/person/password-change.tsx @@ -1,9 +1,12 @@ +import { None } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { + GetSiteResponse, LoginResponse, PasswordChange as PasswordChangeForm, - SiteView, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; @@ -14,9 +17,7 @@ import { setIsoData, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; @@ -24,7 +25,7 @@ import { Spinner } from "../common/icon"; interface State { passwordChangeForm: PasswordChangeForm; loading: boolean; - site_view: SiteView; + siteRes: GetSiteResponse; } export class PasswordChange extends Component<any, State> { @@ -32,13 +33,13 @@ export class PasswordChange extends Component<any, State> { private subscription: Subscription; emptyState: State = { - passwordChangeForm: { + passwordChangeForm: new PasswordChangeForm({ token: this.props.match.params.token, password: undefined, password_verify: undefined, - }, + }), loading: false, - site_view: this.isoData.site_res.site_view, + siteRes: this.isoData.site_res, }; constructor(props: any, context: any) { @@ -57,7 +58,10 @@ export class PasswordChange extends Component<any, State> { } get documentTitle(): string { - return `${i18n.t("password_change")} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `${i18n.t("password_change")} - ${siteView.site.name}`, + none: "", + }); } render() { @@ -66,6 +70,8 @@ export class PasswordChange extends Component<any, State> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <div class="row"> <div class="col-12 col-lg-6 offset-lg-3 mb-4"> @@ -156,7 +162,7 @@ export class PasswordChange extends Component<any, State> { this.setState(this.state); return; } else if (op == UserOperation.PasswordChange) { - let data = wsJsonToRes<LoginResponse>(msg).data; + let data = wsJsonToRes<LoginResponse>(msg, LoginResponse); this.state = this.emptyState; this.setState(this.state); UserService.Instance.login(data); diff --git a/src/shared/components/person/person-details.tsx b/src/shared/components/person/person-details.tsx index 1e37a58..0dabf2a 100644 --- a/src/shared/components/person/person-details.tsx +++ b/src/shared/components/person/person-details.tsx @@ -1,3 +1,4 @@ +import { None, Some } from "@sniptt/monads/build"; import { Component } from "inferno"; import { CommentView, @@ -89,7 +90,9 @@ export class PersonDetails extends Component<PersonDetailsProps, any> { <CommentNodes key={i.id} nodes={[{ comment_view: c }]} - admins={this.props.admins} + admins={Some(this.props.admins)} + moderators={None} + maxCommentsShown={None} noBorder noIndent showCommunity @@ -104,7 +107,9 @@ export class PersonDetails extends Component<PersonDetailsProps, any> { <PostListing key={i.id} post_view={p} - admins={this.props.admins} + admins={Some(this.props.admins)} + duplicates={None} + moderators={None} showCommunity enableDownvotes={this.props.enableDownvotes} enableNsfw={this.props.enableNsfw} @@ -154,7 +159,9 @@ export class PersonDetails extends Component<PersonDetailsProps, any> { <div> <CommentNodes nodes={commentsToFlatNodes(this.props.personRes.comments)} - admins={this.props.admins} + admins={Some(this.props.admins)} + moderators={None} + maxCommentsShown={None} noIndent showCommunity showContext @@ -171,8 +178,10 @@ export class PersonDetails extends Component<PersonDetailsProps, any> { <> <PostListing post_view={post} - admins={this.props.admins} + admins={Some(this.props.admins)} showCommunity + duplicates={None} + moderators={None} enableDownvotes={this.props.enableDownvotes} enableNsfw={this.props.enableNsfw} /> diff --git a/src/shared/components/person/person-listing.tsx b/src/shared/components/person/person-listing.tsx index 56db662..88b8882 100644 --- a/src/shared/components/person/person-listing.tsx +++ b/src/shared/components/person/person-listing.tsx @@ -21,7 +21,7 @@ export class PersonListing extends Component<PersonListingProps, any> { render() { let person = this.props.person; - let local = person.local == null ? true : person.local; + let local = person.local; let apubName: string, link: string; if (local) { @@ -37,11 +37,9 @@ export class PersonListing extends Component<PersonListingProps, any> { let displayName = this.props.useApubName ? apubName - : person.display_name - ? person.display_name - : apubName; + : person.display_name.unwrapOr(apubName); - if (this.props.showApubName && !local && person.display_name) { + if (this.props.showApubName && !local && person.display_name.isSome()) { displayName = `${displayName} (${apubName})`; } @@ -72,12 +70,14 @@ export class PersonListing extends Component<PersonListingProps, any> { } avatarAndName(displayName: string) { - let person = this.props.person; return ( <> - {!this.props.hideAvatar && person.avatar && showAvatars() && ( - <PictrsImage src={person.avatar} icon /> - )} + {this.props.person.avatar.match({ + some: avatar => + !this.props.hideAvatar && + showAvatars() && <PictrsImage src={avatar} icon />, + none: <></>, + })} <span>{displayName}</span> </> ); diff --git a/src/shared/components/person/profile.tsx b/src/shared/components/person/profile.tsx index 372a899..053e4b2 100644 --- a/src/shared/components/person/profile.tsx +++ b/src/shared/components/person/profile.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { Link } from "inferno-router"; import { @@ -12,7 +13,10 @@ import { GetSiteResponse, PostResponse, SortType, + toUndefined, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import moment from "moment"; import { Subscription } from "rxjs"; @@ -20,18 +24,20 @@ import { i18n } from "../../i18next"; import { InitialFetchRequest, PersonDetailsView } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, canMod, capitalizeFirstLetter, createCommentLikeRes, createPostLikeFindRes, editCommentRes, editPostFindRes, + enableDownvotes, + enableNsfw, fetchLimit, futureDaysToUnixTime, getUsernameFromProps, + isAdmin, isBanned, - isMod, mdToHtml, numToSI, relTags, @@ -40,14 +46,11 @@ import { saveCommentRes, saveScrollPosition, setIsoData, - setOptionalAuth, setupTippy, toast, updatePersonBlock, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { BannerIconHeader } from "../common/banner-icon-header"; import { HtmlTags } from "../common/html-tags"; @@ -59,18 +62,18 @@ import { PersonDetails } from "./person-details"; import { PersonListing } from "./person-listing"; interface ProfileState { - personRes: GetPersonDetailsResponse; + personRes: Option<GetPersonDetailsResponse>; userName: string; view: PersonDetailsView; sort: SortType; page: number; loading: boolean; personBlocked: boolean; - siteRes: GetSiteResponse; + banReason: Option<string>; + banExpireDays: Option<number>; showBanDialog: boolean; - banReason: string; - banExpireDays: number; removeData: boolean; + siteRes: GetSiteResponse; } interface ProfileProps { @@ -88,10 +91,10 @@ interface UrlParams { } export class Profile extends Component<any, ProfileState> { - private isoData = setIsoData(this.context); + private isoData = setIsoData(this.context, GetPersonDetailsResponse); private subscription: Subscription; private emptyState: ProfileState = { - personRes: undefined, + personRes: None, userName: getUsernameFromProps(this.props), loading: true, view: Profile.getViewFromProps(this.props.match.view), @@ -117,7 +120,9 @@ export class Profile extends Component<any, ProfileState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.personRes = this.isoData.routeData[0]; + this.state.personRes = Some( + this.isoData.routeData[0] as GetPersonDetailsResponse + ); this.state.loading = false; } else { this.fetchUserData(); @@ -127,28 +132,44 @@ export class Profile extends Component<any, ProfileState> { } fetchUserData() { - let form: GetPersonDetails = { - username: this.state.userName, - sort: this.state.sort, - saved_only: this.state.view === PersonDetailsView.Saved, - page: this.state.page, - limit: fetchLimit, - auth: authField(false), - }; + let form = new GetPersonDetails({ + username: Some(this.state.userName), + person_id: None, + community_id: None, + sort: Some(this.state.sort), + saved_only: Some(this.state.view === PersonDetailsView.Saved), + page: Some(this.state.page), + limit: Some(fetchLimit), + auth: auth(false).ok(), + }); WebSocketService.Instance.send(wsClient.getPersonDetails(form)); } - get isCurrentUser() { - return ( - UserService.Instance.myUserInfo?.local_user_view.person.id == - this.state.personRes?.person_view.person.id - ); + get amCurrentUser() { + return UserService.Instance.myUserInfo.match({ + some: mui => + this.state.personRes.match({ + some: res => + mui.local_user_view.person.id == res.person_view.person.id, + none: false, + }), + none: false, + }); } setPersonBlock() { - this.state.personBlocked = UserService.Instance.myUserInfo?.person_blocks - .map(a => a.target.id) - .includes(this.state.personRes?.person_view.person.id); + UserService.Instance.myUserInfo.match({ + some: mui => + this.state.personRes.match({ + some: res => { + this.state.personBlocked = mui.person_blocks + .map(a => a.target.id) + .includes(res.person_view.person.id); + }, + none: void 0, + }), + none: void 0, + }); } static getViewFromProps(view: string): PersonDetailsView { @@ -165,23 +186,23 @@ export class Profile extends Component<any, ProfileState> { static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let pathSplit = req.path.split("/"); - let promises: Promise<any>[] = []; let username = pathSplit[2]; let view = this.getViewFromProps(pathSplit[4]); - let sort = this.getSortTypeFromProps(pathSplit[6]); - let page = this.getPageFromProps(Number(pathSplit[8])); + let sort = Some(this.getSortTypeFromProps(pathSplit[6])); + let page = Some(this.getPageFromProps(Number(pathSplit[8]))); - let form: GetPersonDetails = { + let form = new GetPersonDetails({ + username: Some(username), + person_id: None, + community_id: None, sort, - saved_only: view === PersonDetailsView.Saved, + saved_only: Some(view === PersonDetailsView.Saved), page, - limit: fetchLimit, - username: username, - }; - setOptionalAuth(form, req.auth); - promises.push(req.client.getPersonDetails(form)); - return promises; + limit: Some(fetchLimit), + auth: req.auth, + }); + return [req.client.getPersonDetails(form)]; } componentDidMount() { @@ -215,13 +236,15 @@ export class Profile extends Component<any, ProfileState> { } get documentTitle(): string { - return `@${this.state.personRes.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`; - } - - get bioTag(): string { - return this.state.personRes.person_view.person.bio - ? this.state.personRes.person_view.person.bio - : undefined; + return this.state.siteRes.site_view.match({ + some: siteView => + this.state.personRes.match({ + some: res => + `@${res.person_view.person.name} - ${siteView.site.name}`, + none: "", + }), + none: "", + }); } render() { @@ -232,41 +255,44 @@ export class Profile extends Component<any, ProfileState> { <Spinner large /> </h5> ) : ( - <div class="row"> - <div class="col-12 col-md-8"> - <> - <HtmlTags - title={this.documentTitle} - path={this.context.router.route.match.url} - description={this.bioTag} - image={this.state.personRes.person_view.person.avatar} - /> - {this.userInfo()} - <hr /> - </> - {!this.state.loading && this.selects()} - <PersonDetails - personRes={this.state.personRes} - admins={this.state.siteRes.admins} - sort={this.state.sort} - page={this.state.page} - limit={fetchLimit} - enableDownvotes={ - this.state.siteRes.site_view.site.enable_downvotes - } - enableNsfw={this.state.siteRes.site_view.site.enable_nsfw} - view={this.state.view} - onPageChange={this.handlePageChange} - /> - </div> + this.state.personRes.match({ + some: res => ( + <div class="row"> + <div class="col-12 col-md-8"> + <> + <HtmlTags + title={this.documentTitle} + path={this.context.router.route.match.url} + description={res.person_view.person.bio} + image={res.person_view.person.avatar} + /> + {this.userInfo()} + <hr /> + </> + {!this.state.loading && this.selects()} + <PersonDetails + personRes={res} + admins={this.state.siteRes.admins} + sort={this.state.sort} + page={this.state.page} + limit={fetchLimit} + enableDownvotes={enableDownvotes(this.state.siteRes)} + enableNsfw={enableNsfw(this.state.siteRes)} + view={this.state.view} + onPageChange={this.handlePageChange} + /> + </div> - {!this.state.loading && ( - <div class="col-12 col-md-4"> - {this.moderates()} - {this.isCurrentUser && this.follows()} + {!this.state.loading && ( + <div class="col-12 col-md-4"> + {this.moderates()} + {this.amCurrentUser && this.follows()} + </div> + )} </div> - )} - </div> + ), + none: <></>, + }) )} </div> ); @@ -352,286 +378,330 @@ export class Profile extends Component<any, ProfileState> { } handleBlockPerson(personId: number) { if (personId != 0) { - let blockUserForm: BlockPerson = { + let blockUserForm = new BlockPerson({ person_id: personId, block: true, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm)); } } handleUnblockPerson(recipientId: number) { - let blockUserForm: BlockPerson = { + let blockUserForm = new BlockPerson({ person_id: recipientId, block: false, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm)); } userInfo() { - let pv = this.state.personRes?.person_view; - - return ( - <div> - <BannerIconHeader banner={pv.person.banner} icon={pv.person.avatar} /> - <div class="mb-3"> - <div class=""> - <div class="mb-0 d-flex flex-wrap"> - <div> - {pv.person.display_name && ( - <h5 class="mb-0">{pv.person.display_name}</h5> - )} - <ul class="list-inline mb-2"> - <li className="list-inline-item"> - <PersonListing - person={pv.person} - realLink - useApubName - muted - hideAvatar - /> - </li> - {isBanned(pv.person) && ( - <li className="list-inline-item badge badge-danger"> - {i18n.t("banned")} - </li> - )} - {pv.person.admin && ( + return this.state.personRes + .map(r => r.person_view) + .match({ + some: pv => ( + <div> + <BannerIconHeader + banner={pv.person.banner} + icon={pv.person.avatar} + /> + <div class="mb-3"> + <div class=""> + <div class="mb-0 d-flex flex-wrap"> + <div> + {pv.person.display_name && ( + <h5 class="mb-0">{pv.person.display_name}</h5> + )} + <ul class="list-inline mb-2"> + <li className="list-inline-item"> + <PersonListing + person={pv.person} + realLink + useApubName + muted + hideAvatar + /> + </li> + {isBanned(pv.person) && ( + <li className="list-inline-item badge badge-danger"> + {i18n.t("banned")} + </li> + )} + {pv.person.admin && ( + <li className="list-inline-item badge badge-light"> + {i18n.t("admin")} + </li> + )} + {pv.person.bot_account && ( + <li className="list-inline-item badge badge-light"> + {i18n.t("bot_account").toLowerCase()} + </li> + )} + </ul> + </div> + {this.banDialog()} + <div className="flex-grow-1 unselectable pointer mx-2"></div> + {!this.amCurrentUser && + UserService.Instance.myUserInfo.isSome() && ( + <> + <a + className={`d-flex align-self-start btn btn-secondary mr-2 ${ + !pv.person.matrix_user_id && "invisible" + }`} + rel={relTags} + href={`https://matrix.to/#/${pv.person.matrix_user_id}`} + > + {i18n.t("send_secure_message")} + </a> + <Link + className={ + "d-flex align-self-start btn btn-secondary mr-2" + } + to={`/create_private_message/recipient/${pv.person.id}`} + > + {i18n.t("send_message")} + </Link> + {this.state.personBlocked ? ( + <button + className={ + "d-flex align-self-start btn btn-secondary mr-2" + } + onClick={linkEvent( + pv.person.id, + this.handleUnblockPerson + )} + > + {i18n.t("unblock_user")} + </button> + ) : ( + <button + className={ + "d-flex align-self-start btn btn-secondary mr-2" + } + onClick={linkEvent( + pv.person.id, + this.handleBlockPerson + )} + > + {i18n.t("block_user")} + </button> + )} + </> + )} + + {canMod( + None, + Some(this.state.siteRes.admins), + pv.person.id + ) && + !isAdmin(Some(this.state.siteRes.admins), pv.person.id) && + !this.state.showBanDialog && + (!isBanned(pv.person) ? ( + <button + className={ + "d-flex align-self-start btn btn-secondary mr-2" + } + onClick={linkEvent(this, this.handleModBanShow)} + aria-label={i18n.t("ban")} + > + {capitalizeFirstLetter(i18n.t("ban"))} + </button> + ) : ( + <button + className={ + "d-flex align-self-start btn btn-secondary mr-2" + } + onClick={linkEvent(this, this.handleModBanSubmit)} + aria-label={i18n.t("unban")} + > + {capitalizeFirstLetter(i18n.t("unban"))} + </button> + ))} + </div> + {pv.person.bio.match({ + some: bio => ( + <div className="d-flex align-items-center mb-2"> + <div + className="md-div" + dangerouslySetInnerHTML={mdToHtml(bio)} + /> + </div> + ), + none: <></>, + })} + <div> + <ul class="list-inline mb-2"> <li className="list-inline-item badge badge-light"> - {i18n.t("admin")} + {i18n.t("number_of_posts", { + count: pv.counts.post_count, + formattedCount: numToSI(pv.counts.post_count), + })} </li> - )} - {pv.person.bot_account && ( <li className="list-inline-item badge badge-light"> - {i18n.t("bot_account").toLowerCase()} + {i18n.t("number_of_comments", { + count: pv.counts.comment_count, + formattedCount: numToSI(pv.counts.comment_count), + })} </li> - )} - </ul> - </div> - {this.banDialog()} - <div className="flex-grow-1 unselectable pointer mx-2"></div> - {!this.isCurrentUser && UserService.Instance.myUserInfo && ( - <> - <a - className={`d-flex align-self-start btn btn-secondary mr-2 ${ - !pv.person.matrix_user_id && "invisible" - }`} - rel={relTags} - href={`https://matrix.to/#/${pv.person.matrix_user_id}`} - > - {i18n.t("send_secure_message")} - </a> - <Link - className={"d-flex align-self-start btn btn-secondary mr-2"} - to={`/create_private_message/recipient/${pv.person.id}`} - > - {i18n.t("send_message")} - </Link> - {this.state.personBlocked ? ( - <button - className={ - "d-flex align-self-start btn btn-secondary mr-2" - } - onClick={linkEvent( - pv.person.id, - this.handleUnblockPerson - )} - > - {i18n.t("unblock_user")} - </button> - ) : ( - <button - className={ - "d-flex align-self-start btn btn-secondary mr-2" - } - onClick={linkEvent(pv.person.id, this.handleBlockPerson)} - > - {i18n.t("block_user")} - </button> - )} - </> - )} - - {this.canAdmin && - !this.personIsAdmin && - !this.state.showBanDialog && - (!isBanned(pv.person) ? ( - <button - className={"d-flex align-self-start btn btn-secondary mr-2"} - onClick={linkEvent(this, this.handleModBanShow)} - aria-label={i18n.t("ban")} - > - {capitalizeFirstLetter(i18n.t("ban"))} - </button> - ) : ( - <button - className={"d-flex align-self-start btn btn-secondary mr-2"} - onClick={linkEvent(this, this.handleModBanSubmit)} - aria-label={i18n.t("unban")} - > - {capitalizeFirstLetter(i18n.t("unban"))} - </button> - ))} - </div> - {pv.person.bio && ( - <div className="d-flex align-items-center mb-2"> - <div - className="md-div" - dangerouslySetInnerHTML={mdToHtml(pv.person.bio)} - /> + </ul> + </div> + <div class="text-muted"> + {i18n.t("joined")}{" "} + <MomentTime + published={pv.person.published} + updated={None} + showAgo + ignoreUpdated + /> + </div> + <div className="d-flex align-items-center text-muted mb-2"> + <Icon icon="cake" /> + <span className="ml-2"> + {i18n.t("cake_day_title")}{" "} + {moment + .utc(pv.person.published) + .local() + .format("MMM DD, YYYY")} + </span> + </div> </div> - )} - <div> - <ul class="list-inline mb-2"> - <li className="list-inline-item badge badge-light"> - {i18n.t("number_of_posts", { - count: pv.counts.post_count, - formattedCount: numToSI(pv.counts.post_count), - })} - </li> - <li className="list-inline-item badge badge-light"> - {i18n.t("number_of_comments", { - count: pv.counts.comment_count, - formattedCount: numToSI(pv.counts.comment_count), - })} - </li> - </ul> - </div> - <div class="text-muted"> - {i18n.t("joined")}{" "} - <MomentTime data={pv.person} showAgo ignoreUpdated /> - </div> - <div className="d-flex align-items-center text-muted mb-2"> - <Icon icon="cake" /> - <span className="ml-2"> - {i18n.t("cake_day_title")}{" "} - {moment.utc(pv.person.published).local().format("MMM DD, YYYY")} - </span> </div> </div> - </div> - </div> - ); + ), + none: <></>, + }); } banDialog() { - let pv = this.state.personRes?.person_view; - return ( - <> - {this.state.showBanDialog && ( - <form onSubmit={linkEvent(this, this.handleModBanSubmit)}> - <div class="form-group row col-12"> - <label class="col-form-label" htmlFor="profile-ban-reason"> - {i18n.t("reason")} - </label> - <input - type="text" - id="profile-ban-reason" - class="form-control mr-2" - placeholder={i18n.t("reason")} - value={this.state.banReason} - onInput={linkEvent(this, this.handleModBanReasonChange)} - /> - <label class="col-form-label" htmlFor={`mod-ban-expires`}> - {i18n.t("expires")} - </label> - <input - type="number" - id={`mod-ban-expires`} - class="form-control mr-2" - placeholder={i18n.t("number_of_days")} - value={this.state.banExpireDays} - onInput={linkEvent(this, this.handleModBanExpireDaysChange)} - /> - <div class="form-group"> - <div class="form-check"> + return this.state.personRes + .map(r => r.person_view) + .match({ + some: pv => ( + <> + {this.state.showBanDialog && ( + <form onSubmit={linkEvent(this, this.handleModBanSubmit)}> + <div class="form-group row col-12"> + <label class="col-form-label" htmlFor="profile-ban-reason"> + {i18n.t("reason")} + </label> <input - class="form-check-input" - id="mod-ban-remove-data" - type="checkbox" - checked={this.state.removeData} - onChange={linkEvent(this, this.handleModRemoveDataChange)} + type="text" + id="profile-ban-reason" + class="form-control mr-2" + placeholder={i18n.t("reason")} + value={toUndefined(this.state.banReason)} + onInput={linkEvent(this, this.handleModBanReasonChange)} /> - <label - class="form-check-label" - htmlFor="mod-ban-remove-data" - title={i18n.t("remove_content_more")} - > - {i18n.t("remove_content")} + <label class="col-form-label" htmlFor={`mod-ban-expires`}> + {i18n.t("expires")} </label> + <input + type="number" + id={`mod-ban-expires`} + class="form-control mr-2" + placeholder={i18n.t("number_of_days")} + value={toUndefined(this.state.banExpireDays)} + onInput={linkEvent(this, this.handleModBanExpireDaysChange)} + /> + <div class="form-group"> + <div class="form-check"> + <input + class="form-check-input" + id="mod-ban-remove-data" + type="checkbox" + checked={this.state.removeData} + onChange={linkEvent( + this, + this.handleModRemoveDataChange + )} + /> + <label + class="form-check-label" + htmlFor="mod-ban-remove-data" + title={i18n.t("remove_content_more")} + > + {i18n.t("remove_content")} + </label> + </div> + </div> </div> - </div> - </div> - {/* TODO hold off on expires until later */} - {/* <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.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */} - {/* </div> */} - <div class="form-group row"> - <button - type="cancel" - class="btn btn-secondary mr-2" - aria-label={i18n.t("cancel")} - onClick={linkEvent(this, this.handleModBanSubmitCancel)} - > - {i18n.t("cancel")} - </button> - <button - type="submit" - class="btn btn-secondary" - aria-label={i18n.t("ban")} - > - {i18n.t("ban")} {pv.person.name} - </button> - </div> - </form> - )} - </> - ); + {/* TODO hold off on expires until later */} + {/* <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.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */} + {/* </div> */} + <div class="form-group row"> + <button + type="cancel" + class="btn btn-secondary mr-2" + aria-label={i18n.t("cancel")} + onClick={linkEvent(this, this.handleModBanSubmitCancel)} + > + {i18n.t("cancel")} + </button> + <button + type="submit" + class="btn btn-secondary" + aria-label={i18n.t("ban")} + > + {i18n.t("ban")} {pv.person.name} + </button> + </div> + </form> + )} + </> + ), + none: <></>, + }); } + // TODO test this, make sure its good moderates() { - return ( - <div> - {this.state.personRes.moderates.length > 0 && ( - <div class="card border-secondary mb-3"> - <div class="card-body"> - <h5>{i18n.t("moderates")}</h5> - <ul class="list-unstyled mb-0"> - {this.state.personRes.moderates.map(cmv => ( - <li> - <CommunityLink community={cmv.community} /> - </li> - ))} - </ul> - </div> - </div> - )} - </div> - ); + return this.state.personRes + .map(r => r.moderates) + .match({ + some: moderates => { + if (moderates.length > 0) { + <div class="card border-secondary mb-3"> + <div class="card-body"> + <h5>{i18n.t("moderates")}</h5> + <ul class="list-unstyled mb-0"> + {moderates.map(cmv => ( + <li> + <CommunityLink community={cmv.community} /> + </li> + ))} + </ul> + </div> + </div>; + } + }, + none: void 0, + }); } follows() { - let follows = UserService.Instance.myUserInfo.follows; - return ( - <div> - {follows.length > 0 && ( - <div class="card border-secondary mb-3"> - <div class="card-body"> - <h5>{i18n.t("subscribed")}</h5> - <ul class="list-unstyled mb-0"> - {follows.map(cfv => ( - <li> - <CommunityLink community={cfv.community} /> - </li> - ))} - </ul> - </div> - </div> - )} - </div> - ); + return UserService.Instance.myUserInfo + .map(m => m.follows) + .match({ + some: follows => { + if (follows.length > 0) { + <div class="card border-secondary mb-3"> + <div class="card-body"> + <h5>{i18n.t("subscribed")}</h5> + <ul class="list-unstyled mb-0"> + {follows.map(cfv => ( + <li> + <CommunityLink community={cfv.community} /> + </li> + ))} + </ul> + </div> + </div>; + } + }, + none: void 0, + }); } updateUrl(paramUpdates: UrlParams) { @@ -649,29 +719,8 @@ export class Profile extends Component<any, ProfileState> { this.fetchUserData(); } - get canAdmin(): boolean { - return ( - this.state.siteRes?.admins && - canMod( - UserService.Instance.myUserInfo, - this.state.siteRes.admins.map(a => a.person.id), - this.state.personRes?.person_view.person.id - ) - ); - } - - get personIsAdmin(): boolean { - return ( - this.state.siteRes?.admins && - isMod( - this.state.siteRes.admins.map(a => a.person.id), - this.state.personRes?.person_view.person.id - ) - ); - } - handlePageChange(page: number) { - this.updateUrl({ page }); + this.updateUrl({ page: page }); } handleSortChange(val: SortType) { @@ -714,24 +763,30 @@ export class Profile extends Component<any, ProfileState> { handleModBanSubmit(i: Profile, event?: any) { if (event) event.preventDefault(); - let pv = i.state.personRes.person_view; - // If its an unban, restore all their data - let ban = !pv.person.banned; - if (ban == false) { - i.state.removeData = false; - } - let form: BanPerson = { - person_id: pv.person.id, - ban, - remove_data: i.state.removeData, - reason: i.state.banReason, - expires: futureDaysToUnixTime(i.state.banExpireDays), - auth: authField(), - }; - WebSocketService.Instance.send(wsClient.banPerson(form)); - - i.state.showBanDialog = false; - i.setState(i.state); + i.state.personRes + .map(r => r.person_view.person) + .match({ + some: person => { + // If its an unban, restore all their data + let ban = !person.banned; + if (ban == false) { + i.state.removeData = false; + } + let form = new BanPerson({ + person_id: person.id, + ban, + remove_data: Some(i.state.removeData), + reason: i.state.banReason, + expires: i.state.banExpireDays.map(futureDaysToUnixTime), + auth: auth().unwrap(), + }); + WebSocketService.Instance.send(wsClient.banPerson(form)); + + i.state.showBanDialog = false; + i.setState(i.state); + }, + none: void 0, + }); } parseMessage(msg: any) { @@ -749,41 +804,53 @@ export class Profile extends Component<any, ProfileState> { // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well // and set the parent state if it is not set or differs // TODO this might need to get abstracted - let data = wsJsonToRes<GetPersonDetailsResponse>(msg).data; - this.state.personRes = data; - console.log(data); + let data = wsJsonToRes<GetPersonDetailsResponse>( + msg, + GetPersonDetailsResponse + ); + this.state.personRes = Some(data); this.state.loading = false; this.setPersonBlock(); this.setState(this.state); restoreScrollPosition(this.context); } else if (op == UserOperation.AddAdmin) { - let data = wsJsonToRes<AddAdminResponse>(msg).data; + let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse); this.state.siteRes.admins = data.admins; this.setState(this.state); } else if (op == UserOperation.CreateCommentLike) { - let data = wsJsonToRes<CommentResponse>(msg).data; - createCommentLikeRes(data.comment_view, this.state.personRes.comments); + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + createCommentLikeRes( + data.comment_view, + this.state.personRes.map(r => r.comments).unwrapOr([]) + ); this.setState(this.state); } else if ( op == UserOperation.EditComment || op == UserOperation.DeleteComment || op == UserOperation.RemoveComment ) { - let data = wsJsonToRes<CommentResponse>(msg).data; - editCommentRes(data.comment_view, this.state.personRes.comments); + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + editCommentRes( + data.comment_view, + this.state.personRes.map(r => r.comments).unwrapOr([]) + ); this.setState(this.state); } else if (op == UserOperation.CreateComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; - if ( - UserService.Instance.myUserInfo && - data.comment_view.creator.id == - UserService.Instance.myUserInfo?.local_user_view.person.id - ) { - toast(i18n.t("reply_sent")); - } + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + UserService.Instance.myUserInfo.match({ + some: mui => { + if (data.comment_view.creator.id == mui.local_user_view.person.id) { + toast(i18n.t("reply_sent")); + } + }, + none: void 0, + }); } else if (op == UserOperation.SaveComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; - saveCommentRes(data.comment_view, this.state.personRes.comments); + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + saveCommentRes( + data.comment_view, + this.state.personRes.map(r => r.comments).unwrapOr([]) + ); this.setState(this.state); } else if ( op == UserOperation.EditPost || @@ -793,29 +860,40 @@ export class Profile extends Component<any, ProfileState> { op == UserOperation.StickyPost || op == UserOperation.SavePost ) { - let data = wsJsonToRes<PostResponse>(msg).data; - editPostFindRes(data.post_view, this.state.personRes.posts); + let data = wsJsonToRes<PostResponse>(msg, PostResponse); + editPostFindRes( + data.post_view, + this.state.personRes.map(r => r.posts).unwrapOr([]) + ); this.setState(this.state); } else if (op == UserOperation.CreatePostLike) { - let data = wsJsonToRes<PostResponse>(msg).data; - createPostLikeFindRes(data.post_view, this.state.personRes.posts); + let data = wsJsonToRes<PostResponse>(msg, PostResponse); + createPostLikeFindRes( + data.post_view, + this.state.personRes.map(r => r.posts).unwrapOr([]) + ); this.setState(this.state); } else if (op == UserOperation.BanPerson) { - let data = wsJsonToRes<BanPersonResponse>(msg).data; - this.state.personRes.comments - .filter(c => c.creator.id == data.person_view.person.id) - .forEach(c => (c.creator.banned = data.banned)); - this.state.personRes.posts - .filter(c => c.creator.id == data.person_view.person.id) - .forEach(c => (c.creator.banned = data.banned)); - let pv = this.state.personRes.person_view; - - if (pv.person.id == data.person_view.person.id) { - pv.person.banned = data.banned; - } - this.setState(this.state); + let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse); + this.state.personRes.match({ + some: res => { + res.comments + .filter(c => c.creator.id == data.person_view.person.id) + .forEach(c => (c.creator.banned = data.banned)); + res.posts + .filter(c => c.creator.id == data.person_view.person.id) + .forEach(c => (c.creator.banned = data.banned)); + let pv = res.person_view; + + if (pv.person.id == data.person_view.person.id) { + pv.person.banned = data.banned; + } + this.setState(this.state); + }, + none: void 0, + }); } else if (op == UserOperation.BlockPerson) { - let data = wsJsonToRes<BlockPersonResponse>(msg).data; + let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse); updatePersonBlock(data); this.setPersonBlock(); this.setState(this.state); diff --git a/src/shared/components/person/registration-applications.tsx b/src/shared/components/person/registration-applications.tsx index 9009f74..eec9031 100644 --- a/src/shared/components/person/registration-applications.tsx +++ b/src/shared/components/person/registration-applications.tsx @@ -1,18 +1,20 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { + GetSiteResponse, ListRegistrationApplications, ListRegistrationApplicationsResponse, RegistrationApplicationResponse, - RegistrationApplicationView, - SiteView, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, fetchLimit, isBrowser, setIsoData, @@ -20,9 +22,7 @@ import { toast, updateRegistrationApplicationRes, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; @@ -35,10 +35,10 @@ enum UnreadOrAll { } interface RegistrationApplicationsState { - applications: RegistrationApplicationView[]; - page: number; - site_view: SiteView; + listRegistrationApplicationsResponse: Option<ListRegistrationApplicationsResponse>; + siteRes: GetSiteResponse; unreadOrAll: UnreadOrAll; + page: number; loading: boolean; } @@ -46,13 +46,16 @@ export class RegistrationApplications extends Component< any, RegistrationApplicationsState > { - private isoData = setIsoData(this.context); + private isoData = setIsoData( + this.context, + ListRegistrationApplicationsResponse + ); private subscription: Subscription; private emptyState: RegistrationApplicationsState = { + listRegistrationApplicationsResponse: None, + siteRes: this.isoData.site_res, unreadOrAll: UnreadOrAll.Unread, - applications: [], page: 1, - site_view: this.isoData.site_res.site_view, loading: true, }; @@ -62,7 +65,7 @@ export class RegistrationApplications extends Component< this.state = this.emptyState; this.handlePageChange = this.handlePageChange.bind(this); - if (!UserService.Instance.myUserInfo && isBrowser()) { + if (UserService.Instance.myUserInfo.isNone() && isBrowser()) { toast(i18n.t("not_logged_in"), "danger"); this.context.router.history.push(`/login`); } @@ -72,8 +75,9 @@ export class RegistrationApplications extends Component< // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.applications = - this.isoData.routeData[0].registration_applications || []; // TODO test + this.state.listRegistrationApplicationsResponse = Some( + this.isoData.routeData[0] as ListRegistrationApplicationsResponse + ); this.state.loading = false; } else { this.refetch(); @@ -91,11 +95,17 @@ export class RegistrationApplications extends Component< } get documentTitle(): string { - return `@${ - UserService.Instance.myUserInfo.local_user_view.person.name - } ${i18n.t("registration_applications")} - ${ - this.state.site_view.site.name - }`; + return this.state.siteRes.site_view.match({ + some: siteView => + UserService.Instance.myUserInfo.match({ + some: mui => + `@${mui.local_user_view.person.name} ${i18n.t( + "registration_applications" + )} - ${siteView.site.name}`, + none: "", + }), + none: "", + }); } render() { @@ -111,6 +121,8 @@ export class RegistrationApplications extends Component< <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <h5 class="mb-2">{i18n.t("registration_applications")}</h5> {this.selects()} @@ -168,19 +180,22 @@ export class RegistrationApplications extends Component< } applicationList() { - return ( - <div> - {this.state.applications.map(ra => ( - <> - <hr /> - <RegistrationApplication - key={ra.registration_application.id} - application={ra} - /> - </> - ))} - </div> - ); + return this.state.listRegistrationApplicationsResponse.match({ + some: res => ( + <div> + {res.registration_applications.map(ra => ( + <> + <hr /> + <RegistrationApplication + key={ra.registration_application.id} + application={ra} + /> + </> + ))} + </div> + ), + none: <></>, + }); } handleUnreadOrAllChange(i: RegistrationApplications, event: any) { @@ -198,12 +213,12 @@ export class RegistrationApplications extends Component< static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let promises: Promise<any>[] = []; - let form: ListRegistrationApplications = { - unread_only: true, - page: 1, - limit: fetchLimit, - auth: req.auth, - }; + let form = new ListRegistrationApplications({ + unread_only: Some(true), + page: Some(1), + limit: Some(fetchLimit), + auth: req.auth.unwrap(), + }); promises.push(req.client.listRegistrationApplications(form)); return promises; @@ -211,12 +226,12 @@ export class RegistrationApplications extends Component< refetch() { let unread_only = this.state.unreadOrAll == UnreadOrAll.Unread; - let form: ListRegistrationApplications = { - unread_only: unread_only, - page: this.state.page, - limit: fetchLimit, - auth: authField(), - }; + let form = new ListRegistrationApplications({ + unread_only: Some(unread_only), + page: Some(this.state.page), + limit: Some(fetchLimit), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.listRegistrationApplications(form)); } @@ -229,16 +244,24 @@ export class RegistrationApplications extends Component< } else if (msg.reconnect) { this.refetch(); } else if (op == UserOperation.ListRegistrationApplications) { - let data = wsJsonToRes<ListRegistrationApplicationsResponse>(msg).data; - this.state.applications = data.registration_applications; + let data = wsJsonToRes<ListRegistrationApplicationsResponse>( + msg, + ListRegistrationApplicationsResponse + ); + this.state.listRegistrationApplicationsResponse = Some(data); this.state.loading = false; window.scrollTo(0, 0); this.setState(this.state); } else if (op == UserOperation.ApproveRegistrationApplication) { - let data = wsJsonToRes<RegistrationApplicationResponse>(msg).data; + let data = wsJsonToRes<RegistrationApplicationResponse>( + msg, + RegistrationApplicationResponse + ); updateRegistrationApplicationRes( data.registration_application, - this.state.applications + this.state.listRegistrationApplicationsResponse + .map(r => r.registration_applications) + .unwrapOr([]) ); let uacs = UserService.Instance.unreadApplicationCountSub; // Minor bug, where if the application switches from deny to approve, the count will still go down diff --git a/src/shared/components/person/reports.tsx b/src/shared/components/person/reports.tsx index 99edf96..f8a641b 100644 --- a/src/shared/components/person/reports.tsx +++ b/src/shared/components/person/reports.tsx @@ -1,22 +1,25 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { CommentReportResponse, CommentReportView, + GetSiteResponse, ListCommentReports, ListCommentReportsResponse, ListPostReports, ListPostReportsResponse, PostReportResponse, PostReportView, - SiteView, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, fetchLimit, isBrowser, setIsoData, @@ -25,9 +28,7 @@ import { updateCommentReportRes, updatePostReportRes, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { CommentReport } from "../comment/comment-report"; import { HtmlTags } from "../common/html-tags"; @@ -59,27 +60,31 @@ type ItemType = { }; interface ReportsState { + listCommentReportsResponse: Option<ListCommentReportsResponse>; + listPostReportsResponse: Option<ListPostReportsResponse>; unreadOrAll: UnreadOrAll; messageType: MessageType; - commentReports: CommentReportView[]; - postReports: PostReportView[]; combined: ItemType[]; + siteRes: GetSiteResponse; page: number; - site_view: SiteView; loading: boolean; } export class Reports extends Component<any, ReportsState> { - private isoData = setIsoData(this.context); + private isoData = setIsoData( + this.context, + ListCommentReportsResponse, + ListPostReportsResponse + ); private subscription: Subscription; private emptyState: ReportsState = { + listCommentReportsResponse: None, + listPostReportsResponse: None, unreadOrAll: UnreadOrAll.Unread, messageType: MessageType.All, - commentReports: [], - postReports: [], combined: [], page: 1, - site_view: this.isoData.site_res.site_view, + siteRes: this.isoData.site_res, loading: true, }; @@ -89,7 +94,7 @@ export class Reports extends Component<any, ReportsState> { this.state = this.emptyState; this.handlePageChange = this.handlePageChange.bind(this); - if (!UserService.Instance.myUserInfo && isBrowser()) { + if (UserService.Instance.myUserInfo.isNone() && isBrowser()) { toast(i18n.t("not_logged_in"), "danger"); this.context.router.history.push(`/login`); } @@ -99,9 +104,12 @@ export class Reports extends Component<any, ReportsState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.commentReports = - this.isoData.routeData[0].comment_reports || []; - this.state.postReports = this.isoData.routeData[1].post_reports || []; + this.state.listCommentReportsResponse = Some( + this.isoData.routeData[0] as ListCommentReportsResponse + ); + this.state.listPostReportsResponse = Some( + this.isoData.routeData[1] as ListPostReportsResponse + ); this.state.combined = this.buildCombined(); this.state.loading = false; } else { @@ -116,9 +124,17 @@ export class Reports extends Component<any, ReportsState> { } get documentTitle(): string { - return `@${ - UserService.Instance.myUserInfo.local_user_view.person.name - } ${i18n.t("reports")} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => + UserService.Instance.myUserInfo.match({ + some: mui => + `@${mui.local_user_view.person.name} ${i18n.t("reports")} - ${ + siteView.site.name + }`, + none: "", + }), + none: "", + }); } render() { @@ -134,6 +150,8 @@ export class Reports extends Component<any, ReportsState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <h5 class="mb-2">{i18n.t("reports")}</h5> {this.selects()} @@ -260,12 +278,14 @@ export class Reports extends Component<any, ReportsState> { } buildCombined(): ItemType[] { - let comments: ItemType[] = this.state.commentReports.map(r => - this.replyToReplyType(r) - ); - let posts: ItemType[] = this.state.postReports.map(r => - this.mentionToReplyType(r) - ); + let comments: ItemType[] = this.state.listCommentReportsResponse + .map(r => r.comment_reports) + .unwrapOr([]) + .map(r => this.replyToReplyType(r)); + let posts: ItemType[] = this.state.listPostReportsResponse + .map(r => r.post_reports) + .unwrapOr([]) + .map(r => this.mentionToReplyType(r)); return [...comments, ...posts].sort((a, b) => b.published.localeCompare(a.published) @@ -299,29 +319,35 @@ export class Reports extends Component<any, ReportsState> { } commentReports() { - return ( - <div> - {this.state.commentReports.map(cr => ( - <> - <hr /> - <CommentReport key={cr.comment_report.id} report={cr} /> - </> - ))} - </div> - ); + return this.state.listCommentReportsResponse.match({ + some: res => ( + <div> + {res.comment_reports.map(cr => ( + <> + <hr /> + <CommentReport key={cr.comment_report.id} report={cr} /> + </> + ))} + </div> + ), + none: <></>, + }); } postReports() { - return ( - <div> - {this.state.postReports.map(pr => ( - <> - <hr /> - <PostReport key={pr.post_report.id} report={pr} /> - </> - ))} - </div> - ); + return this.state.listPostReportsResponse.match({ + some: res => ( + <div> + {res.post_reports.map(pr => ( + <> + <hr /> + <PostReport key={pr.post_report.id} report={pr} /> + </> + ))} + </div> + ), + none: <></>, + }); } handlePageChange(page: number) { @@ -346,47 +372,61 @@ export class Reports extends Component<any, ReportsState> { static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let promises: Promise<any>[] = []; - let commentReportsForm: ListCommentReports = { + let unresolved_only = Some(true); + let page = Some(1); + let limit = Some(fetchLimit); + let community_id = None; + let auth = req.auth.unwrap(); + + let commentReportsForm = new ListCommentReports({ // TODO community_id - unresolved_only: true, - page: 1, - limit: fetchLimit, - auth: req.auth, - }; + unresolved_only, + community_id, + page, + limit, + auth, + }); promises.push(req.client.listCommentReports(commentReportsForm)); - let postReportsForm: ListPostReports = { + let postReportsForm = new ListPostReports({ // TODO community_id - unresolved_only: true, - page: 1, - limit: fetchLimit, - auth: req.auth, - }; + unresolved_only, + community_id, + page, + limit, + auth, + }); promises.push(req.client.listPostReports(postReportsForm)); return promises; } refetch() { - let unresolved_only = this.state.unreadOrAll == UnreadOrAll.Unread; - let commentReportsForm: ListCommentReports = { - // TODO community_id + let unresolved_only = Some(this.state.unreadOrAll == UnreadOrAll.Unread); + let community_id = None; + let page = Some(this.state.page); + let limit = Some(fetchLimit); + + let commentReportsForm = new ListCommentReports({ unresolved_only, - page: this.state.page, - limit: fetchLimit, - auth: authField(), - }; + // TODO community_id + community_id, + page, + limit, + auth: auth().unwrap(), + }); WebSocketService.Instance.send( wsClient.listCommentReports(commentReportsForm) ); - let postReportsForm: ListPostReports = { - // TODO community_id + let postReportsForm = new ListPostReports({ unresolved_only, - page: this.state.page, - limit: fetchLimit, - auth: authField(), - }; + // TODO community_id + community_id, + page, + limit, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.listPostReports(postReportsForm)); } @@ -399,8 +439,11 @@ export class Reports extends Component<any, ReportsState> { } else if (msg.reconnect) { this.refetch(); } else if (op == UserOperation.ListCommentReports) { - let data = wsJsonToRes<ListCommentReportsResponse>(msg).data; - this.state.commentReports = data.comment_reports; + let data = wsJsonToRes<ListCommentReportsResponse>( + msg, + ListCommentReportsResponse + ); + this.state.listCommentReportsResponse = Some(data); this.state.combined = this.buildCombined(); this.state.loading = false; // this.sendUnreadCount(); @@ -408,8 +451,11 @@ export class Reports extends Component<any, ReportsState> { this.setState(this.state); setupTippy(); } else if (op == UserOperation.ListPostReports) { - let data = wsJsonToRes<ListPostReportsResponse>(msg).data; - this.state.postReports = data.post_reports; + let data = wsJsonToRes<ListPostReportsResponse>( + msg, + ListPostReportsResponse + ); + this.state.listPostReportsResponse = Some(data); this.state.combined = this.buildCombined(); this.state.loading = false; // this.sendUnreadCount(); @@ -417,8 +463,11 @@ export class Reports extends Component<any, ReportsState> { this.setState(this.state); setupTippy(); } else if (op == UserOperation.ResolvePostReport) { - let data = wsJsonToRes<PostReportResponse>(msg).data; - updatePostReportRes(data.post_report_view, this.state.postReports); + let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse); + updatePostReportRes( + data.post_report_view, + this.state.listPostReportsResponse.map(r => r.post_reports).unwrapOr([]) + ); let urcs = UserService.Instance.unreadReportCountSub; if (data.post_report_view.post_report.resolved) { urcs.next(urcs.getValue() - 1); @@ -427,10 +476,12 @@ export class Reports extends Component<any, ReportsState> { } this.setState(this.state); } else if (op == UserOperation.ResolveCommentReport) { - let data = wsJsonToRes<CommentReportResponse>(msg).data; + let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse); updateCommentReportRes( data.comment_report_view, - this.state.commentReports + this.state.listCommentReportsResponse + .map(r => r.comment_reports) + .unwrapOr([]) ); let urcs = UserService.Instance.unreadReportCountSub; if (data.comment_report_view.comment_report.resolved) { diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx index 0884085..532d922 100644 --- a/src/shared/components/person/settings.tsx +++ b/src/shared/components/person/settings.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { BlockCommunity, @@ -15,19 +16,23 @@ import { PersonViewSafe, SaveUserSettings, SortType, + toUndefined, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n, languages } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, capitalizeFirstLetter, choicesConfig, communitySelectName, communityToChoice, debounce, elementUrl, + enableNsfw, fetchCommunities, fetchThemeList, fetchUsers, @@ -44,9 +49,7 @@ import { updateCommunityBlock, updatePersonBlock, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Icon, Spinner } from "../common/icon"; @@ -65,20 +68,19 @@ if (isBrowser()) { interface SettingsState { saveUserSettingsForm: SaveUserSettings; changePasswordForm: ChangePassword; - saveUserSettingsLoading: boolean; - changePasswordLoading: boolean; - deleteAccountLoading: boolean; - deleteAccountShowConfirm: boolean; deleteAccountForm: DeleteAccount; personBlocks: PersonBlockView[]; - blockPersonId: number; - blockPerson?: PersonViewSafe; + blockPerson: Option<PersonViewSafe>; communityBlocks: CommunityBlockView[]; blockCommunityId: number; blockCommunity?: CommunityView; currentTab: string; - siteRes: GetSiteResponse; themeList: string[]; + saveUserSettingsLoading: boolean; + changePasswordLoading: boolean; + deleteAccountLoading: boolean; + deleteAccountShowConfirm: boolean; + siteRes: GetSiteResponse; } export class Settings extends Component<any, SettingsState> { @@ -87,25 +89,43 @@ export class Settings extends Component<any, SettingsState> { private blockCommunityChoices: any; private subscription: Subscription; private emptyState: SettingsState = { - saveUserSettingsForm: { - auth: authField(false), - }, - changePasswordForm: { - new_password: null, - new_password_verify: null, - old_password: null, - auth: authField(false), - }, - saveUserSettingsLoading: null, + saveUserSettingsForm: new SaveUserSettings({ + show_nsfw: None, + show_scores: None, + show_avatars: None, + show_read_posts: None, + show_bot_accounts: None, + show_new_post_notifs: None, + default_sort_type: None, + default_listing_type: None, + theme: None, + lang: None, + avatar: None, + banner: None, + display_name: None, + email: None, + bio: None, + matrix_user_id: None, + send_notifications_to_email: None, + bot_account: None, + auth: undefined, + }), + changePasswordForm: new ChangePassword({ + new_password: undefined, + new_password_verify: undefined, + old_password: undefined, + auth: undefined, + }), + saveUserSettingsLoading: false, changePasswordLoading: false, - deleteAccountLoading: null, + deleteAccountLoading: false, deleteAccountShowConfirm: false, - deleteAccountForm: { - password: null, - auth: authField(false), - }, + deleteAccountForm: new DeleteAccount({ + password: undefined, + auth: undefined, + }), personBlocks: [], - blockPersonId: 0, + blockPerson: None, communityBlocks: [], blockCommunityId: 0, currentTab: "settings", @@ -154,7 +174,7 @@ export class Settings extends Component<any, SettingsState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} - description={this.documentTitle} + description={Some(this.documentTitle)} image={this.state.saveUserSettingsForm.avatar} /> <ul class="nav nav-tabs mb-2"> @@ -342,14 +362,17 @@ export class Settings extends Component<any, SettingsState> { <select class="form-control" id="block-person-filter" - value={this.state.blockPersonId} + value={this.state.blockPerson.map(p => p.person.id).unwrapOr(0)} > <option value="0">—</option> - {this.state.blockPerson && ( - <option value={this.state.blockPerson.person.id}> - {personSelectName(this.state.blockPerson)} - </option> - )} + {this.state.blockPerson.match({ + some: personView => ( + <option value={personView.person.id}> + {personSelectName(personView)} + </option> + ), + none: <></>, + })} </select> </div> </div> @@ -431,7 +454,9 @@ export class Settings extends Component<any, SettingsState> { type="text" class="form-control" placeholder={i18n.t("optional")} - value={this.state.saveUserSettingsForm.display_name} + value={toUndefined( + this.state.saveUserSettingsForm.display_name + )} onInput={linkEvent(this, this.handleDisplayNameChange)} pattern="^(?!@)(.+)$" minLength={3} @@ -446,7 +471,9 @@ export class Settings extends Component<any, SettingsState> { <MarkdownTextArea initialContent={this.state.saveUserSettingsForm.bio} onContentChange={this.handleBioChange} - maxLength={300} + maxLength={Some(300)} + placeholder={None} + buttonTitle={None} hideNavigationWarnings /> </div> @@ -461,7 +488,7 @@ export class Settings extends Component<any, SettingsState> { id="user-email" class="form-control" placeholder={i18n.t("optional")} - value={this.state.saveUserSettingsForm.email} + value={toUndefined(this.state.saveUserSettingsForm.email)} onInput={linkEvent(this, this.handleEmailChange)} minLength={3} /> @@ -479,7 +506,9 @@ export class Settings extends Component<any, SettingsState> { type="text" class="form-control" placeholder="@user:example.com" - value={this.state.saveUserSettingsForm.matrix_user_id} + value={toUndefined( + this.state.saveUserSettingsForm.matrix_user_id + )} onInput={linkEvent(this, this.handleMatrixUserIdChange)} pattern="^@[A-Za-z0-9._=-]+:[A-Za-z0-9.-]+\.[A-Za-z]{2,}$" /> @@ -515,7 +544,7 @@ export class Settings extends Component<any, SettingsState> { <div class="col-sm-9"> <select id="user-language" - value={this.state.saveUserSettingsForm.lang} + value={toUndefined(this.state.saveUserSettingsForm.lang)} onChange={linkEvent(this, this.handleLangChange)} class="custom-select w-auto" > @@ -541,7 +570,7 @@ export class Settings extends Component<any, SettingsState> { <div class="col-sm-9"> <select id="user-theme" - value={this.state.saveUserSettingsForm.theme} + value={toUndefined(this.state.saveUserSettingsForm.theme)} onChange={linkEvent(this, this.handleThemeChange)} class="custom-select w-auto" > @@ -561,7 +590,9 @@ export class Settings extends Component<any, SettingsState> { <ListingTypeSelect type_={ Object.values(ListingType)[ - this.state.saveUserSettingsForm.default_listing_type + this.state.saveUserSettingsForm.default_listing_type.unwrapOr( + 1 + ) ] } showLocal={showLocal(this.isoData)} @@ -576,21 +607,25 @@ export class Settings extends Component<any, SettingsState> { <SortSelect sort={ Object.values(SortType)[ - this.state.saveUserSettingsForm.default_sort_type + this.state.saveUserSettingsForm.default_sort_type.unwrapOr( + 0 + ) ] } onChange={this.handleSortTypeChange} /> </div> </form> - {this.state.siteRes.site_view.site.enable_nsfw && ( + {enableNsfw(this.state.siteRes) && ( <div class="form-group"> <div class="form-check"> <input class="form-check-input" id="user-show-nsfw" type="checkbox" - checked={this.state.saveUserSettingsForm.show_nsfw} + checked={toUndefined( + this.state.saveUserSettingsForm.show_nsfw + )} onChange={linkEvent(this, this.handleShowNsfwChange)} /> <label class="form-check-label" htmlFor="user-show-nsfw"> @@ -605,7 +640,9 @@ export class Settings extends Component<any, SettingsState> { class="form-check-input" id="user-show-scores" type="checkbox" - checked={this.state.saveUserSettingsForm.show_scores} + checked={toUndefined( + this.state.saveUserSettingsForm.show_scores + )} onChange={linkEvent(this, this.handleShowScoresChange)} /> <label class="form-check-label" htmlFor="user-show-scores"> @@ -619,7 +656,9 @@ export class Settings extends Component<any, SettingsState> { class="form-check-input" id="user-show-avatars" type="checkbox" - checked={this.state.saveUserSettingsForm.show_avatars} + checked={toUndefined( + this.state.saveUserSettingsForm.show_avatars + )} onChange={linkEvent(this, this.handleShowAvatarsChange)} /> <label class="form-check-label" htmlFor="user-show-avatars"> @@ -633,7 +672,9 @@ export class Settings extends Component<any, SettingsState> { class="form-check-input" id="user-bot-account" type="checkbox" - checked={this.state.saveUserSettingsForm.bot_account} + checked={toUndefined( + this.state.saveUserSettingsForm.bot_account + )} onChange={linkEvent(this, this.handleBotAccount)} /> <label class="form-check-label" htmlFor="user-bot-account"> @@ -647,7 +688,9 @@ export class Settings extends Component<any, SettingsState> { class="form-check-input" id="user-show-bot-accounts" type="checkbox" - checked={this.state.saveUserSettingsForm.show_bot_accounts} + checked={toUndefined( + this.state.saveUserSettingsForm.show_bot_accounts + )} onChange={linkEvent(this, this.handleShowBotAccounts)} /> <label class="form-check-label" htmlFor="user-show-bot-accounts"> @@ -661,7 +704,9 @@ export class Settings extends Component<any, SettingsState> { class="form-check-input" id="user-show-read-posts" type="checkbox" - checked={this.state.saveUserSettingsForm.show_read_posts} + checked={toUndefined( + this.state.saveUserSettingsForm.show_read_posts + )} onChange={linkEvent(this, this.handleReadPosts)} /> <label class="form-check-label" htmlFor="user-show-read-posts"> @@ -675,7 +720,9 @@ export class Settings extends Component<any, SettingsState> { class="form-check-input" id="user-show-new-post-notifs" type="checkbox" - checked={this.state.saveUserSettingsForm.show_new_post_notifs} + checked={toUndefined( + this.state.saveUserSettingsForm.show_new_post_notifs + )} onChange={linkEvent(this, this.handleShowNewPostNotifs)} /> <label @@ -693,9 +740,9 @@ export class Settings extends Component<any, SettingsState> { id="user-send-notifications-to-email" type="checkbox" disabled={!this.state.saveUserSettingsForm.email} - checked={ + checked={toUndefined( this.state.saveUserSettingsForm.send_notifications_to_email - } + )} onChange={linkEvent( this, this.handleSendNotificationsToEmailChange @@ -844,31 +891,31 @@ export class Settings extends Component<any, SettingsState> { handleBlockPerson(personId: number) { if (personId != 0) { - let blockUserForm: BlockPerson = { + let blockUserForm = new BlockPerson({ person_id: personId, block: true, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm)); } } handleUnblockPerson(i: { ctx: Settings; recipientId: number }) { - let blockUserForm: BlockPerson = { + let blockUserForm = new BlockPerson({ person_id: i.recipientId, block: false, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm)); } handleBlockCommunity(community_id: number) { if (community_id != 0) { - let blockCommunityForm: BlockCommunity = { + let blockCommunityForm = new BlockCommunity({ community_id, block: true, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send( wsClient.blockCommunity(blockCommunityForm) ); @@ -876,126 +923,133 @@ export class Settings extends Component<any, SettingsState> { } handleUnblockCommunity(i: { ctx: Settings; communityId: number }) { - let blockCommunityForm: BlockCommunity = { + let blockCommunityForm = new BlockCommunity({ community_id: i.communityId, block: false, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.blockCommunity(blockCommunityForm)); } handleShowNsfwChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.show_nsfw = event.target.checked; + i.state.saveUserSettingsForm.show_nsfw = Some(event.target.checked); i.setState(i.state); } handleShowAvatarsChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.show_avatars = event.target.checked; - UserService.Instance.myUserInfo.local_user_view.local_user.show_avatars = - event.target.checked; // Just for instant updates + i.state.saveUserSettingsForm.show_avatars = Some(event.target.checked); + UserService.Instance.myUserInfo.match({ + some: mui => + (mui.local_user_view.local_user.show_avatars = event.target.checked), + none: void 0, + }); i.setState(i.state); } handleBotAccount(i: Settings, event: any) { - i.state.saveUserSettingsForm.bot_account = event.target.checked; + i.state.saveUserSettingsForm.bot_account = Some(event.target.checked); i.setState(i.state); } handleShowBotAccounts(i: Settings, event: any) { - i.state.saveUserSettingsForm.show_bot_accounts = event.target.checked; + i.state.saveUserSettingsForm.show_bot_accounts = Some(event.target.checked); i.setState(i.state); } handleReadPosts(i: Settings, event: any) { - i.state.saveUserSettingsForm.show_read_posts = event.target.checked; + i.state.saveUserSettingsForm.show_read_posts = Some(event.target.checked); i.setState(i.state); } handleShowNewPostNotifs(i: Settings, event: any) { - i.state.saveUserSettingsForm.show_new_post_notifs = event.target.checked; + i.state.saveUserSettingsForm.show_new_post_notifs = Some( + event.target.checked + ); i.setState(i.state); } handleShowScoresChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.show_scores = event.target.checked; - UserService.Instance.myUserInfo.local_user_view.local_user.show_scores = - event.target.checked; // Just for instant updates + i.state.saveUserSettingsForm.show_scores = Some(event.target.checked); + UserService.Instance.myUserInfo.match({ + some: mui => + (mui.local_user_view.local_user.show_scores = event.target.checked), + none: void 0, + }); i.setState(i.state); } handleSendNotificationsToEmailChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.send_notifications_to_email = - event.target.checked; + i.state.saveUserSettingsForm.send_notifications_to_email = Some( + event.target.checked + ); i.setState(i.state); } handleThemeChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.theme = event.target.value; + i.state.saveUserSettingsForm.theme = Some(event.target.value); setTheme(event.target.value, true); i.setState(i.state); } handleLangChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.lang = event.target.value; - i18n.changeLanguage(getLanguages(i.state.saveUserSettingsForm.lang)[0]); + i.state.saveUserSettingsForm.lang = Some(event.target.value); + i18n.changeLanguage( + getLanguages(i.state.saveUserSettingsForm.lang.unwrap())[0] + ); i.setState(i.state); } handleSortTypeChange(val: SortType) { - this.state.saveUserSettingsForm.default_sort_type = - Object.keys(SortType).indexOf(val); + this.state.saveUserSettingsForm.default_sort_type = Some( + Object.keys(SortType).indexOf(val) + ); this.setState(this.state); } handleListingTypeChange(val: ListingType) { - this.state.saveUserSettingsForm.default_listing_type = - Object.keys(ListingType).indexOf(val); + this.state.saveUserSettingsForm.default_listing_type = Some( + Object.keys(ListingType).indexOf(val) + ); this.setState(this.state); } handleEmailChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.email = event.target.value; + i.state.saveUserSettingsForm.email = Some(event.target.value); i.setState(i.state); } handleBioChange(val: string) { - this.state.saveUserSettingsForm.bio = val; + this.state.saveUserSettingsForm.bio = Some(val); this.setState(this.state); } handleAvatarUpload(url: string) { - this.state.saveUserSettingsForm.avatar = url; + this.state.saveUserSettingsForm.avatar = Some(url); this.setState(this.state); } handleAvatarRemove() { - this.state.saveUserSettingsForm.avatar = ""; + this.state.saveUserSettingsForm.avatar = Some(""); this.setState(this.state); } handleBannerUpload(url: string) { - this.state.saveUserSettingsForm.banner = url; + this.state.saveUserSettingsForm.banner = Some(url); this.setState(this.state); } handleBannerRemove() { - this.state.saveUserSettingsForm.banner = ""; + this.state.saveUserSettingsForm.banner = Some(""); this.setState(this.state); } handleDisplayNameChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.display_name = event.target.value; + i.state.saveUserSettingsForm.display_name = Some(event.target.value); i.setState(i.state); } handleMatrixUserIdChange(i: Settings, event: any) { - i.state.saveUserSettingsForm.matrix_user_id = event.target.value; - if ( - i.state.saveUserSettingsForm.matrix_user_id == "" && - !UserService.Instance.myUserInfo.local_user_view.person.matrix_user_id - ) { - i.state.saveUserSettingsForm.matrix_user_id = undefined; - } + i.state.saveUserSettingsForm.matrix_user_id = Some(event.target.value); i.setState(i.state); } @@ -1026,6 +1080,7 @@ export class Settings extends Component<any, SettingsState> { handleSaveSettingsSubmit(i: Settings, event: any) { event.preventDefault(); i.state.saveUserSettingsLoading = true; + i.state.saveUserSettingsForm.auth = auth().unwrap(); i.setState(i.state); WebSocketService.Instance.send( @@ -1036,6 +1091,7 @@ export class Settings extends Component<any, SettingsState> { handleChangePasswordSubmit(i: Settings, event: any) { event.preventDefault(); i.state.changePasswordLoading = true; + i.state.changePasswordForm.auth = auth().unwrap(); i.setState(i.state); WebSocketService.Instance.send( @@ -1057,6 +1113,7 @@ export class Settings extends Component<any, SettingsState> { handleDeleteAccount(i: Settings, event: any) { event.preventDefault(); i.state.deleteAccountLoading = true; + i.state.deleteAccountForm.auth = auth().unwrap(); i.setState(i.state); WebSocketService.Instance.send( @@ -1074,36 +1131,55 @@ export class Settings extends Component<any, SettingsState> { } setUserInfo() { - let luv = UserService.Instance.myUserInfo.local_user_view; - this.state.saveUserSettingsForm.show_nsfw = luv.local_user.show_nsfw; - this.state.saveUserSettingsForm.theme = luv.local_user.theme - ? luv.local_user.theme - : "browser"; - this.state.saveUserSettingsForm.default_sort_type = - luv.local_user.default_sort_type; - this.state.saveUserSettingsForm.default_listing_type = - luv.local_user.default_listing_type; - this.state.saveUserSettingsForm.lang = luv.local_user.lang; - this.state.saveUserSettingsForm.avatar = luv.person.avatar; - this.state.saveUserSettingsForm.banner = luv.person.banner; - this.state.saveUserSettingsForm.display_name = luv.person.display_name; - this.state.saveUserSettingsForm.show_avatars = luv.local_user.show_avatars; - this.state.saveUserSettingsForm.bot_account = luv.person.bot_account; - this.state.saveUserSettingsForm.show_bot_accounts = - luv.local_user.show_bot_accounts; - this.state.saveUserSettingsForm.show_scores = luv.local_user.show_scores; - this.state.saveUserSettingsForm.show_read_posts = - luv.local_user.show_read_posts; - this.state.saveUserSettingsForm.show_new_post_notifs = - luv.local_user.show_new_post_notifs; - this.state.saveUserSettingsForm.email = luv.local_user.email; - this.state.saveUserSettingsForm.bio = luv.person.bio; - this.state.saveUserSettingsForm.send_notifications_to_email = - luv.local_user.send_notifications_to_email; - this.state.saveUserSettingsForm.matrix_user_id = luv.person.matrix_user_id; - this.state.personBlocks = UserService.Instance.myUserInfo.person_blocks; - this.state.communityBlocks = - UserService.Instance.myUserInfo.community_blocks; + UserService.Instance.myUserInfo.match({ + some: mui => { + let luv = mui.local_user_view; + this.state.saveUserSettingsForm.show_nsfw = Some( + luv.local_user.show_nsfw + ); + this.state.saveUserSettingsForm.theme = Some( + luv.local_user.theme ? luv.local_user.theme : "browser" + ); + this.state.saveUserSettingsForm.default_sort_type = Some( + luv.local_user.default_sort_type + ); + this.state.saveUserSettingsForm.default_listing_type = Some( + luv.local_user.default_listing_type + ); + this.state.saveUserSettingsForm.lang = Some(luv.local_user.lang); + this.state.saveUserSettingsForm.avatar = luv.person.avatar; + this.state.saveUserSettingsForm.banner = luv.person.banner; + this.state.saveUserSettingsForm.display_name = luv.person.display_name; + this.state.saveUserSettingsForm.show_avatars = Some( + luv.local_user.show_avatars + ); + this.state.saveUserSettingsForm.bot_account = Some( + luv.person.bot_account + ); + this.state.saveUserSettingsForm.show_bot_accounts = Some( + luv.local_user.show_bot_accounts + ); + this.state.saveUserSettingsForm.show_scores = Some( + luv.local_user.show_scores + ); + this.state.saveUserSettingsForm.show_read_posts = Some( + luv.local_user.show_read_posts + ); + this.state.saveUserSettingsForm.show_new_post_notifs = Some( + luv.local_user.show_new_post_notifs + ); + this.state.saveUserSettingsForm.email = luv.local_user.email; + this.state.saveUserSettingsForm.bio = luv.person.bio; + this.state.saveUserSettingsForm.send_notifications_to_email = Some( + luv.local_user.send_notifications_to_email + ); + this.state.saveUserSettingsForm.matrix_user_id = + luv.person.matrix_user_id; + this.state.personBlocks = mui.person_blocks; + this.state.communityBlocks = mui.community_blocks; + }, + none: void 0, + }); } parseMessage(msg: any) { @@ -1118,14 +1194,14 @@ export class Settings extends Component<any, SettingsState> { toast(i18n.t(msg.error), "danger"); return; } else if (op == UserOperation.SaveUserSettings) { - let data = wsJsonToRes<LoginResponse>(msg).data; + let data = wsJsonToRes<LoginResponse>(msg, LoginResponse); UserService.Instance.login(data); this.state.saveUserSettingsLoading = false; this.setState(this.state); - + toast(i18n.t("saved")); window.scrollTo(0, 0); } else if (op == UserOperation.ChangePassword) { - let data = wsJsonToRes<LoginResponse>(msg).data; + let data = wsJsonToRes<LoginResponse>(msg, LoginResponse); UserService.Instance.login(data); this.state.changePasswordLoading = false; this.setState(this.state); @@ -1138,13 +1214,21 @@ export class Settings extends Component<any, SettingsState> { }); UserService.Instance.logout(); window.location.href = "/"; - location.reload(); } else if (op == UserOperation.BlockPerson) { - let data = wsJsonToRes<BlockPersonResponse>(msg).data; - this.setState({ personBlocks: updatePersonBlock(data) }); + let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse); + updatePersonBlock(data).match({ + some: blocks => this.setState({ personBlocks: blocks }), + none: void 0, + }); } else if (op == UserOperation.BlockCommunity) { - let data = wsJsonToRes<BlockCommunityResponse>(msg).data; - this.setState({ communityBlocks: updateCommunityBlock(data) }); + let data = wsJsonToRes<BlockCommunityResponse>( + msg, + BlockCommunityResponse + ); + updateCommunityBlock(data).match({ + some: blocks => this.setState({ communityBlocks: blocks }), + none: void 0, + }); } } } diff --git a/src/shared/components/person/verify-email.tsx b/src/shared/components/person/verify-email.tsx index d27a8bb..fed026f 100644 --- a/src/shared/components/person/verify-email.tsx +++ b/src/shared/components/person/verify-email.tsx @@ -1,9 +1,12 @@ +import { None } from "@sniptt/monads/build"; import { Component } from "inferno"; import { - SiteView, + GetSiteResponse, UserOperation, VerifyEmail as VerifyEmailForm, VerifyEmailResponse, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; @@ -13,15 +16,13 @@ import { setIsoData, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; interface State { verifyEmailForm: VerifyEmailForm; - site_view: SiteView; + siteRes: GetSiteResponse; } export class VerifyEmail extends Component<any, State> { @@ -29,10 +30,10 @@ export class VerifyEmail extends Component<any, State> { private subscription: Subscription; emptyState: State = { - verifyEmailForm: { + verifyEmailForm: new VerifyEmailForm({ token: this.props.match.params.token, - }, - site_view: this.isoData.site_res.site_view, + }), + siteRes: this.isoData.site_res, }; constructor(props: any, context: any) { @@ -57,7 +58,10 @@ export class VerifyEmail extends Component<any, State> { } get documentTitle(): string { - return `${i18n.t("verify_email")} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `${i18n.t("verify_email")} - ${siteView.site.name}`, + none: "", + }); } render() { @@ -66,6 +70,8 @@ export class VerifyEmail extends Component<any, State> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <div class="row"> <div class="col-12 col-lg-6 offset-lg-3 mb-4"> @@ -85,7 +91,7 @@ export class VerifyEmail extends Component<any, State> { this.props.history.push("/"); return; } else if (op == UserOperation.VerifyEmail) { - let data = wsJsonToRes<VerifyEmailResponse>(msg).data; + let data = wsJsonToRes<VerifyEmailResponse>(msg, VerifyEmailResponse); if (data) { toast(i18n.t("email_verified")); this.state = this.emptyState; diff --git a/src/shared/components/post/create-post.tsx b/src/shared/components/post/create-post.tsx index b5c95c4..68d546e 100644 --- a/src/shared/components/post/create-post.tsx +++ b/src/shared/components/post/create-post.tsx @@ -1,48 +1,50 @@ +import { Either, Left, None, Option, Right, Some } from "@sniptt/monads"; import { Component } from "inferno"; import { - CommunityView, GetCommunity, GetCommunityResponse, + GetSiteResponse, ListCommunities, ListCommunitiesResponse, ListingType, PostView, - SiteView, SortType, + toOption, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { InitialFetchRequest, PostFormParams } from "shared/interfaces"; import { i18n } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, + enableDownvotes, + enableNsfw, fetchLimit, isBrowser, setIsoData, - setOptionalAuth, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; import { PostForm } from "./post-form"; interface CreatePostState { - site_view: SiteView; - communities: CommunityView[]; + listCommunitiesResponse: Option<ListCommunitiesResponse>; + siteRes: GetSiteResponse; loading: boolean; } export class CreatePost extends Component<any, CreatePostState> { - private isoData = setIsoData(this.context); + private isoData = setIsoData(this.context, ListCommunitiesResponse); private subscription: Subscription; private emptyState: CreatePostState = { - site_view: this.isoData.site_res.site_view, - communities: [], + siteRes: this.isoData.site_res, + listCommunitiesResponse: None, loading: true, }; @@ -51,7 +53,7 @@ export class CreatePost extends Component<any, CreatePostState> { this.handlePostCreate = this.handlePostCreate.bind(this); this.state = this.emptyState; - if (!UserService.Instance.myUserInfo && isBrowser()) { + if (UserService.Instance.myUserInfo.isNone() && isBrowser()) { toast(i18n.t("not_logged_in"), "danger"); this.context.router.history.push(`/login`); } @@ -61,7 +63,9 @@ export class CreatePost extends Component<any, CreatePostState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.communities = this.isoData.routeData[0].communities; + this.state.listCommunitiesResponse = Some( + this.isoData.routeData[0] as ListCommunitiesResponse + ); this.state.loading = false; } else { this.refetch(); @@ -69,27 +73,39 @@ export class CreatePost extends Component<any, CreatePostState> { } refetch() { - if (this.params.community_id) { - let form: GetCommunity = { - id: this.params.community_id, - }; - WebSocketService.Instance.send(wsClient.getCommunity(form)); - } else if (this.params.community_name) { - let form: GetCommunity = { - name: this.params.community_name, - }; - WebSocketService.Instance.send(wsClient.getCommunity(form)); - } else { - let listCommunitiesForm: ListCommunities = { - type_: ListingType.All, - sort: SortType.TopAll, - limit: fetchLimit, - auth: authField(false), - }; - WebSocketService.Instance.send( - wsClient.listCommunities(listCommunitiesForm) - ); - } + this.params.nameOrId.match({ + some: opt => + opt.match({ + left: name => { + let form = new GetCommunity({ + name: Some(name), + id: None, + auth: auth(false).ok(), + }); + WebSocketService.Instance.send(wsClient.getCommunity(form)); + }, + right: id => { + let form = new GetCommunity({ + id: Some(id), + name: None, + auth: auth(false).ok(), + }); + WebSocketService.Instance.send(wsClient.getCommunity(form)); + }, + }), + none: () => { + let listCommunitiesForm = new ListCommunities({ + type_: Some(ListingType.All), + sort: Some(SortType.TopAll), + limit: Some(fetchLimit), + page: None, + auth: auth(false).ok(), + }); + WebSocketService.Instance.send( + wsClient.listCommunities(listCommunitiesForm) + ); + }, + }); } componentWillUnmount() { @@ -99,7 +115,10 @@ export class CreatePost extends Component<any, CreatePostState> { } get documentTitle(): string { - return `${i18n.t("create_post")} - ${this.state.site_view.site.name}`; + return this.state.siteRes.site_view.match({ + some: siteView => `${i18n.t("create_post")} - ${siteView.site.name}`, + none: "", + }); } render() { @@ -108,24 +127,32 @@ export class CreatePost extends Component<any, CreatePostState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> {this.state.loading ? ( <h5> <Spinner large /> </h5> ) : ( - <div class="row"> - <div class="col-12 col-lg-6 offset-lg-3 mb-4"> - <h5>{i18n.t("create_post")}</h5> - <PostForm - communities={this.state.communities} - onCreate={this.handlePostCreate} - params={this.params} - enableDownvotes={this.state.site_view.site.enable_downvotes} - enableNsfw={this.state.site_view.site.enable_nsfw} - /> - </div> - </div> + this.state.listCommunitiesResponse.match({ + some: res => ( + <div class="row"> + <div class="col-12 col-lg-6 offset-lg-3 mb-4"> + <h5>{i18n.t("create_post")}</h5> + <PostForm + post_view={None} + communities={Some(res.communities)} + onCreate={this.handlePostCreate} + params={Some(this.params)} + enableDownvotes={enableDownvotes(this.state.siteRes)} + enableNsfw={enableNsfw(this.state.siteRes)} + /> + </div> + </div> + ), + none: <></>, + }) )} </div> ); @@ -133,36 +160,48 @@ export class CreatePost extends Component<any, CreatePostState> { get params(): PostFormParams { let urlParams = new URLSearchParams(this.props.location.search); + let name = toOption(urlParams.get("community_name")).or( + this.prevCommunityName + ); + let id = toOption(urlParams.get("community_id")) + .map(Number) + .or(this.prevCommunityId); + let nameOrId: Option<Either<string, number>>; + if (name.isSome()) { + nameOrId = Some(Left(name.unwrap())); + } else if (id.isSome()) { + nameOrId = Some(Right(id.unwrap())); + } else { + nameOrId = None; + } + let params: PostFormParams = { - name: urlParams.get("title"), - community_name: urlParams.get("community_name") || this.prevCommunityName, - community_id: urlParams.get("community_id") - ? Number(urlParams.get("community_id")) || this.prevCommunityId - : null, - body: urlParams.get("body"), - url: urlParams.get("url"), + name: toOption(urlParams.get("title")), + nameOrId, + body: toOption(urlParams.get("body")), + url: toOption(urlParams.get("url")), }; return params; } - get prevCommunityName(): string { + get prevCommunityName(): Option<string> { if (this.props.match.params.name) { - return this.props.match.params.name; + return toOption(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 toOption(lastLocation.split("/c/")[1]); } } - return null; + return None; } - get prevCommunityId(): number { + get prevCommunityId(): Option<number> { if (this.props.match.params.id) { - return this.props.match.params.id; + return toOption(this.props.match.params.id); } - return null; + return None; } handlePostCreate(post_view: PostView) { @@ -170,12 +209,13 @@ export class CreatePost extends Component<any, CreatePostState> { } static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { - let listCommunitiesForm: ListCommunities = { - type_: ListingType.All, - sort: SortType.TopAll, - limit: fetchLimit, - }; - setOptionalAuth(listCommunitiesForm, req.auth); + let listCommunitiesForm = new ListCommunities({ + type_: Some(ListingType.All), + sort: Some(SortType.TopAll), + limit: Some(fetchLimit), + page: None, + auth: req.auth, + }); return [req.client.listCommunities(listCommunitiesForm)]; } @@ -186,13 +226,18 @@ export class CreatePost extends Component<any, CreatePostState> { toast(i18n.t(msg.error), "danger"); return; } else if (op == UserOperation.ListCommunities) { - let data = wsJsonToRes<ListCommunitiesResponse>(msg).data; - this.state.communities = data.communities; + let data = wsJsonToRes<ListCommunitiesResponse>( + msg, + ListCommunitiesResponse + ); + this.state.listCommunitiesResponse = Some(data); this.state.loading = false; this.setState(this.state); } else if (op == UserOperation.GetCommunity) { - let data = wsJsonToRes<GetCommunityResponse>(msg).data; - this.state.communities = [data.community_view]; + let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse); + this.state.listCommunitiesResponse = Some({ + communities: [data.community_view], + }); this.state.loading = false; this.setState(this.state); } diff --git a/src/shared/components/post/metadata-card.tsx b/src/shared/components/post/metadata-card.tsx index 8c27d2f..116cdc9 100644 --- a/src/shared/components/post/metadata-card.tsx +++ b/src/shared/components/post/metadata-card.tsx @@ -29,56 +29,71 @@ export class MetadataCard extends Component< let post = this.props.post; return ( <> - {post.embed_title && !this.state.expanded && ( - <div class="card border-secondary mt-3 mb-2"> - <div class="row"> - <div class="col-12"> - <div class="card-body"> - {post.name !== post.embed_title && [ - <h5 class="card-title d-inline"> - <a class="text-body" href={post.url} rel={relTags}> - {post.embed_title} - </a> - </h5>, - <span class="d-inline-block ml-2 mb-2 small text-muted"> - <a - class="text-muted font-italic" - href={post.url} - rel={relTags} - > - {new URL(post.url).hostname} - <Icon icon="external-link" classes="ml-1" /> - </a> - </span>, - ]} - {post.embed_description && ( - <div - className="card-text small text-muted md-div" - dangerouslySetInnerHTML={{ - __html: post.embed_description, - }} - /> - )} - {post.embed_html && ( - <button - class="mt-2 btn btn-secondary text-monospace" - onClick={linkEvent(this, this.handleIframeExpand)} - data-tippy-content={i18n.t("expand_here")} - > - {this.state.expanded ? "-" : "+"} - </button> - )} - </div> - </div> - </div> - </div> - )} - {this.state.expanded && ( - <div - class="mt-3 mb-2" - dangerouslySetInnerHTML={{ __html: post.embed_html }} - /> - )} + {!this.state.expanded && + post.embed_title.match({ + some: embedTitle => + post.url.match({ + some: url => ( + <div class="card border-secondary mt-3 mb-2"> + <div class="row"> + <div class="col-12"> + <div class="card-body"> + {post.name !== embedTitle && [ + <h5 class="card-title d-inline"> + <a class="text-body" href={url} rel={relTags}> + {embedTitle} + </a> + </h5>, + <span class="d-inline-block ml-2 mb-2 small text-muted"> + <a + class="text-muted font-italic" + href={url} + rel={relTags} + > + {new URL(url).hostname} + <Icon icon="external-link" classes="ml-1" /> + </a> + </span>, + ]} + {post.embed_description.match({ + some: desc => ( + <div + className="card-text small text-muted md-div" + dangerouslySetInnerHTML={{ + __html: desc, + }} + /> + ), + none: <></>, + })} + {post.embed_html.isSome() && ( + <button + class="mt-2 btn btn-secondary text-monospace" + onClick={linkEvent(this, this.handleIframeExpand)} + data-tippy-content={i18n.t("expand_here")} + > + {this.state.expanded ? "-" : "+"} + </button> + )} + </div> + </div> + </div> + </div> + ), + none: <></>, + }), + none: <></>, + })} + {this.state.expanded && + post.embed_html.match({ + some: html => ( + <div + class="mt-3 mb-2" + dangerouslySetInnerHTML={{ __html: html }} + /> + ), + none: <></>, + })} </> ); } diff --git a/src/shared/components/post/post-form.tsx b/src/shared/components/post/post-form.tsx index de0b765..1bb7840 100644 --- a/src/shared/components/post/post-form.tsx +++ b/src/shared/components/post/post-form.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import autosize from "autosize"; import { Component, linkEvent } from "inferno"; import { Prompt } from "inferno-router"; @@ -12,7 +13,10 @@ import { SearchResponse, SearchType, SortType, + toUndefined, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { pictrsUri } from "../../env"; @@ -21,7 +25,7 @@ import { PostFormParams } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { archiveTodayUrl, - authField, + auth, capitalizeFirstLetter, choicesConfig, communitySelectName, @@ -36,13 +40,12 @@ import { relTags, setupTippy, toast, + trendingFetchLimit, validTitle, validURL, webArchiveUrl, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { Icon, Spinner } from "../common/icon"; import { MarkdownTextArea } from "../common/markdown-textarea"; @@ -56,42 +59,45 @@ if (isBrowser()) { const MAX_POST_TITLE_LENGTH = 200; interface PostFormProps { - post_view?: PostView; // If a post is given, that means this is an edit - communities?: CommunityView[]; - params?: PostFormParams; + post_view: Option<PostView>; // If a post is given, that means this is an edit + communities: Option<CommunityView[]>; + params: Option<PostFormParams>; onCancel?(): any; onCreate?(post: PostView): any; onEdit?(post: PostView): any; - enableNsfw: boolean; - enableDownvotes: boolean; + enableNsfw?: boolean; + enableDownvotes?: boolean; } interface PostFormState { postForm: CreatePost; + suggestedTitle: Option<string>; + suggestedPosts: Option<PostView[]>; + crossPosts: Option<PostView[]>; loading: boolean; imageLoading: boolean; previewMode: boolean; - suggestedTitle: string; - suggestedPosts: PostView[]; - crossPosts: PostView[]; } export class PostForm extends Component<PostFormProps, PostFormState> { private subscription: Subscription; private choices: any; private emptyState: PostFormState = { - postForm: { - community_id: null, - name: null, - nsfw: false, - auth: authField(false), - }, + postForm: new CreatePost({ + community_id: undefined, + name: undefined, + nsfw: Some(false), + url: None, + body: None, + honeypot: None, + auth: undefined, + }), loading: false, imageLoading: false, previewMode: false, - suggestedTitle: undefined, - suggestedPosts: [], - crossPosts: [], + suggestedTitle: None, + suggestedPosts: None, + crossPosts: None, }; constructor(props: any, context: any) { @@ -103,26 +109,28 @@ export class PostForm extends Component<PostFormProps, PostFormState> { this.state = this.emptyState; // Means its an edit - if (this.props.post_view) { - this.state.postForm = { - body: this.props.post_view.post.body, - name: this.props.post_view.post.name, - community_id: this.props.post_view.community.id, - url: this.props.post_view.post.url, - nsfw: this.props.post_view.post.nsfw, - auth: authField(), - }; - } - - 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.props.post_view.match({ + some: pv => + (this.state.postForm = new CreatePost({ + body: pv.post.body, + name: pv.post.name, + community_id: pv.community.id, + url: pv.post.url, + nsfw: Some(pv.post.nsfw), + honeypot: None, + auth: auth().unwrap(), + })), + none: void 0, + }); + + this.props.params.match({ + some: params => { + this.state.postForm.name = toUndefined(params.name); + this.state.postForm.url = params.url; + this.state.postForm.body = params.body; + }, + none: void 0, + }); this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); @@ -141,8 +149,8 @@ export class PostForm extends Component<PostFormProps, PostFormState> { if ( !this.state.loading && (this.state.postForm.name || - this.state.postForm.url || - this.state.postForm.body) + this.state.postForm.url.isSome() || + this.state.postForm.body.isSome()) ) { window.onbeforeunload = () => true; } else { @@ -163,8 +171,8 @@ export class PostForm extends Component<PostFormProps, PostFormState> { when={ !this.state.loading && (this.state.postForm.name || - this.state.postForm.url || - this.state.postForm.body) + this.state.postForm.url.isSome() || + this.state.postForm.body.isSome()) } message={i18n.t("block_leaving")} /> @@ -178,26 +186,29 @@ export class PostForm extends Component<PostFormProps, PostFormState> { type="url" id="post-url" class="form-control" - value={this.state.postForm.url} + value={toUndefined(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" - role="button" - onClick={linkEvent(this, this.copySuggestedTitle)} - > - {i18n.t("copy_suggested_title", { - title: this.state.suggestedTitle, - })} - </div> - )} + {this.state.suggestedTitle.match({ + some: title => ( + <div + class="mt-1 text-muted small font-weight-bold pointer" + role="button" + onClick={linkEvent(this, this.copySuggestedTitle)} + > + {i18n.t("copy_suggested_title", { + title, + })} + </div> + ), + none: <></>, + })} <form> <label htmlFor="file-upload" className={`${ - UserService.Instance.myUserInfo && "pointer" + UserService.Instance.myUserInfo.isSome() && "pointer" } d-inline-block float-right text-muted font-weight-bold`} data-tippy-content={i18n.t("upload_image")} > @@ -209,58 +220,68 @@ export class PostForm extends Component<PostFormProps, PostFormState> { accept="image/*,video/*" name="file" class="d-none" - disabled={!UserService.Instance.myUserInfo} + disabled={UserService.Instance.myUserInfo.isNone()} onChange={linkEvent(this, this.handleImageUpload)} /> </form> - {this.state.postForm.url && validURL(this.state.postForm.url) && ( - <div> - <a - href={`${webArchiveUrl}/save/${encodeURIComponent( - this.state.postForm.url - )}`} - class="mr-2 d-inline-block float-right text-muted small font-weight-bold" - rel={relTags} - > - archive.org {i18n.t("archive_link")} - </a> - <a - href={`${ghostArchiveUrl}/search?term=${encodeURIComponent( - this.state.postForm.url - )}`} - class="mr-2 d-inline-block float-right text-muted small font-weight-bold" - rel={relTags} - > - ghostarchive.org {i18n.t("archive_link")} - </a> - <a - href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent( - this.state.postForm.url - )}`} - class="mr-2 d-inline-block float-right text-muted small font-weight-bold" - rel={relTags} - > - archive.today {i18n.t("archive_link")} - </a> - </div> - )} + {this.state.postForm.url.match({ + some: url => + validURL(url) && ( + <div> + <a + href={`${webArchiveUrl}/save/${encodeURIComponent( + url + )}`} + class="mr-2 d-inline-block float-right text-muted small font-weight-bold" + rel={relTags} + > + archive.org {i18n.t("archive_link")} + </a> + <a + href={`${ghostArchiveUrl}/search?term=${encodeURIComponent( + url + )}`} + class="mr-2 d-inline-block float-right text-muted small font-weight-bold" + rel={relTags} + > + ghostarchive.org {i18n.t("archive_link")} + </a> + <a + href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent( + url + )}`} + class="mr-2 d-inline-block float-right text-muted small font-weight-bold" + rel={relTags} + > + archive.today {i18n.t("archive_link")} + </a> + </div> + ), + none: <></>, + })} {this.state.imageLoading && <Spinner />} - {isImage(this.state.postForm.url) && ( - <img src={this.state.postForm.url} class="img-fluid" alt="" /> - )} - {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} - /> - </> - )} + {this.state.postForm.url.match({ + some: url => + isImage(url) && <img src={url} class="img-fluid" alt="" />, + none: <></>, + })} + {this.state.crossPosts.match({ + some: xPosts => + xPosts.length > 0 && ( + <> + <div class="my-1 text-muted small font-weight-bold"> + {i18n.t("cross_posts")} + </div> + <PostListings + showCommunity + posts={xPosts} + enableDownvotes={this.props.enableDownvotes} + enableNsfw={this.props.enableNsfw} + /> + </> + ), + none: <></>, + })} </div> </div> <div class="form-group row"> @@ -285,18 +306,22 @@ export class PostForm extends Component<PostFormProps, PostFormState> { {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} - /> - </> - )} + {this.state.suggestedPosts.match({ + some: sPosts => + sPosts.length > 0 && ( + <> + <div class="my-1 text-muted small font-weight-bold"> + {i18n.t("related_posts")} + </div> + <PostListings + posts={sPosts} + enableDownvotes={this.props.enableDownvotes} + enableNsfw={this.props.enableNsfw} + /> + </> + ), + none: <></>, + })} </div> </div> @@ -306,10 +331,13 @@ export class PostForm extends Component<PostFormProps, PostFormState> { <MarkdownTextArea initialContent={this.state.postForm.body} onContentChange={this.handlePostBodyChange} + placeholder={None} + buttonTitle={None} + maxLength={None} /> </div> </div> - {!this.props.post_view && ( + {this.props.post_view.isNone() && ( <div class="form-group row"> <label class="col-sm-2 col-form-label" htmlFor="post-community"> {i18n.t("community")} @@ -322,7 +350,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> { onInput={linkEvent(this, this.handlePostCommunityChange)} > <option>{i18n.t("select_a_community")}</option> - {this.props.communities.map(cv => ( + {this.props.communities.unwrapOr([]).map(cv => ( <option value={cv.community.id}> {communitySelectName(cv)} </option> @@ -342,7 +370,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> { class="form-check-input position-static" id="post-nsfw" type="checkbox" - checked={this.state.postForm.nsfw} + checked={toUndefined(this.state.postForm.nsfw)} onChange={linkEvent(this, this.handlePostNsfwChange)} /> </div> @@ -356,7 +384,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> { type="text" class="form-control honeypot" id="register-honey" - value={this.state.postForm.honeypot} + value={toUndefined(this.state.postForm.honeypot)} onInput={linkEvent(this, this.handleHoneyPotChange)} /> <div class="form-group row"> @@ -370,13 +398,13 @@ export class PostForm extends Component<PostFormProps, PostFormState> { > {this.state.loading ? ( <Spinner /> - ) : this.props.post_view ? ( + ) : this.props.post_view.isSome() ? ( capitalizeFirstLetter(i18n.t("save")) ) : ( capitalizeFirstLetter(i18n.t("create")) )} </button> - {this.props.post_view && ( + {this.props.post_view.isSome() && ( <button type="button" class="btn btn-secondary" @@ -396,65 +424,87 @@ export class PostForm extends Component<PostFormProps, PostFormState> { event.preventDefault(); // Coerce empty url string to undefined - if (i.state.postForm.url !== undefined && i.state.postForm.url === "") { - i.state.postForm.url = undefined; + if ( + i.state.postForm.url.isSome() && + i.state.postForm.url.unwrapOr("blank") === "" + ) { + i.state.postForm.url = None; } - if (i.props.post_view) { - let form: EditPost = { - ...i.state.postForm, - post_id: i.props.post_view.post.id, - }; - WebSocketService.Instance.send(wsClient.editPost(form)); - } else { - WebSocketService.Instance.send(wsClient.createPost(i.state.postForm)); - } + let pForm = i.state.postForm; + i.props.post_view.match({ + some: pv => { + let form = new EditPost({ + name: Some(pForm.name), + url: pForm.url, + body: pForm.body, + nsfw: pForm.nsfw, + post_id: pv.post.id, + auth: auth().unwrap(), + }); + WebSocketService.Instance.send(wsClient.editPost(form)); + }, + none: () => { + i.state.postForm.auth = auth().unwrap(); + WebSocketService.Instance.send(wsClient.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; - setTimeout(() => { - let textarea: any = document.getElementById("post-title"); - autosize.update(textarea); - }, 10); - i.setState(i.state); + i.state.suggestedTitle.match({ + some: sTitle => { + i.state.postForm.name = sTitle.substring(0, MAX_POST_TITLE_LENGTH); + i.state.suggestedTitle = None; + setTimeout(() => { + let textarea: any = document.getElementById("post-title"); + autosize.update(textarea); + }, 10); + i.setState(i.state); + }, + none: void 0, + }); } handlePostUrlChange(i: PostForm, event: any) { - i.state.postForm.url = event.target.value; + i.state.postForm.url = Some(event.target.value); i.setState(i.state); i.fetchPageTitle(); } fetchPageTitle() { - if (validURL(this.state.postForm.url)) { - let form: Search = { - q: this.state.postForm.url, - type_: SearchType.Url, - sort: SortType.TopAll, - listing_type: ListingType.All, - page: 1, - limit: 6, - auth: authField(false), - }; - - WebSocketService.Instance.send(wsClient.search(form)); - - // Fetch the page title - getSiteMetadata(this.state.postForm.url).then(d => { - this.state.suggestedTitle = d.metadata.title; - this.setState(this.state); - }); - } else { - this.state.suggestedTitle = undefined; - this.state.crossPosts = []; - } + this.state.postForm.url.match({ + some: url => { + if (validURL(url)) { + let form = new Search({ + q: url, + community_id: None, + community_name: None, + creator_id: None, + type_: Some(SearchType.Url), + sort: Some(SortType.TopAll), + listing_type: Some(ListingType.All), + page: Some(1), + limit: Some(trendingFetchLimit), + auth: auth(false).ok(), + }); + + WebSocketService.Instance.send(wsClient.search(form)); + + // Fetch the page title + getSiteMetadata(url).then(d => { + this.state.suggestedTitle = d.metadata.title; + this.setState(this.state); + }); + } else { + this.state.suggestedTitle = None; + this.state.crossPosts = None; + } + }, + none: void 0, + }); } handlePostNameChange(i: PostForm, event: any) { @@ -464,28 +514,30 @@ export class PostForm extends Component<PostFormProps, PostFormState> { } fetchSimilarPosts() { - let form: Search = { + let form = new Search({ q: this.state.postForm.name, - type_: SearchType.Posts, - sort: SortType.TopAll, - listing_type: ListingType.All, - community_id: this.state.postForm.community_id, - page: 1, - limit: 6, - auth: authField(false), - }; + type_: Some(SearchType.Posts), + sort: Some(SortType.TopAll), + listing_type: Some(ListingType.All), + community_id: Some(this.state.postForm.community_id), + community_name: None, + creator_id: None, + page: Some(1), + limit: Some(trendingFetchLimit), + auth: auth(false).ok(), + }); if (this.state.postForm.name !== "") { WebSocketService.Instance.send(wsClient.search(form)); } else { - this.state.suggestedPosts = []; + this.state.suggestedPosts = None; } this.setState(this.state); } handlePostBodyChange(val: string) { - this.state.postForm.body = val; + this.state.postForm.body = Some(val); this.setState(this.state); } @@ -495,12 +547,12 @@ export class PostForm extends Component<PostFormProps, PostFormState> { } handlePostNsfwChange(i: PostForm, event: any) { - i.state.postForm.nsfw = event.target.checked; + i.state.postForm.nsfw = Some(event.target.checked); i.setState(i.state); } handleHoneyPotChange(i: PostForm, event: any) { - i.state.postForm.honeypot = event.target.value; + i.state.postForm.honeypot = Some(event.target.value); i.setState(i.state); } @@ -549,7 +601,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> { let url = `${pictrsUri}/${hash}`; let deleteToken = res.files[0].delete_token; let deleteUrl = `${pictrsUri}/delete/${deleteToken}/${hash}`; - i.state.postForm.url = url; + i.state.postForm.url = Some(url); i.state.imageLoading = false; i.setState(i.state); pictrsDeleteToast( @@ -606,30 +658,34 @@ export class PostForm extends Component<PostFormProps, PostFormState> { } } - if (this.props.post_view) { - this.state.postForm.community_id = this.props.post_view.community.id; - } else if ( - this.props.params && - (this.props.params.community_id || this.props.params.community_name) - ) { - if (this.props.params.community_name) { - let foundCommunityId = this.props.communities.find( - r => r.community.name == this.props.params.community_name - ).community.id; - this.state.postForm.community_id = foundCommunityId; - } else if (this.props.params.community_id) { - this.state.postForm.community_id = this.props.params.community_id; - } - - if (isBrowser()) { - this.choices.setChoiceByValue( - this.state.postForm.community_id.toString() - ); - } - this.setState(this.state); - } else { - // By default, the null valued 'Select a Community' + this.props.post_view.match({ + some: pv => (this.state.postForm.community_id = pv.community.id), + none: void 0, + }); + this.props.params.match({ + some: params => + params.nameOrId.match({ + some: nameOrId => + nameOrId.match({ + left: name => { + let foundCommunityId = this.props.communities + .unwrapOr([]) + .find(r => r.community.name == name).community.id; + this.state.postForm.community_id = foundCommunityId; + }, + right: id => (this.state.postForm.community_id = id), + }), + none: void 0, + }), + none: void 0, + }); + + if (isBrowser() && this.state.postForm.community_id) { + this.choices.setChoiceByValue( + this.state.postForm.community_id.toString() + ); } + this.setState(this.state); } parseMessage(msg: any) { @@ -642,30 +698,34 @@ export class PostForm extends Component<PostFormProps, PostFormState> { this.setState(this.state); return; } else if (op == UserOperation.CreatePost) { - let data = wsJsonToRes<PostResponse>(msg).data; - if ( - data.post_view.creator.id == - UserService.Instance.myUserInfo.local_user_view.person.id - ) { - this.state.loading = false; - this.props.onCreate(data.post_view); - } + let data = wsJsonToRes<PostResponse>(msg, PostResponse); + UserService.Instance.myUserInfo.match({ + some: mui => { + if (data.post_view.creator.id == mui.local_user_view.person.id) { + this.state.loading = false; + this.props.onCreate(data.post_view); + } + }, + none: void 0, + }); } else if (op == UserOperation.EditPost) { - let data = wsJsonToRes<PostResponse>(msg).data; - if ( - data.post_view.creator.id == - UserService.Instance.myUserInfo.local_user_view.person.id - ) { - this.state.loading = false; - this.props.onEdit(data.post_view); - } + let data = wsJsonToRes<PostResponse>(msg, PostResponse); + UserService.Instance.myUserInfo.match({ + some: mui => { + if (data.post_view.creator.id == mui.local_user_view.person.id) { + this.state.loading = false; + this.props.onEdit(data.post_view); + } + }, + none: void 0, + }); } else if (op == UserOperation.Search) { - let data = wsJsonToRes<SearchResponse>(msg).data; + let data = wsJsonToRes<SearchResponse>(msg, SearchResponse); if (data.type_ == SearchType[SearchType.Posts]) { - this.state.suggestedPosts = data.posts; + this.state.suggestedPosts = Some(data.posts); } else if (data.type_ == SearchType[SearchType.Url]) { - this.state.crossPosts = data.posts; + this.state.crossPosts = Some(data.posts); } this.setState(this.state); } diff --git a/src/shared/components/post/post-listing.tsx b/src/shared/components/post/post-listing.tsx index 253eeb4..eadf48a 100644 --- a/src/shared/components/post/post-listing.tsx +++ b/src/shared/components/post/post-listing.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import classNames from "classnames"; import { Component, linkEvent } from "inferno"; import { Link } from "inferno-router"; @@ -17,6 +18,7 @@ import { RemovePost, SavePost, StickyPost, + toUndefined, TransferCommunity, } from "lemmy-js-client"; import { externalHost } from "../../env"; @@ -24,10 +26,13 @@ import { i18n } from "../../i18next"; import { BanType } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + amCommunityCreator, + auth, + canAdmin, canMod, futureDaysToUnixTime, hostname, + isAdmin, isBanned, isImage, isMod, @@ -51,10 +56,10 @@ import { PostForm } from "./post-form"; interface PostListingState { showEdit: boolean; showRemoveDialog: boolean; - removeReason: string; + removeReason: Option<string>; showBanDialog: boolean; - banReason: string; - banExpireDays: number; + banReason: Option<string>; + banExpireDays: Option<number>; banType: BanType; removeData: boolean; showConfirmTransferSite: boolean; @@ -65,8 +70,8 @@ interface PostListingState { showMoreMobile: boolean; showBody: boolean; showReportDialog: boolean; - reportReason: string; - my_vote: number; + reportReason: Option<string>; + my_vote: Option<number>; score: number; upvotes: number; downvotes: number; @@ -74,13 +79,13 @@ interface PostListingState { interface PostListingProps { post_view: PostView; - duplicates?: PostView[]; + duplicates: Option<PostView[]>; + moderators: Option<CommunityModeratorView[]>; + admins: Option<PersonViewSafe[]>; showCommunity?: boolean; showBody?: boolean; - moderators?: CommunityModeratorView[]; - admins?: PersonViewSafe[]; - enableDownvotes: boolean; - enableNsfw: boolean; + enableDownvotes?: boolean; + enableNsfw?: boolean; viewOnly?: boolean; } @@ -88,10 +93,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> { private emptyState: PostListingState = { showEdit: false, showRemoveDialog: false, - removeReason: null, + removeReason: None, showBanDialog: false, - banReason: null, - banExpireDays: null, + banReason: None, + banExpireDays: None, banType: BanType.Community, removeData: false, showConfirmTransferSite: false, @@ -102,7 +107,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { showMoreMobile: false, showBody: false, showReportDialog: false, - reportReason: null, + reportReason: None, my_vote: this.props.post_view.my_vote, score: this.props.post_view.counts.score, upvotes: this.props.post_view.counts.upvotes, @@ -138,15 +143,17 @@ export class PostListing extends Component<PostListingProps, PostListingState> { <> {this.listing()} {this.state.imageExpanded && this.img} - {post.url && this.showBody && post.embed_title && ( - <MetadataCard post={post} /> - )} - {this.showBody && post.body && this.body()} + {post.url.isSome() && + this.showBody && + post.embed_title.isSome() && <MetadataCard post={post} />} + {this.showBody && this.body()} </> ) : ( <div class="col-12"> <PostForm - post_view={this.props.post_view} + post_view={Some(this.props.post_view)} + communities={None} + params={None} onEdit={this.handleEditPost} onCancel={this.handleEditCancel} enableNsfw={this.props.enableNsfw} @@ -159,39 +166,41 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } body() { - let post = this.props.post_view.post; - return ( - <div class="col-12 card my-2 p-2"> - {this.state.viewSource ? ( - <pre>{post.body}</pre> - ) : ( - <div - className="md-div" - dangerouslySetInnerHTML={mdToHtml(post.body)} - /> - )} - </div> - ); + return this.props.post_view.post.body.match({ + some: body => ( + <div class="col-12 card my-2 p-2"> + {this.state.viewSource ? ( + <pre>{body}</pre> + ) : ( + <div className="md-div" dangerouslySetInnerHTML={mdToHtml(body)} /> + )} + </div> + ), + none: <></>, + }); } get img() { - return ( - <> - <div class="offset-sm-3 my-2 d-none d-sm-block"> - <a href={this.imageSrc} class="d-inline-block"> - <PictrsImage src={this.imageSrc} /> - </a> - </div> - <div className="my-2 d-block d-sm-none"> - <a - class="d-inline-block" - onClick={linkEvent(this, this.handleImageExpandClick)} - > - <PictrsImage src={this.imageSrc} /> - </a> - </div> - </> - ); + return this.imageSrc.match({ + some: src => ( + <> + <div class="offset-sm-3 my-2 d-none d-sm-block"> + <a href={src} class="d-inline-block"> + <PictrsImage src={src} /> + </a> + </div> + <div className="my-2 d-block d-sm-none"> + <a + class="d-inline-block" + onClick={linkEvent(this, this.handleImageExpandClick)} + > + <PictrsImage src={src} /> + </a> + </div> + </> + ), + none: <></>, + }); } imgThumb(src: string) { @@ -206,53 +215,58 @@ export class PostListing extends Component<PostListingProps, PostListingState> { ); } - get imageSrc(): string { + get imageSrc(): Option<string> { let post = this.props.post_view.post; - if (isImage(post.url)) { - if (post.url.includes("pictrs")) { - return post.url; - } else if (post.thumbnail_url) { - return post.thumbnail_url; + let url = post.url; + let thumbnail = post.thumbnail_url; + + if (url.isSome() && isImage(url.unwrap())) { + if (url.unwrap().includes("pictrs")) { + return url; + } else if (thumbnail.isSome()) { + return thumbnail; } else { - return post.url; + return url; } - } else if (post.thumbnail_url) { - return post.thumbnail_url; + } else if (thumbnail.isSome()) { + return thumbnail; } else { - return null; + return None; } } thumbnail() { let post = this.props.post_view.post; + let url = post.url; + let thumbnail = post.thumbnail_url; - if (isImage(post.url)) { + if (url.isSome() && isImage(url.unwrap())) { return ( <a - href={this.imageSrc} + href={this.imageSrc.unwrap()} class="text-body d-inline-block position-relative mb-2" data-tippy-content={i18n.t("expand_here")} onClick={linkEvent(this, this.handleImageExpandClick)} aria-label={i18n.t("expand_here")} > - {this.imgThumb(this.imageSrc)} + {this.imgThumb(this.imageSrc.unwrap())} <Icon icon="image" classes="mini-overlay" /> </a> ); - } else if (post.thumbnail_url) { + } else if (url.isSome() && thumbnail.isSome()) { return ( <a class="text-body d-inline-block position-relative mb-2" - href={post.url} + href={url.unwrap()} rel={relTags} - title={post.url} + title={url.unwrap()} > - {this.imgThumb(this.imageSrc)} + {this.imgThumb(this.imageSrc.unwrap())} <Icon icon="external-link" classes="mini-overlay" /> </a> ); - } else if (post.url) { - if (isVideo(post.url)) { + } else if (url.isSome()) { + if (isVideo(url.unwrap())) { return ( <div class="embed-responsive embed-responsive-16by9"> <video @@ -262,7 +276,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { controls class="embed-responsive-item" > - <source src={post.url} type="video/mp4" /> + <source src={url.unwrap()} type="video/mp4" /> </video> </div> ); @@ -270,8 +284,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> { return ( <a className="text-body" - href={post.url} - title={post.url} + href={url.unwrap()} + title={url.unwrap()} rel={relTags} > <div class="thumbnail rounded bg-light d-flex justify-content-center"> @@ -302,10 +316,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> { <li className="list-inline-item"> <PersonListing person={post_view.creator} /> - {this.creatorIsMod && ( + {this.creatorIsMod_ && ( <span className="mx-1 badge badge-light">{i18n.t("mod")}</span> )} - {this.creatorIsAdmin && ( + {this.creatorIsAdmin_ && ( <span className="mx-1 badge badge-light">{i18n.t("admin")}</span> )} {post_view.creator.bot_account && ( @@ -328,41 +342,51 @@ export class PostListing extends Component<PostListingProps, PostListingState> { )} </li> <li className="list-inline-item">•</li> - {post_view.post.url && !(hostname(post_view.post.url) == externalHost) && ( - <> - <li className="list-inline-item"> - <a - className="text-muted font-italic" - href={post_view.post.url} - title={post_view.post.url} - rel={relTags} - > - {hostname(post_view.post.url)} - </a> - </li> - <li className="list-inline-item">•</li> - </> - )} + {post_view.post.url.match({ + some: url => + !(hostname(url) == externalHost) && ( + <> + <li className="list-inline-item"> + <a + className="text-muted font-italic" + href={url} + title={url} + rel={relTags} + > + {hostname(url)} + </a> + </li> + <li className="list-inline-item">•</li> + </> + ), + none: <></>, + })} <li className="list-inline-item"> <span> - <MomentTime data={post_view.post} /> + <MomentTime + published={post_view.post.published} + updated={post_view.post.updated} + /> </span> </li> - {post_view.post.body && ( - <> - <li className="list-inline-item">•</li> - <li className="list-inline-item"> - <button - className="text-muted btn btn-sm btn-link p-0" - data-tippy-content={md.render(post_view.post.body)} - data-tippy-allowHtml={true} - onClick={linkEvent(this, this.handleShowBody)} - > - <Icon icon="book-open" classes="icon-inline mr-1" /> - </button> - </li> - </> - )} + {post_view.post.body.match({ + some: body => ( + <> + <li className="list-inline-item">•</li> + <li className="list-inline-item"> + <button + className="text-muted btn btn-sm btn-link p-0" + data-tippy-content={md.render(body)} + data-tippy-allowHtml={true} + onClick={linkEvent(this, this.handleShowBody)} + > + <Icon icon="book-open" classes="icon-inline mr-1" /> + </button> + </li> + </> + ), + none: <></>, + })} </ul> ); } @@ -372,7 +396,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { <div className={`vote-bar col-1 pr-0 small text-center`}> <button className={`btn-animate btn btn-link p-0 ${ - this.state.my_vote == 1 ? "text-info" : "text-muted" + this.state.my_vote.unwrapOr(0) == 1 ? "text-info" : "text-muted" }`} onClick={linkEvent(this, this.handlePostLike)} data-tippy-content={i18n.t("upvote")} @@ -393,7 +417,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> { {this.props.enableDownvotes && ( <button className={`btn-animate btn btn-link p-0 ${ - this.state.my_vote == -1 ? "text-danger" : "text-muted" + this.state.my_vote.unwrapOr(0) == -1 + ? "text-danger" + : "text-muted" }`} onClick={linkEvent(this, this.handlePostDisLike)} data-tippy-content={i18n.t("downvote")} @@ -411,25 +437,28 @@ export class PostListing extends Component<PostListingProps, PostListingState> { return ( <div className="post-title overflow-hidden"> <h5> - {this.showBody && post.url ? ( - <a - className={!post.stickied ? "text-body" : "text-primary"} - href={post.url} - title={post.url} - rel={relTags} - > - {post.name} - </a> - ) : ( - <Link - className={!post.stickied ? "text-body" : "text-primary"} - to={`/post/${post.id}`} - title={i18n.t("comments")} - > - {post.name} - </Link> - )} - {(isImage(post.url) || post.thumbnail_url) && ( + {post.url.match({ + some: url => ( + <a + className={!post.stickied ? "text-body" : "text-primary"} + href={url} + title={url} + rel={relTags} + > + {post.name} + </a> + ), + none: ( + <Link + className={!post.stickied ? "text-body" : "text-primary"} + to={`/post/${post.id}`} + title={i18n.t("comments")} + > + {post.name} + </Link> + ), + })} + {post.url.map(isImage).or(post.thumbnail_url).unwrapOr(false) && ( <button class="btn btn-link text-monospace text-muted small d-inline-block ml-2" data-tippy-content={i18n.t("expand_here")} @@ -483,28 +512,30 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } duplicatesLine() { - let dupes = this.props.duplicates; - return ( - dupes && - dupes.length > 0 && ( - <ul class="list-inline mb-1 small text-muted"> - <> - <li className="list-inline-item mr-2"> - {i18n.t("cross_posted_to")} - </li> - {dupes.map(pv => ( + return this.props.duplicates.match({ + some: dupes => + dupes.length > 0 && ( + <ul class="list-inline mb-1 small text-muted"> + <> <li className="list-inline-item mr-2"> - <Link to={`/post/${pv.post.id}`}> - {pv.community.local - ? pv.community.name - : `${pv.community.name}@${hostname(pv.community.actor_id)}`} - </Link> + {i18n.t("cross_posted_to")} </li> - ))} - </> - </ul> - ) - ); + {dupes.map(pv => ( + <li className="list-inline-item mr-2"> + <Link to={`/post/${pv.post.id}`}> + {pv.community.local + ? pv.community.name + : `${pv.community.name}@${hostname( + pv.community.actor_id + )}`} + </Link> + </li> + ))} + </> + </ul> + ), + none: <></>, + }); } commentsLine(mobile = false) { @@ -522,7 +553,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { </a> )} {mobile && !this.props.viewOnly && this.mobileVotes} - {UserService.Instance.myUserInfo && + {UserService.Instance.myUserInfo.isSome() && !this.props.viewOnly && this.postActions(mobile)} </div> @@ -556,16 +587,16 @@ export class PostListing extends Component<PostListingProps, PostListingState> { )} {this.state.showAdvanced && ( <> - {this.showBody && post_view.post.body && this.viewSourceButton} - {this.canModOnSelf && ( + {this.showBody && + post_view.post.body.isSome() && + this.viewSourceButton} + {this.canModOnSelf_ && ( <> {this.lockButton} {this.stickyButton} </> )} - {(this.canMod || this.canAdmin || true) && ( - <>{this.modRemoveButton}</> - )} + {(this.canMod_ || this.canAdmin_) && <>{this.modRemoveButton}</>} </> )} {!mobile && this.showMoreButton} @@ -603,7 +634,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { <div> <button className={`btn-animate btn py-0 px-1 ${ - this.state.my_vote == 1 ? "text-info" : "text-muted" + this.state.my_vote.unwrapOr(0) == 1 ? "text-info" : "text-muted" }`} {...tippy} onClick={linkEvent(this, this.handlePostLike)} @@ -617,7 +648,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> { {this.props.enableDownvotes && ( <button className={`ml-2 btn-animate btn py-0 px-1 ${ - this.state.my_vote == -1 ? "text-danger" : "text-muted" + this.state.my_vote.unwrapOr(0) == -1 + ? "text-danger" + : "text-muted" }`} onClick={linkEvent(this, this.handlePostDisLike)} {...tippy} @@ -822,9 +855,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> { return ( this.state.showAdvanced && ( <> - {this.canMod && ( + {this.canMod_ && ( <> - {!this.creatorIsMod && + {!this.creatorIsMod_ && (!post_view.creator_banned_from_community ? ( <button class="btn btn-link btn-animate text-muted py-0" @@ -853,12 +886,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> { class="btn btn-link btn-animate text-muted py-0" onClick={linkEvent(this, this.handleAddModToCommunity)} aria-label={ - this.creatorIsMod + this.creatorIsMod_ ? i18n.t("remove_as_mod") : i18n.t("appoint_as_mod") } > - {this.creatorIsMod + {this.creatorIsMod_ ? i18n.t("remove_as_mod") : i18n.t("appoint_as_mod")} </button> @@ -866,8 +899,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> { </> )} {/* Community creators and admins can transfer community to another mod */} - {(this.amCommunityCreator || this.canAdmin) && - this.creatorIsMod && + {(amCommunityCreator(this.props.moderators, post_view.creator.id) || + this.canAdmin_) && + this.creatorIsMod_ && (!this.state.showConfirmTransferCommunity ? ( <button class="btn btn-link btn-animate text-muted py-0" @@ -907,9 +941,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> { </> ))} {/* Admins can ban from all, and appoint other admins */} - {this.canAdmin && ( + {this.canAdmin_ && ( <> - {!this.creatorIsAdmin && + {!this.creatorIsAdmin_ && (!isBanned(post_view.creator) ? ( <button class="btn btn-link btn-animate text-muted py-0" @@ -932,12 +966,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> { class="btn btn-link btn-animate text-muted py-0" onClick={linkEvent(this, this.handleAddAdmin)} aria-label={ - this.creatorIsAdmin + this.creatorIsAdmin_ ? i18n.t("remove_as_admin") : i18n.t("appoint_as_admin") } > - {this.creatorIsAdmin + {this.creatorIsAdmin_ ? i18n.t("remove_as_admin") : i18n.t("appoint_as_admin")} </button> @@ -966,7 +1000,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { id="post-listing-remove-reason" class="form-control mr-2" placeholder={i18n.t("reason")} - value={this.state.removeReason} + value={toUndefined(this.state.removeReason)} onInput={linkEvent(this, this.handleModRemoveReasonChange)} /> <button @@ -989,7 +1023,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { id="post-listing-ban-reason" class="form-control mr-2" placeholder={i18n.t("reason")} - value={this.state.banReason} + value={toUndefined(this.state.banReason)} onInput={linkEvent(this, this.handleModBanReasonChange)} /> <label class="col-form-label" htmlFor={`mod-ban-expires`}> @@ -1000,7 +1034,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { id={`mod-ban-expires`} class="form-control mr-2" placeholder={i18n.t("number_of_days")} - value={this.state.banExpireDays} + value={toUndefined(this.state.banExpireDays)} onInput={linkEvent(this, this.handleModBanExpireDaysChange)} /> <div class="form-group"> @@ -1052,7 +1086,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { class="form-control mr-2" placeholder={i18n.t("reason")} required - value={this.state.reportReason} + value={toUndefined(this.state.reportReason)} onInput={linkEvent(this, this.handleReportReasonChange)} /> <button @@ -1070,7 +1104,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> { mobileThumbnail() { let post = this.props.post_view.post; - return post.thumbnail_url || isImage(post.url) ? ( + return post.thumbnail_url.isSome() || + post.url.map(isImage).unwrapOr(false) ? ( <div class="row"> <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}> {this.postTitleLine()} @@ -1088,10 +1123,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> { showMobilePreview() { let post = this.props.post_view.post; return ( - post.body && - !this.showBody && ( - <div className="md-div mb-1 preview-lines">{post.body}</div> - ) + !this.showBody && + post.body.match({ + some: body => <div className="md-div mb-1 preview-lines">{body}</div>, + none: <></>, + }) ); } @@ -1144,119 +1180,26 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } private get myPost(): boolean { - return ( - UserService.Instance.myUserInfo && - this.props.post_view.creator.id == - UserService.Instance.myUserInfo.local_user_view.person.id - ); - } - - get creatorIsMod(): boolean { - return ( - this.props.moderators && - isMod( - this.props.moderators.map(m => m.moderator.id), - this.props.post_view.creator.id - ) - ); - } - - get creatorIsAdmin(): boolean { - return ( - this.props.admins && - isMod( - this.props.admins.map(a => a.person.id), - this.props.post_view.creator.id - ) - ); - } - - /** - * If the current user is allowed to mod this post. - * The creator of this post is not allowed even if they are a mod. - */ - get canMod(): boolean { - if (this.props.admins && this.props.moderators) { - let adminsThenMods = this.props.admins - .map(a => a.person.id) - .concat(this.props.moderators.map(m => m.moderator.id)); - - return canMod( - UserService.Instance.myUserInfo, - adminsThenMods, - this.props.post_view.creator.id - ); - } else { - return false; - } - } - - /** - * If the current user is allowed to mod this post. - * The creator of this post is allowed if they are a mod. - */ - get canModOnSelf(): boolean { - if (this.props.admins && this.props.moderators) { - let adminsThenMods = this.props.admins - .map(a => a.person.id) - .concat(this.props.moderators.map(m => m.moderator.id)); - - return canMod( - UserService.Instance.myUserInfo, - adminsThenMods, - this.props.post_view.creator.id, - true - ); - } else { - return false; - } - } - - get canAdmin(): boolean { - return ( - this.props.admins && - canMod( - UserService.Instance.myUserInfo, - this.props.admins.map(a => a.person.id), - this.props.post_view.creator.id - ) - ); - } - - get amCommunityCreator(): boolean { - return ( - this.props.moderators && - UserService.Instance.myUserInfo && - this.props.post_view.creator.id != - UserService.Instance.myUserInfo.local_user_view.person.id && - UserService.Instance.myUserInfo.local_user_view.person.id == - this.props.moderators[0].moderator.id - ); - } - - get amSiteCreator(): boolean { - return ( - this.props.admins && - UserService.Instance.myUserInfo && - this.props.post_view.creator.id != - UserService.Instance.myUserInfo.local_user_view.person.id && - UserService.Instance.myUserInfo.local_user_view.person.id == - this.props.admins[0].person.id - ); + return UserService.Instance.myUserInfo.match({ + some: mui => + this.props.post_view.creator.id == mui.local_user_view.person.id, + none: false, + }); } handlePostLike(i: PostListing, event: any) { event.preventDefault(); - if (!UserService.Instance.myUserInfo) { + if (UserService.Instance.myUserInfo.isNone()) { this.context.router.history.push(`/login`); } - let new_vote = i.state.my_vote == 1 ? 0 : 1; + let myVote = this.state.my_vote.unwrapOr(0); + let newVote = myVote == 1 ? 0 : 1; - if (i.state.my_vote == 1) { + if (myVote == 1) { i.state.score--; i.state.upvotes--; - } else if (i.state.my_vote == -1) { + } else if (myVote == -1) { i.state.downvotes--; i.state.upvotes++; i.state.score += 2; @@ -1265,13 +1208,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> { i.state.score++; } - i.state.my_vote = new_vote; + i.state.my_vote = Some(newVote); - let form: CreatePostLike = { + let form = new CreatePostLike({ post_id: i.props.post_view.post.id, - score: i.state.my_vote, - auth: authField(), - }; + score: newVote, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.likePost(form)); i.setState(i.state); @@ -1280,17 +1223,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> { handlePostDisLike(i: PostListing, event: any) { event.preventDefault(); - if (!UserService.Instance.myUserInfo) { + if (UserService.Instance.myUserInfo.isNone()) { this.context.router.history.push(`/login`); } - let new_vote = i.state.my_vote == -1 ? 0 : -1; + let myVote = this.state.my_vote.unwrapOr(0); + let newVote = myVote == -1 ? 0 : -1; - if (i.state.my_vote == 1) { + if (myVote == 1) { i.state.score -= 2; i.state.upvotes--; i.state.downvotes++; - } else if (i.state.my_vote == -1) { + } else if (myVote == -1) { i.state.downvotes--; i.state.score++; } else { @@ -1298,13 +1242,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> { i.state.score--; } - i.state.my_vote = new_vote; + i.state.my_vote = Some(newVote); - let form: CreatePostLike = { + let form = new CreatePostLike({ post_id: i.props.post_view.post.id, - score: i.state.my_vote, - auth: authField(), - }; + score: newVote, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.likePost(form)); i.setState(i.state); @@ -1333,17 +1277,17 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } handleReportReasonChange(i: PostListing, event: any) { - i.state.reportReason = event.target.value; + i.state.reportReason = Some(event.target.value); i.setState(i.state); } handleReportSubmit(i: PostListing, event: any) { event.preventDefault(); - let form: CreatePostReport = { + let form = new CreatePostReport({ post_id: i.props.post_view.post.id, - reason: i.state.reportReason, - auth: authField(), - }; + reason: toUndefined(i.state.reportReason), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.createPostReport(form)); i.state.showReportDialog = false; @@ -1351,31 +1295,31 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } handleBlockUserClick(i: PostListing) { - let blockUserForm: BlockPerson = { + let blockUserForm = new BlockPerson({ person_id: i.props.post_view.creator.id, block: true, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm)); } handleDeleteClick(i: PostListing) { - let deleteForm: DeletePost = { + let deleteForm = new DeletePost({ post_id: i.props.post_view.post.id, deleted: !i.props.post_view.post.deleted, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.deletePost(deleteForm)); } handleSavePostClick(i: PostListing) { let saved = i.props.post_view.saved == undefined ? true : !i.props.post_view.saved; - let form: SavePost = { + let form = new SavePost({ post_id: i.props.post_view.post.id, save: saved, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.savePost(form)); } @@ -1384,10 +1328,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> { let post = this.props.post_view.post; let params = `?title=${encodeURIComponent(post.name)}`; - if (post.url) { - params += `&url=${encodeURIComponent(post.url)}`; + if (post.url.isSome()) { + params += `&url=${encodeURIComponent(post.url.unwrap())}`; } - if (post.body) { + if (post.body.isSome()) { params += `&body=${encodeURIComponent(this.crossPostBody())}`; } return params; @@ -1395,9 +1339,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> { crossPostBody(): string { let post = this.props.post_view.post; - let body = `${i18n.t("cross_posted_from")} ${ - post.ap_id - }\n\n${post.body.replace(/^/gm, "> ")}`; + let body = `${i18n.t("cross_posted_from")} ${post.ap_id}\n\n${post.body + .unwrap() + .replace(/^/gm, "> ")}`; return body; } @@ -1412,7 +1356,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } handleModRemoveReasonChange(i: PostListing, event: any) { - i.state.removeReason = event.target.value; + i.state.removeReason = Some(event.target.value); i.setState(i.state); } @@ -1423,12 +1367,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> { handleModRemoveSubmit(i: PostListing, event: any) { event.preventDefault(); - let form: RemovePost = { + let form = new RemovePost({ post_id: i.props.post_view.post.id, removed: !i.props.post_view.post.removed, reason: i.state.removeReason, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.removePost(form)); i.state.showRemoveDialog = false; @@ -1436,20 +1380,20 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } handleModLock(i: PostListing) { - let form: LockPost = { + let form = new LockPost({ post_id: i.props.post_view.post.id, locked: !i.props.post_view.post.locked, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.lockPost(form)); } handleModSticky(i: PostListing) { - let form: StickyPost = { + let form = new StickyPost({ post_id: i.props.post_view.post.id, stickied: !i.props.post_view.post.stickied, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.stickyPost(form)); } @@ -1468,12 +1412,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } handleModBanReasonChange(i: PostListing, event: any) { - i.state.banReason = event.target.value; + i.state.banReason = Some(event.target.value); i.setState(i.state); } handleModBanExpireDaysChange(i: PostListing, event: any) { - i.state.banExpireDays = event.target.value; + i.state.banExpireDays = Some(event.target.value); i.setState(i.state); } @@ -1498,15 +1442,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> { if (ban == false) { i.state.removeData = false; } - let form: BanFromCommunity = { + let form = new BanFromCommunity({ person_id: i.props.post_view.creator.id, community_id: i.props.post_view.community.id, ban, - remove_data: i.state.removeData, + remove_data: Some(i.state.removeData), reason: i.state.banReason, - expires: futureDaysToUnixTime(i.state.banExpireDays), - auth: authField(), - }; + expires: i.state.banExpireDays.map(futureDaysToUnixTime), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.banFromCommunity(form)); } else { // If its an unban, restore all their data @@ -1514,14 +1458,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> { if (ban == false) { i.state.removeData = false; } - let form: BanPerson = { + let form = new BanPerson({ person_id: i.props.post_view.creator.id, ban, - remove_data: i.state.removeData, + remove_data: Some(i.state.removeData), reason: i.state.banReason, - expires: futureDaysToUnixTime(i.state.banExpireDays), - auth: authField(), - }; + expires: i.state.banExpireDays.map(futureDaysToUnixTime), + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.banPerson(form)); } @@ -1530,22 +1474,22 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } handleAddModToCommunity(i: PostListing) { - let form: AddModToCommunity = { + let form = new AddModToCommunity({ person_id: i.props.post_view.creator.id, community_id: i.props.post_view.community.id, - added: !i.creatorIsMod, - auth: authField(), - }; + added: !i.creatorIsMod_, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.addModToCommunity(form)); i.setState(i.state); } handleAddAdmin(i: PostListing) { - let form: AddAdmin = { + let form = new AddAdmin({ person_id: i.props.post_view.creator.id, - added: !i.creatorIsAdmin, - auth: authField(), - }; + added: !i.creatorIsAdmin_, + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.addAdmin(form)); i.setState(i.state); } @@ -1561,11 +1505,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> { } handleTransferCommunity(i: PostListing) { - let form: TransferCommunity = { + let form = new TransferCommunity({ community_id: i.props.post_view.community.id, person_id: i.props.post_view.creator.id, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.transferCommunity(form)); i.state.showConfirmTransferCommunity = false; i.setState(i.state); @@ -1630,4 +1574,34 @@ export class PostListing extends Component<PostListingProps, PostListingState> { return `${points} • ${upvotes} • ${downvotes}`; } + + get canModOnSelf_(): boolean { + return canMod( + this.props.moderators, + this.props.admins, + this.props.post_view.creator.id, + undefined, + true + ); + } + + get canMod_(): boolean { + return canMod( + this.props.moderators, + this.props.admins, + this.props.post_view.creator.id + ); + } + + get canAdmin_(): boolean { + return canAdmin(this.props.admins, this.props.post_view.creator.id); + } + + get creatorIsMod_(): boolean { + return isMod(this.props.moderators, this.props.post_view.creator.id); + } + + get creatorIsAdmin_(): boolean { + return isAdmin(this.props.admins, this.props.post_view.creator.id); + } } diff --git a/src/shared/components/post/post-listings.tsx b/src/shared/components/post/post-listings.tsx index 8bdb569..a26d9d6 100644 --- a/src/shared/components/post/post-listings.tsx +++ b/src/shared/components/post/post-listings.tsx @@ -1,3 +1,4 @@ +import { None, Some } from "@sniptt/monads"; import { Component } from "inferno"; import { T } from "inferno-i18next-dess"; import { Link } from "inferno-router"; @@ -13,39 +14,30 @@ interface PostListingsProps { enableNsfw: boolean; } -interface PostListingsState { - posts: PostView[]; -} - -export class PostListings extends Component< - PostListingsProps, - PostListingsState -> { +export class PostListings extends Component<PostListingsProps, any> { duplicatesMap = new Map<number, PostView[]>(); - private emptyState: PostListingsState = { - posts: [], - }; - constructor(props: any, context: any) { super(props, context); - this.state = this.emptyState; - if (this.props.removeDuplicates) { - this.state.posts = this.removeDuplicates(); - } else { - this.state.posts = this.props.posts; - } + } + + get posts() { + return this.props.removeDuplicates + ? this.removeDuplicates() + : this.props.posts; } render() { return ( <div> - {this.state.posts.length > 0 ? ( - this.state.posts.map(post_view => ( + {this.posts.length > 0 ? ( + this.posts.map(post_view => ( <> <PostListing post_view={post_view} - duplicates={this.duplicatesMap.get(post_view.post.id)} + duplicates={Some(this.duplicatesMap.get(post_view.post.id))} + moderators={None} + admins={None} showCommunity={this.props.showCommunity} enableDownvotes={this.props.enableDownvotes} enableNsfw={this.props.enableNsfw} @@ -56,7 +48,7 @@ export class PostListings extends Component< ) : ( <> <div>{i18n.t("no_posts")}</div> - {this.props.showCommunity !== undefined && ( + {this.props.showCommunity && ( <T i18nKey="subscribe_to_communities"> #<Link to="/communities">#</Link> </T> @@ -76,19 +68,20 @@ export class PostListings extends Component< // Loop over the posts, find ones with same urls for (let pv of posts) { - if ( - pv.post.url && - !pv.post.deleted && + !pv.post.deleted && !pv.post.removed && !pv.community.deleted && - !pv.community.removed - ) { - if (!urlMap.get(pv.post.url)) { - urlMap.set(pv.post.url, [pv]); - } else { - urlMap.get(pv.post.url).push(pv); - } - } + !pv.community.removed && + pv.post.url.match({ + some: url => { + if (!urlMap.get(url)) { + urlMap.set(url, [pv]); + } else { + urlMap.get(url).push(pv); + } + }, + none: void 0, + }); } // Sort by oldest @@ -103,19 +96,22 @@ export class PostListings extends Component< for (let i = 0; i < posts.length; i++) { let pv = posts[i]; - if (pv.post.url) { - let found = urlMap.get(pv.post.url); - if (found) { - // If its the oldest, add - if (pv.post.id == found[0].post.id) { - this.duplicatesMap.set(pv.post.id, found.slice(1)); + pv.post.url.match({ + some: url => { + let found = urlMap.get(url); + if (found) { + // If its the oldest, add + if (pv.post.id == found[0].post.id) { + this.duplicatesMap.set(pv.post.id, found.slice(1)); + } + // Otherwise, delete it + else { + posts.splice(i--, 1); + } } - // Otherwise, delete it - else { - posts.splice(i--, 1); - } - } - } + }, + none: void 0, + }); } return posts; diff --git a/src/shared/components/post/post-report.tsx b/src/shared/components/post/post-report.tsx index 9a80055..1848761 100644 --- a/src/shared/components/post/post-report.tsx +++ b/src/shared/components/post/post-report.tsx @@ -1,9 +1,10 @@ +import { None } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { PostReportView, PostView, ResolvePostReport } from "lemmy-js-client"; import { i18n } from "../../i18next"; import { WebSocketService } from "../../services"; -import { authField, wsClient } from "../../utils"; +import { auth, wsClient } from "../../utils"; import { Icon } from "../common/icon"; import { PersonListing } from "../person/person-listing"; import { PostListing } from "./post-listing"; @@ -45,6 +46,9 @@ export class PostReport extends Component<PostReportProps, any> { <div> <PostListing post_view={pv} + duplicates={None} + moderators={None} + admins={None} showCommunity={true} enableDownvotes={true} enableNsfw={true} @@ -56,21 +60,24 @@ export class PostReport extends Component<PostReportProps, any> { <div> {i18n.t("reason")}: {r.post_report.reason} </div> - {r.resolver && ( - <div> - {r.post_report.resolved ? ( - <T i18nKey="resolved_by"> - # - <PersonListing person={r.resolver} /> - </T> - ) : ( - <T i18nKey="unresolved_by"> - # - <PersonListing person={r.resolver} /> - </T> - )} - </div> - )} + {r.resolver.match({ + some: resolver => ( + <div> + {r.post_report.resolved ? ( + <T i18nKey="resolved_by"> + # + <PersonListing person={resolver} /> + </T> + ) : ( + <T i18nKey="unresolved_by"> + # + <PersonListing person={resolver} /> + </T> + )} + </div> + ), + none: <></>, + })} <button className="btn btn-link btn-animate text-muted py-0" onClick={linkEvent(this, this.handleResolveReport)} @@ -89,11 +96,11 @@ export class PostReport extends Component<PostReportProps, any> { } handleResolveReport(i: PostReport) { - let form: ResolvePostReport = { + let form = new ResolvePostReport({ report_id: i.props.report.post_report.id, resolved: !i.props.report.post_report.resolved, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.resolvePostReport(form)); } } diff --git a/src/shared/components/post/post.tsx b/src/shared/components/post/post.tsx index 5d15e83..00d2789 100644 --- a/src/shared/components/post/post.tsx +++ b/src/shared/components/post/post.tsx @@ -1,3 +1,4 @@ +import { None, Option, Right, Some } from "@sniptt/monads"; import autosize from "autosize"; import { Component, createRef, linkEvent, RefObject } from "inferno"; import { @@ -23,6 +24,8 @@ import { SearchType, SortType, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; @@ -34,13 +37,15 @@ import { } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, buildCommentsTree, commentsToFlatNodes, createCommentLikeRes, createPostLikeRes, debounce, editCommentRes, + enableDownvotes, + enableNsfw, getCommentIdFromProps, getIdFromProps, insertCommentIntoTree, @@ -50,14 +55,12 @@ import { saveCommentRes, saveScrollPosition, setIsoData, - setOptionalAuth, setupTippy, toast, + trendingFetchLimit, updatePersonBlock, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { CommentForm } from "../comment/comment-form"; import { CommentNodes } from "../comment/comment-nodes"; @@ -69,7 +72,7 @@ import { PostListing } from "./post-listing"; const commentsShownInterval = 15; interface PostState { - postRes: GetPostResponse; + postRes: Option<GetPostResponse>; postId: number; commentTree: CommentNodeI[]; commentId?: number; @@ -77,7 +80,7 @@ interface PostState { commentViewType: CommentViewType; scrolled?: boolean; loading: boolean; - crossPosts: PostView[]; + crossPosts: Option<PostView[]>; siteRes: GetSiteResponse; commentSectionRef?: RefObject<HTMLDivElement>; showSidebarMobile: boolean; @@ -86,10 +89,10 @@ interface PostState { export class Post extends Component<any, PostState> { private subscription: Subscription; - private isoData = setIsoData(this.context); + private isoData = setIsoData(this.context, GetPostResponse); private commentScrollDebounced: () => void; private emptyState: PostState = { - postRes: null, + postRes: None, postId: getIdFromProps(this.props), commentTree: [], commentId: getCommentIdFromProps(this.props), @@ -97,7 +100,7 @@ export class Post extends Component<any, PostState> { commentViewType: CommentViewType.Tree, scrolled: false, loading: true, - crossPosts: [], + crossPosts: None, siteRes: this.isoData.site_res, commentSectionRef: null, showSidebarMobile: false, @@ -115,14 +118,24 @@ export class Post extends Component<any, PostState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.postRes = this.isoData.routeData[0]; + this.state.postRes = Some(this.isoData.routeData[0] as GetPostResponse); this.state.commentTree = buildCommentsTree( - this.state.postRes.comments, + this.state.postRes.unwrap().comments, this.state.commentSort ); this.state.loading = false; if (isBrowser()) { + WebSocketService.Instance.send( + wsClient.communityJoin({ + community_id: + this.state.postRes.unwrap().community_view.community.id, + }) + ); + WebSocketService.Instance.send( + wsClient.postJoin({ post_id: this.state.postId }) + ); + this.fetchCrossPosts(); if (this.state.commentId) { this.scrollCommentIntoView(); @@ -138,42 +151,47 @@ export class Post extends Component<any, PostState> { } fetchPost() { - let form: GetPost = { + let form = new GetPost({ id: this.state.postId, - auth: authField(false), - }; + auth: auth(false).ok(), + }); WebSocketService.Instance.send(wsClient.getPost(form)); } fetchCrossPosts() { - if (this.state.postRes.post_view.post.url) { - let form: Search = { - q: this.state.postRes.post_view.post.url, - type_: SearchType.Url, - sort: SortType.TopAll, - listing_type: ListingType.All, - page: 1, - limit: 6, - auth: authField(false), - }; - WebSocketService.Instance.send(wsClient.search(form)); - } + this.state.postRes + .andThen(r => r.post_view.post.url) + .match({ + some: url => { + let form = new Search({ + q: url, + type_: Some(SearchType.Url), + sort: Some(SortType.TopAll), + listing_type: Some(ListingType.All), + page: Some(1), + limit: Some(trendingFetchLimit), + community_id: None, + community_name: None, + creator_id: None, + auth: auth(false).ok(), + }); + WebSocketService.Instance.send(wsClient.search(form)); + }, + none: void 0, + }); } static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let pathSplit = req.path.split("/"); - let promises: Promise<any>[] = []; let id = Number(pathSplit[2]); - let postForm: GetPost = { + let postForm = new GetPost({ id, - }; - setOptionalAuth(postForm, req.auth); - - promises.push(req.client.getPost(postForm)); + auth: req.auth, + }); - return promises; + return [req.client.getPost(postForm)]; } componentWillUnmount() { @@ -185,9 +203,6 @@ export class Post extends Component<any, PostState> { } componentDidMount() { - WebSocketService.Instance.send( - wsClient.postJoin({ post_id: this.state.postId }) - ); autosize(document.querySelectorAll("textarea")); this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100); @@ -231,34 +246,38 @@ export class Post extends Component<any, PostState> { // TODO this needs some re-work markScrolledAsRead(commentId: number) { - let found = this.state.postRes.comments.find( - c => c.comment.id == commentId - ); - let parent = this.state.postRes.comments.find( - c => found.comment.parent_id == c.comment.id - ); - let parent_person_id = parent - ? parent.creator.id - : this.state.postRes.post_view.creator.id; - - if ( - UserService.Instance.myUserInfo && - UserService.Instance.myUserInfo.local_user_view.person.id == - parent_person_id - ) { - let form: MarkCommentAsRead = { - comment_id: found.comment.id, - read: true, - auth: authField(), - }; - WebSocketService.Instance.send(wsClient.markCommentAsRead(form)); - UserService.Instance.unreadInboxCountSub.next( - UserService.Instance.unreadInboxCountSub.value - 1 - ); - } + this.state.postRes.match({ + some: res => { + let found = res.comments.find(c => c.comment.id == commentId); + let parent = res.comments.find( + c => found.comment.parent_id.unwrapOr(0) == c.comment.id + ); + let parent_person_id = parent + ? parent.creator.id + : res.post_view.creator.id; + + UserService.Instance.myUserInfo.match({ + some: mui => { + if (mui.local_user_view.person.id == parent_person_id) { + let form = new MarkCommentAsRead({ + comment_id: found.comment.id, + read: true, + auth: auth().unwrap(), + }); + WebSocketService.Instance.send(wsClient.markCommentAsRead(form)); + UserService.Instance.unreadInboxCountSub.next( + UserService.Instance.unreadInboxCountSub.value - 1 + ); + } + }, + none: void 0, + }); + }, + none: void 0, + }); } - isBottom(el: Element) { + isBottom(el: Element): boolean { return el?.getBoundingClientRect().bottom <= window.innerHeight; } @@ -274,23 +293,35 @@ export class Post extends Component<any, PostState> { }; get documentTitle(): string { - return `${this.state.postRes.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`; + return this.state.postRes.match({ + some: res => + this.state.siteRes.site_view.match({ + some: siteView => + `${res.post_view.post.name} - ${siteView.site.name}`, + none: "", + }), + none: "", + }); } - get imageTag(): string { - let post = this.state.postRes.post_view.post; - return ( - post.thumbnail_url || - (post.url ? (isImage(post.url) ? post.url : undefined) : undefined) - ); + get imageTag(): Option<string> { + return this.state.postRes.match({ + some: res => + res.post_view.post.thumbnail_url.or( + res.post_view.post.url.match({ + some: url => (isImage(url) ? Some(url) : None), + none: None, + }) + ), + none: None, + }); } - get descriptionTag(): string { - return this.state.postRes.post_view.post.body; + get descriptionTag(): Option<string> { + return this.state.postRes.andThen(r => r.post_view.post.body); } render() { - let pv = this.state.postRes?.post_view; return ( <div class="container"> {this.state.loading ? ( @@ -298,56 +329,59 @@ export class Post extends Component<any, PostState> { <Spinner large /> </h5> ) : ( - <div class="row"> - <div class="col-12 col-md-8 mb-3"> - <HtmlTags - title={this.documentTitle} - path={this.context.router.route.match.url} - image={this.imageTag} - description={this.descriptionTag} - /> - <PostListing - post_view={pv} - duplicates={this.state.crossPosts} - showBody - showCommunity - moderators={this.state.postRes.moderators} - admins={this.state.siteRes.admins} - enableDownvotes={ - this.state.siteRes.site_view.site.enable_downvotes - } - enableNsfw={this.state.siteRes.site_view.site.enable_nsfw} - /> - <div ref={this.state.commentSectionRef} className="mb-2" /> - <CommentForm - postId={this.state.postId} - disabled={pv.post.locked} - /> - <div class="d-block d-md-none"> - <button - class="btn btn-secondary d-inline-block mb-2 mr-3" - onClick={linkEvent(this, this.handleShowSidebarMobile)} - > - {i18n.t("sidebar")}{" "} - <Icon - icon={ - this.state.showSidebarMobile - ? `minus-square` - : `plus-square` - } - classes="icon-inline" + this.state.postRes.match({ + some: res => ( + <div class="row"> + <div class="col-12 col-md-8 mb-3"> + <HtmlTags + title={this.documentTitle} + path={this.context.router.route.match.url} + image={this.imageTag} + description={this.descriptionTag} + /> + <PostListing + post_view={res.post_view} + duplicates={this.state.crossPosts} + showBody + showCommunity + moderators={Some(res.moderators)} + admins={Some(this.state.siteRes.admins)} + enableDownvotes={enableDownvotes(this.state.siteRes)} + enableNsfw={enableNsfw(this.state.siteRes)} /> - </button> - {this.state.showSidebarMobile && this.sidebar()} + <div ref={this.state.commentSectionRef} className="mb-2" /> + <CommentForm + node={Right(this.state.postId)} + disabled={res.post_view.post.locked} + /> + <div class="d-block d-md-none"> + <button + class="btn btn-secondary d-inline-block mb-2 mr-3" + onClick={linkEvent(this, this.handleShowSidebarMobile)} + > + {i18n.t("sidebar")}{" "} + <Icon + icon={ + this.state.showSidebarMobile + ? `minus-square` + : `plus-square` + } + classes="icon-inline" + /> + </button> + {this.state.showSidebarMobile && this.sidebar()} + </div> + {res.comments.length > 0 && this.sortRadios()} + {this.state.commentViewType == CommentViewType.Tree && + this.commentsTree()} + {this.state.commentViewType == CommentViewType.Chat && + this.commentsFlat()} + </div> + <div class="d-none d-md-block col-md-4">{this.sidebar()}</div> </div> - {this.state.postRes.comments.length > 0 && this.sortRadios()} - {this.state.commentViewType == CommentViewType.Tree && - this.commentsTree()} - {this.state.commentViewType == CommentViewType.Chat && - this.commentsFlat()} - </div> - <div class="d-none d-md-block col-md-4">{this.sidebar()}</div> - </div> + ), + none: <></>, + }) )} </div> ); @@ -431,43 +465,48 @@ export class Post extends Component<any, PostState> { commentsFlat() { // These are already sorted by new - return ( - <div> - <CommentNodes - nodes={commentsToFlatNodes(this.state.postRes.comments)} - maxCommentsShown={this.state.maxCommentsShown} - noIndent - locked={this.state.postRes.post_view.post.locked} - moderators={this.state.postRes.moderators} - admins={this.state.siteRes.admins} - postCreatorId={this.state.postRes.post_view.creator.id} - showContext - enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes} - /> - </div> - ); + return this.state.postRes.match({ + some: res => ( + <div> + <CommentNodes + nodes={commentsToFlatNodes(res.comments)} + maxCommentsShown={Some(this.state.maxCommentsShown)} + noIndent + locked={res.post_view.post.locked} + moderators={Some(res.moderators)} + admins={Some(this.state.siteRes.admins)} + enableDownvotes={enableDownvotes(this.state.siteRes)} + showContext + /> + </div> + ), + none: <></>, + }); } sidebar() { - return ( - <div class="mb-3"> - <Sidebar - community_view={this.state.postRes.community_view} - moderators={this.state.postRes.moderators} - admins={this.state.siteRes.admins} - online={this.state.postRes.online} - enableNsfw={this.state.siteRes.site_view.site.enable_nsfw} - showIcon - /> - </div> - ); + return this.state.postRes.match({ + some: res => ( + <div class="mb-3"> + <Sidebar + community_view={res.community_view} + moderators={res.moderators} + admins={this.state.siteRes.admins} + online={res.online} + enableNsfw={enableNsfw(this.state.siteRes)} + showIcon + /> + </div> + ), + none: <></>, + }); } handleCommentSortChange(i: Post, event: any) { i.state.commentSort = Number(event.target.value); i.state.commentViewType = CommentViewType.Tree; i.state.commentTree = buildCommentsTree( - i.state.postRes.comments, + i.state.postRes.map(r => r.comments).unwrapOr([]), i.state.commentSort ); i.setState(i.state); @@ -477,7 +516,7 @@ export class Post extends Component<any, PostState> { i.state.commentViewType = Number(event.target.value); i.state.commentSort = CommentSortType.New; i.state.commentTree = buildCommentsTree( - i.state.postRes.comments, + i.state.postRes.map(r => r.comments).unwrapOr([]), i.state.commentSort ); i.setState(i.state); @@ -489,19 +528,21 @@ export class Post extends Component<any, PostState> { } commentsTree() { - return ( - <div> - <CommentNodes - nodes={this.state.commentTree} - maxCommentsShown={this.state.maxCommentsShown} - locked={this.state.postRes.post_view.post.locked} - moderators={this.state.postRes.moderators} - admins={this.state.siteRes.admins} - postCreatorId={this.state.postRes.post_view.creator.id} - enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes} - /> - </div> - ); + return this.state.postRes.match({ + some: res => ( + <div> + <CommentNodes + nodes={this.state.commentTree} + maxCommentsShown={Some(this.state.maxCommentsShown)} + locked={res.post_view.post.locked} + moderators={Some(res.moderators)} + admins={Some(this.state.siteRes.admins)} + enableDownvotes={enableDownvotes(this.state.siteRes)} + /> + </div> + ), + none: <></>, + }); } parseMessage(msg: any) { @@ -516,18 +557,29 @@ export class Post extends Component<any, PostState> { WebSocketService.Instance.send( wsClient.getPost({ id: postId, - auth: authField(false), + auth: auth(false).ok(), }) ); } else if (op == UserOperation.GetPost) { - let data = wsJsonToRes<GetPostResponse>(msg).data; - this.state.postRes = data; + let data = wsJsonToRes<GetPostResponse>(msg, GetPostResponse); + this.state.postRes = Some(data); + this.state.commentTree = buildCommentsTree( - this.state.postRes.comments, + this.state.postRes.map(r => r.comments).unwrapOr([]), this.state.commentSort ); this.state.loading = false; + // join the rooms + WebSocketService.Instance.send( + wsClient.postJoin({ post_id: this.state.postId }) + ); + WebSocketService.Instance.send( + wsClient.communityJoin({ + community_id: data.community_view.community.id, + }) + ); + // Get cross-posts this.fetchCrossPosts(); this.setState(this.state); @@ -542,18 +594,25 @@ export class Post extends Component<any, PostState> { this.scrollCommentIntoView(); } } else if (op == UserOperation.CreateComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); // Don't get comments from the post room, if the creator is blocked - let creatorBlocked = UserService.Instance.myUserInfo?.person_blocks + let creatorBlocked = UserService.Instance.myUserInfo + .map(m => m.person_blocks) + .unwrapOr([]) .map(pb => pb.target.id) .includes(data.comment_view.creator.id); // Necessary since it might be a user reply, which has the recipients, to avoid double if (data.recipient_ids.length == 0 && !creatorBlocked) { - this.state.postRes.comments.unshift(data.comment_view); - insertCommentIntoTree(this.state.commentTree, data.comment_view); - this.state.postRes.post_view.counts.comments++; + this.state.postRes.match({ + some: res => { + res.comments.unshift(data.comment_view); + insertCommentIntoTree(this.state.commentTree, data.comment_view); + res.post_view.counts.comments++; + }, + none: void 0, + }); this.setState(this.state); setupTippy(); } @@ -562,21 +621,33 @@ export class Post extends Component<any, PostState> { op == UserOperation.DeleteComment || op == UserOperation.RemoveComment ) { - let data = wsJsonToRes<CommentResponse>(msg).data; - editCommentRes(data.comment_view, this.state.postRes.comments); + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + editCommentRes( + data.comment_view, + this.state.postRes.map(r => r.comments).unwrapOr([]) + ); this.setState(this.state); } else if (op == UserOperation.SaveComment) { - let data = wsJsonToRes<CommentResponse>(msg).data; - saveCommentRes(data.comment_view, this.state.postRes.comments); + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + saveCommentRes( + data.comment_view, + this.state.postRes.map(r => r.comments).unwrapOr([]) + ); this.setState(this.state); setupTippy(); } else if (op == UserOperation.CreateCommentLike) { - let data = wsJsonToRes<CommentResponse>(msg).data; - createCommentLikeRes(data.comment_view, this.state.postRes.comments); + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + createCommentLikeRes( + data.comment_view, + this.state.postRes.map(r => r.comments).unwrapOr([]) + ); this.setState(this.state); } else if (op == UserOperation.CreatePostLike) { - let data = wsJsonToRes<PostResponse>(msg).data; - createPostLikeRes(data.post_view, this.state.postRes.post_view); + let data = wsJsonToRes<PostResponse>(msg, PostResponse); + this.state.postRes.match({ + some: res => createPostLikeRes(data.post_view, res.post_view), + none: void 0, + }); this.setState(this.state); } else if ( op == UserOperation.EditPost || @@ -586,8 +657,11 @@ export class Post extends Component<any, PostState> { op == UserOperation.StickyPost || op == UserOperation.SavePost ) { - let data = wsJsonToRes<PostResponse>(msg).data; - this.state.postRes.post_view = data.post_view; + let data = wsJsonToRes<PostResponse>(msg, PostResponse); + this.state.postRes.match({ + some: res => (res.post_view = data.post_view), + none: void 0, + }); this.setState(this.state); setupTippy(); } else if ( @@ -596,68 +670,94 @@ export class Post extends Component<any, PostState> { op == UserOperation.RemoveCommunity || op == UserOperation.FollowCommunity ) { - let data = wsJsonToRes<CommunityResponse>(msg).data; - this.state.postRes.community_view = data.community_view; - this.state.postRes.post_view.community = data.community_view.community; - this.setState(this.state); - this.setState(this.state); + let data = wsJsonToRes<CommunityResponse>(msg, CommunityResponse); + this.state.postRes.match({ + some: res => { + res.community_view = data.community_view; + res.post_view.community = data.community_view.community; + this.setState(this.state); + }, + none: void 0, + }); } else if (op == UserOperation.BanFromCommunity) { - let data = wsJsonToRes<BanFromCommunityResponse>(msg).data; - this.state.postRes.comments - .filter(c => c.creator.id == data.person_view.person.id) - .forEach(c => (c.creator_banned_from_community = data.banned)); - if ( - this.state.postRes.post_view.creator.id == data.person_view.person.id - ) { - this.state.postRes.post_view.creator_banned_from_community = - data.banned; - } - this.setState(this.state); + let data = wsJsonToRes<BanFromCommunityResponse>( + msg, + BanFromCommunityResponse + ); + this.state.postRes.match({ + some: res => { + res.comments + .filter(c => c.creator.id == data.person_view.person.id) + .forEach(c => (c.creator_banned_from_community = data.banned)); + if (res.post_view.creator.id == data.person_view.person.id) { + res.post_view.creator_banned_from_community = data.banned; + } + this.setState(this.state); + }, + none: void 0, + }); } else if (op == UserOperation.AddModToCommunity) { - let data = wsJsonToRes<AddModToCommunityResponse>(msg).data; - this.state.postRes.moderators = data.moderators; - this.setState(this.state); + let data = wsJsonToRes<AddModToCommunityResponse>( + msg, + AddModToCommunityResponse + ); + this.state.postRes.match({ + some: res => { + res.moderators = data.moderators; + this.setState(this.state); + }, + none: void 0, + }); } else if (op == UserOperation.BanPerson) { - let data = wsJsonToRes<BanPersonResponse>(msg).data; - this.state.postRes.comments - .filter(c => c.creator.id == data.person_view.person.id) - .forEach(c => (c.creator.banned = data.banned)); - if ( - this.state.postRes.post_view.creator.id == data.person_view.person.id - ) { - this.state.postRes.post_view.creator.banned = data.banned; - } - this.setState(this.state); + let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse); + this.state.postRes.match({ + some: res => { + res.comments + .filter(c => c.creator.id == data.person_view.person.id) + .forEach(c => (c.creator.banned = data.banned)); + if (res.post_view.creator.id == data.person_view.person.id) { + res.post_view.creator.banned = data.banned; + } + this.setState(this.state); + }, + none: void 0, + }); } else if (op == UserOperation.AddAdmin) { - let data = wsJsonToRes<AddAdminResponse>(msg).data; + let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse); this.state.siteRes.admins = data.admins; this.setState(this.state); } else if (op == UserOperation.Search) { - let data = wsJsonToRes<SearchResponse>(msg).data; - this.state.crossPosts = data.posts.filter( + let data = wsJsonToRes<SearchResponse>(msg, SearchResponse); + let xPosts = data.posts.filter( p => p.post.id != Number(this.props.match.params.id) ); + this.state.crossPosts = xPosts.length > 0 ? Some(xPosts) : None; this.setState(this.state); } else if (op == UserOperation.LeaveAdmin) { - let data = wsJsonToRes<GetSiteResponse>(msg).data; + let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse); this.state.siteRes = data; this.setState(this.state); } else if (op == UserOperation.TransferCommunity) { - let data = wsJsonToRes<GetCommunityResponse>(msg).data; - this.state.postRes.community_view = data.community_view; - this.state.postRes.post_view.community = data.community_view.community; - this.state.postRes.moderators = data.moderators; - this.setState(this.state); + let data = wsJsonToRes<GetCommunityResponse>(msg, GetCommunityResponse); + this.state.postRes.match({ + some: res => { + res.community_view = data.community_view; + res.post_view.community = data.community_view.community; + res.moderators = data.moderators; + this.setState(this.state); + }, + none: void 0, + }); } else if (op == UserOperation.BlockPerson) { - let data = wsJsonToRes<BlockPersonResponse>(msg).data; + let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse); updatePersonBlock(data); } else if (op == UserOperation.CreatePostReport) { - let data = wsJsonToRes<PostReportResponse>(msg).data; + let data = wsJsonToRes<PostReportResponse>(msg, PostReportResponse); if (data) { toast(i18n.t("report_created")); } } else if (op == UserOperation.CreateCommentReport) { - let data = wsJsonToRes<CommentReportResponse>(msg).data; + let data = wsJsonToRes<CommentReportResponse>(msg, CommentReportResponse); if (data) { toast(i18n.t("report_created")); } diff --git a/src/shared/components/private_message/create-private-message.tsx b/src/shared/components/private_message/create-private-message.tsx index b3129f6..a93ba99 100644 --- a/src/shared/components/private_message/create-private-message.tsx +++ b/src/shared/components/private_message/create-private-message.tsx @@ -1,34 +1,34 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component } from "inferno"; import { GetPersonDetails, GetPersonDetailsResponse, - PersonViewSafe, - SiteView, + GetSiteResponse, SortType, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { - authField, + auth, getRecipientIdFromProps, isBrowser, setIsoData, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; import { PrivateMessageForm } from "./private-message-form"; interface CreatePrivateMessageState { - site_view: SiteView; - recipient: PersonViewSafe; + siteRes: GetSiteResponse; + recipientDetailsRes: Option<GetPersonDetailsResponse>; recipient_id: number; loading: boolean; } @@ -37,11 +37,11 @@ export class CreatePrivateMessage extends Component< any, CreatePrivateMessageState > { - private isoData = setIsoData(this.context); + private isoData = setIsoData(this.context, GetPersonDetailsResponse); private subscription: Subscription; private emptyState: CreatePrivateMessageState = { - site_view: this.isoData.site_res.site_view, - recipient: undefined, + siteRes: this.isoData.site_res, + recipientDetailsRes: None, recipient_id: getRecipientIdFromProps(this.props), loading: true, }; @@ -54,14 +54,16 @@ export class CreatePrivateMessage extends Component< this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); - if (!UserService.Instance.myUserInfo) { + if (UserService.Instance.myUserInfo.isNone() && isBrowser()) { toast(i18n.t("not_logged_in"), "danger"); this.context.router.history.push(`/login`); } // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - this.state.recipient = this.isoData.routeData[0].user; + this.state.recipientDetailsRes = Some( + this.isoData.routeData[0] as GetPersonDetailsResponse + ); this.state.loading = false; } else { this.fetchPersonDetails(); @@ -69,30 +71,40 @@ export class CreatePrivateMessage extends Component< } fetchPersonDetails() { - let form: GetPersonDetails = { - person_id: this.state.recipient_id, - sort: SortType.New, - saved_only: false, - auth: authField(false), - }; + let form = new GetPersonDetails({ + person_id: Some(this.state.recipient_id), + sort: Some(SortType.New), + saved_only: Some(false), + username: None, + page: None, + limit: None, + community_id: None, + auth: auth(false).ok(), + }); WebSocketService.Instance.send(wsClient.getPersonDetails(form)); } static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { - let person_id = Number(req.path.split("/").pop()); - let form: GetPersonDetails = { + let person_id = Some(Number(req.path.split("/").pop())); + let form = new GetPersonDetails({ person_id, - sort: SortType.New, - saved_only: false, + sort: Some(SortType.New), + saved_only: Some(false), + username: None, + page: None, + limit: None, + community_id: None, auth: req.auth, - }; + }); return [req.client.getPersonDetails(form)]; } get documentTitle(): string { - return `${i18n.t("create_private_message")} - ${ - this.state.site_view.site.name - }`; + return this.state.recipientDetailsRes.match({ + some: res => + `${i18n.t("create_private_message")} - ${res.person_view.person.name}`, + none: "", + }); } componentWillUnmount() { @@ -107,21 +119,29 @@ export class CreatePrivateMessage extends Component< <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> {this.state.loading ? ( <h5> <Spinner large /> </h5> ) : ( - <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} - recipient={this.state.recipient.person} - /> - </div> - </div> + this.state.recipientDetailsRes.match({ + some: res => ( + <div class="row"> + <div class="col-12 col-lg-6 offset-lg-3 mb-4"> + <h5>{i18n.t("create_private_message")}</h5> + <PrivateMessageForm + privateMessageView={None} + onCreate={this.handlePrivateMessageCreate} + recipient={res.person_view.person} + /> + </div> + </div> + ), + none: <></>, + }) )} </div> ); @@ -143,8 +163,11 @@ export class CreatePrivateMessage extends Component< this.setState(this.state); return; } else if (op == UserOperation.GetPersonDetails) { - let data = wsJsonToRes<GetPersonDetailsResponse>(msg).data; - this.state.recipient = data.person_view; + let data = wsJsonToRes<GetPersonDetailsResponse>( + msg, + GetPersonDetailsResponse + ); + this.state.recipientDetailsRes = Some(data); this.state.loading = false; this.setState(this.state); } diff --git a/src/shared/components/private_message/private-message-form.tsx b/src/shared/components/private_message/private-message-form.tsx index 4f0e9d1..6264981 100644 --- a/src/shared/components/private_message/private-message-form.tsx +++ b/src/shared/components/private_message/private-message-form.tsx @@ -1,3 +1,4 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { Prompt } from "inferno-router"; @@ -8,21 +9,21 @@ import { PrivateMessageResponse, PrivateMessageView, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { WebSocketService } from "../../services"; import { - authField, + auth, capitalizeFirstLetter, isBrowser, relTags, setupTippy, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../../utils"; import { Icon, Spinner } from "../common/icon"; import { MarkdownTextArea } from "../common/markdown-textarea"; @@ -30,7 +31,7 @@ import { PersonListing } from "../person/person-listing"; interface PrivateMessageFormProps { recipient: PersonSafe; - privateMessage?: PrivateMessageView; // If a pm is given, that means this is an edit + privateMessageView: Option<PrivateMessageView>; // If a pm is given, that means this is an edit onCancel?(): any; onCreate?(message: PrivateMessageView): any; onEdit?(message: PrivateMessageView): any; @@ -49,11 +50,11 @@ export class PrivateMessageForm extends Component< > { private subscription: Subscription; private emptyState: PrivateMessageFormState = { - privateMessageForm: { + privateMessageForm: new CreatePrivateMessage({ content: null, recipient_id: this.props.recipient.id, - auth: authField(), - }, + auth: auth().unwrap(), + }), loading: false, previewMode: false, showDisclaimer: false, @@ -70,10 +71,11 @@ export class PrivateMessageForm extends Component< this.subscription = wsSubscribe(this.parseMessage); // Its an edit - if (this.props.privateMessage) { - this.state.privateMessageForm.content = - this.props.privateMessage.private_message.content; - } + this.props.privateMessageView.match({ + some: pm => + (this.state.privateMessageForm.content = pm.private_message.content), + none: void 0, + }); } componentDidMount() { @@ -103,7 +105,7 @@ export class PrivateMessageForm extends Component< message={i18n.t("block_leaving")} /> <form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}> - {!this.props.privateMessage && ( + {this.props.privateMessageView.isNone() && ( <div class="form-group row"> <label class="col-sm-2 col-form-label"> {capitalizeFirstLetter(i18n.t("to"))} @@ -128,7 +130,10 @@ export class PrivateMessageForm extends Component< </label> <div class="col-sm-10"> <MarkdownTextArea - initialContent={this.state.privateMessageForm.content} + initialContent={Some(this.state.privateMessageForm.content)} + placeholder={None} + buttonTitle={None} + maxLength={None} onContentChange={this.handleContentChange} /> </div> @@ -161,13 +166,13 @@ export class PrivateMessageForm extends Component< > {this.state.loading ? ( <Spinner /> - ) : this.props.privateMessage ? ( + ) : this.props.privateMessageView.isSome() ? ( capitalizeFirstLetter(i18n.t("save")) ) : ( capitalizeFirstLetter(i18n.t("send_message")) )} </button> - {this.props.privateMessage && ( + {this.props.privateMessageView.isSome() && ( <button type="button" class="btn btn-secondary" @@ -188,18 +193,19 @@ export class PrivateMessageForm extends Component< handlePrivateMessageSubmit(i: PrivateMessageForm, event: any) { event.preventDefault(); - if (i.props.privateMessage) { - let form: EditPrivateMessage = { - private_message_id: i.props.privateMessage.private_message.id, - content: i.state.privateMessageForm.content, - auth: authField(), - }; - WebSocketService.Instance.send(wsClient.editPrivateMessage(form)); - } else { - WebSocketService.Instance.send( + i.props.privateMessageView.match({ + some: pm => { + let form = new EditPrivateMessage({ + private_message_id: pm.private_message.id, + content: i.state.privateMessageForm.content, + auth: auth().unwrap(), + }); + WebSocketService.Instance.send(wsClient.editPrivateMessage(form)); + }, + none: WebSocketService.Instance.send( wsClient.createPrivateMessage(i.state.privateMessageForm) - ); - } + ), + }); i.state.loading = true; i.setState(i.state); } @@ -237,11 +243,17 @@ export class PrivateMessageForm extends Component< op == UserOperation.DeletePrivateMessage || op == UserOperation.MarkPrivateMessageAsRead ) { - let data = wsJsonToRes<PrivateMessageResponse>(msg).data; + let data = wsJsonToRes<PrivateMessageResponse>( + msg, + PrivateMessageResponse + ); this.state.loading = false; this.props.onEdit(data.private_message_view); } else if (op == UserOperation.CreatePrivateMessage) { - let data = wsJsonToRes<PrivateMessageResponse>(msg).data; + let data = wsJsonToRes<PrivateMessageResponse>( + msg, + PrivateMessageResponse + ); this.state.loading = false; this.props.onCreate(data.private_message_view); this.setState(this.state); diff --git a/src/shared/components/private_message/private-message.tsx b/src/shared/components/private_message/private-message.tsx index 1a7c3b3..5766260 100644 --- a/src/shared/components/private_message/private-message.tsx +++ b/src/shared/components/private_message/private-message.tsx @@ -1,3 +1,4 @@ +import { None, Some } from "@sniptt/monads/build"; import { Component, linkEvent } from "inferno"; import { DeletePrivateMessage, @@ -7,7 +8,7 @@ import { } from "lemmy-js-client"; import { i18n } from "../../i18next"; import { UserService, WebSocketService } from "../../services"; -import { authField, mdToHtml, toast, wsClient } from "../../utils"; +import { auth, mdToHtml, toast, wsClient } from "../../utils"; import { Icon } from "../common/icon"; import { MomentTime } from "../common/moment-time"; import { PersonListing } from "../person/person-listing"; @@ -46,16 +47,17 @@ export class PrivateMessage extends Component< } get mine(): boolean { - return ( - UserService.Instance.myUserInfo && - UserService.Instance.myUserInfo.local_user_view.person.id == - this.props.private_message_view.creator.id - ); + return UserService.Instance.myUserInfo + .map( + m => + m.local_user_view.person.id == + this.props.private_message_view.creator.id + ) + .unwrapOr(false); } render() { let message_view = this.props.private_message_view; - // TODO check this again let otherPerson: PersonSafe = this.mine ? message_view.recipient : message_view.creator; @@ -73,7 +75,10 @@ export class PrivateMessage extends Component< </li> <li className="list-inline-item"> <span> - <MomentTime data={message_view.private_message} /> + <MomentTime + published={message_view.private_message.published} + updated={message_view.private_message.updated} + /> </span> </li> <li className="list-inline-item"> @@ -93,7 +98,7 @@ export class PrivateMessage extends Component< {this.state.showEdit && ( <PrivateMessageForm recipient={otherPerson} - privateMessage={message_view} + privateMessageView={Some(message_view)} onEdit={this.handlePrivateMessageEdit} onCreate={this.handlePrivateMessageCreate} onCancel={this.handleReplyCancel} @@ -207,6 +212,7 @@ export class PrivateMessage extends Component< {this.state.showReply && ( <PrivateMessageForm recipient={otherPerson} + privateMessageView={None} onCreate={this.handlePrivateMessageCreate} /> )} @@ -232,11 +238,11 @@ export class PrivateMessage extends Component< } handleDeleteClick(i: PrivateMessage) { - let form: DeletePrivateMessage = { + let form = new DeletePrivateMessage({ private_message_id: i.props.private_message_view.private_message.id, deleted: !i.props.private_message_view.private_message.deleted, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.deletePrivateMessage(form)); } @@ -247,11 +253,11 @@ export class PrivateMessage extends Component< } handleMarkRead(i: PrivateMessage) { - let form: MarkPrivateMessageAsRead = { + let form = new MarkPrivateMessageAsRead({ private_message_id: i.props.private_message_view.private_message.id, read: !i.props.private_message_view.private_message.read, - auth: authField(), - }; + auth: auth().unwrap(), + }); WebSocketService.Instance.send(wsClient.markPrivateMessageAsRead(form)); } @@ -271,14 +277,15 @@ export class PrivateMessage extends Component< } handlePrivateMessageCreate(message: PrivateMessageView) { - if ( - UserService.Instance.myUserInfo && - message.creator.id == - UserService.Instance.myUserInfo.local_user_view.person.id - ) { - this.state.showReply = false; - this.setState(this.state); - toast(i18n.t("message_sent")); - } + UserService.Instance.myUserInfo.match({ + some: mui => { + if (message.creator.id == mui.local_user_view.person.id) { + this.state.showReply = false; + this.setState(this.state); + toast(i18n.t("message_sent")); + } + }, + none: void 0, + }); } } diff --git a/src/shared/components/search.tsx b/src/shared/components/search.tsx index 373b5a0..b6b0567 100644 --- a/src/shared/components/search.tsx +++ b/src/shared/components/search.tsx @@ -1,10 +1,14 @@ +import { None, Option, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { CommentResponse, CommentView, CommunityView, GetCommunity, + GetCommunityResponse, GetPersonDetails, + GetPersonDetailsResponse, + GetSiteResponse, ListCommunities, ListCommunitiesResponse, ListingType, @@ -16,16 +20,17 @@ import { Search as SearchForm, SearchResponse, SearchType, - Site, SortType, UserOperation, + wsJsonToRes, + wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { InitialFetchRequest } from "shared/interfaces"; import { i18n } from "../i18next"; import { WebSocketService } from "../services"; import { - authField, + auth, capitalizeFirstLetter, choicesConfig, commentsToFlatNodes, @@ -34,6 +39,8 @@ import { createCommentLikeRes, createPostLikeFindRes, debounce, + enableDownvotes, + enableNsfw, fetchCommunities, fetchLimit, fetchUsers, @@ -48,13 +55,10 @@ import { routeSortTypeToEnum, saveScrollPosition, setIsoData, - setOptionalAuth, showLocal, toast, wsClient, - wsJsonToRes, wsSubscribe, - wsUserOp, } from "../utils"; import { CommentNodes } from "./comment/comment-nodes"; import { HtmlTags } from "./common/html-tags"; @@ -89,13 +93,13 @@ interface SearchState { communityId: number; creatorId: number; page: number; - searchResponse?: SearchResponse; + searchResponse: Option<SearchResponse>; communities: CommunityView[]; - creator?: PersonViewSafe; + creatorDetails: Option<GetPersonDetailsResponse>; loading: boolean; - site: Site; + siteRes: GetSiteResponse; searchText: string; - resolveObjectResponse?: ResolveObjectResponse; + resolveObjectResponse: Option<ResolveObjectResponse>; } interface UrlParams { @@ -115,7 +119,14 @@ interface Combined { } export class Search extends Component<any, SearchState> { - private isoData = setIsoData(this.context); + private isoData = setIsoData( + this.context, + GetCommunityResponse, + ListCommunitiesResponse, + GetPersonDetailsResponse, + SearchResponse, + ResolveObjectResponse + ); private communityChoices: any; private creatorChoices: any; private subscription: Subscription; @@ -132,10 +143,11 @@ export class Search extends Component<any, SearchState> { this.props.match.params.community_id ), creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id), - searchResponse: null, - resolveObjectResponse: null, + searchResponse: None, + resolveObjectResponse: None, + creatorDetails: None, loading: true, - site: this.isoData.site_res.site_view.site, + siteRes: this.isoData.site_res, communities: [], }; @@ -180,20 +192,29 @@ export class Search extends Component<any, SearchState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { - let singleOrMultipleCommunities = this.isoData.routeData[0]; - if (singleOrMultipleCommunities.communities) { - this.state.communities = this.isoData.routeData[0].communities; - } else { - this.state.communities = [this.isoData.routeData[0].community_view]; - } + let communityRes = Some( + this.isoData.routeData[0] as GetCommunityResponse + ); + let communitiesRes = Some( + this.isoData.routeData[1] as ListCommunitiesResponse + ); + + // This can be single or multiple communities given + this.state.communities = communitiesRes + .map(c => c.communities) + .unwrapOr([communityRes.map(c => c.community_view).unwrap()]); + + this.state.creatorDetails = Some( + this.isoData.routeData[2] as GetPersonDetailsResponse + ); - let creator = this.isoData.routeData[1]; - if (creator?.person_view) { - this.state.creator = this.isoData.routeData[1].person_view; - } if (this.state.q != "") { - this.state.searchResponse = this.isoData.routeData[2]; - this.state.resolveObjectResponse = this.isoData.routeData[3]; + this.state.searchResponse = Some( + this.isoData.routeData[3] as SearchResponse + ); + this.state.resolveObjectResponse = Some( + this.isoData.routeData[4] as ResolveObjectResponse + ); this.state.loading = false; } else { this.search(); @@ -231,12 +252,13 @@ export class Search extends Component<any, SearchState> { } fetchCommunities() { - let listCommunitiesForm: ListCommunities = { - type_: ListingType.All, - sort: SortType.TopAll, - limit: fetchLimit, - auth: authField(false), - }; + let listCommunitiesForm = new ListCommunities({ + type_: Some(ListingType.All), + sort: Some(SortType.TopAll), + limit: Some(fetchLimit), + page: None, + auth: auth(false).ok(), + }); WebSocketService.Instance.send( wsClient.listCommunities(listCommunitiesForm) ); @@ -247,59 +269,76 @@ export class Search extends Component<any, SearchState> { let promises: Promise<any>[] = []; let communityId = this.getCommunityIdFromProps(pathSplit[11]); - if (communityId !== 0) { - let getCommunityForm: GetCommunity = { - id: communityId, - }; - setOptionalAuth(getCommunityForm, req.auth); - promises.push(req.client.getCommunity(getCommunityForm)); - } else { - let listCommunitiesForm: ListCommunities = { - type_: ListingType.All, - sort: SortType.TopAll, - limit: fetchLimit, - }; - setOptionalAuth(listCommunitiesForm, req.auth); - promises.push(req.client.listCommunities(listCommunitiesForm)); - } + let community_id: Option<number> = + communityId == 0 ? None : Some(communityId); + community_id.match({ + some: id => { + let getCommunityForm = new GetCommunity({ + id: Some(id), + name: None, + auth: req.auth, + }); + promises.push(req.client.getCommunity(getCommunityForm)); + promises.push(Promise.resolve()); + }, + none: () => { + let listCommunitiesForm = new ListCommunities({ + type_: Some(ListingType.All), + sort: Some(SortType.TopAll), + limit: Some(fetchLimit), + page: None, + auth: req.auth, + }); + promises.push(Promise.resolve()); + promises.push(req.client.listCommunities(listCommunitiesForm)); + }, + }); let creatorId = this.getCreatorIdFromProps(pathSplit[13]); - if (creatorId !== 0) { - let getCreatorForm: GetPersonDetails = { - person_id: creatorId, - }; - setOptionalAuth(getCreatorForm, req.auth); - promises.push(req.client.getPersonDetails(getCreatorForm)); - } else { - promises.push(Promise.resolve()); - } + let creator_id: Option<number> = creatorId == 0 ? None : Some(creatorId); + creator_id.match({ + some: id => { + let getCreatorForm = new GetPersonDetails({ + person_id: Some(id), + username: None, + sort: None, + page: None, + limit: None, + community_id: None, + saved_only: None, + auth: req.auth, + }); + promises.push(req.client.getPersonDetails(getCreatorForm)); + }, + none: () => { + promises.push(Promise.resolve()); + }, + }); - let form: SearchForm = { + let form = new SearchForm({ q: this.getSearchQueryFromProps(pathSplit[3]), - type_: this.getSearchTypeFromProps(pathSplit[5]), - sort: this.getSortTypeFromProps(pathSplit[7]), - listing_type: this.getListingTypeFromProps(pathSplit[9]), - page: this.getPageFromProps(pathSplit[15]), - limit: fetchLimit, - }; - if (communityId !== 0) { - form.community_id = communityId; - } - if (creatorId !== 0) { - form.creator_id = creatorId; - } - setOptionalAuth(form, req.auth); + community_id, + community_name: None, + creator_id, + type_: Some(this.getSearchTypeFromProps(pathSplit[5])), + sort: Some(this.getSortTypeFromProps(pathSplit[7])), + listing_type: Some(this.getListingTypeFromProps(pathSplit[9])), + page: Some(this.getPageFromProps(pathSplit[15])), + limit: Some(fetchLimit), + auth: req.auth, + }); - let resolveObjectForm: ResolveObject = { + let resolveObjectForm = new ResolveObject({ q: this.getSearchQueryFromProps(pathSplit[3]), - }; - setOptionalAuth(resolveObjectForm, req.auth); + auth: req.auth, + }); if (form.q != "") { - //this.state.loading = false; - //this.setState(this.state); promises.push(req.client.search(form)); promises.push(req.client.resolveObject(resolveObjectForm)); + } else { + promises.push(Promise.resolve()); + promises.push(Promise.resolve()); } return promises; @@ -318,19 +357,21 @@ export class Search extends Component<any, SearchState> { this.setState({ loading: true, searchText: this.state.q, - searchResponse: null, - resolveObjectResponse: null, + searchResponse: None, + resolveObjectResponse: None, }); this.search(); } } get documentTitle(): string { - if (this.state.q) { - return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`; - } else { - return `${i18n.t("search")} - ${this.state.site.name}`; - } + return this.state.siteRes.site_view.match({ + some: siteView => + this.state.q + ? `${i18n.t("search")} - ${this.state.q} - ${siteView.site.name}` + : `${i18n.t("search")} - ${siteView.site.name}`, + none: "", + }); } render() { @@ -339,6 +380,8 @@ export class Search extends Component<any, SearchState> { <HtmlTags title={this.documentTitle} path={this.context.router.route.match.url} + description={None} + image={None} /> <h5>{i18n.t("search")}</h5> {this.selects()} @@ -459,46 +502,52 @@ export class Search extends Component<any, SearchState> { let combined: Combined[] = []; // Push the possible resolve / federated objects first - let resolveComment = this.state.resolveObjectResponse?.comment; - if (resolveComment) { - combined.push(this.commentViewToCombined(resolveComment)); - } - let resolvePost = this.state.resolveObjectResponse?.post; - if (resolvePost) { - combined.push(this.postViewToCombined(resolvePost)); - } - let resolveCommunity = this.state.resolveObjectResponse?.community; - if (resolveCommunity) { - combined.push(this.communityViewToCombined(resolveCommunity)); - } - let resolveUser = this.state.resolveObjectResponse?.person; - if (resolveUser) { - combined.push(this.personViewSafeToCombined(resolveUser)); - } + this.state.resolveObjectResponse.match({ + some: res => { + let resolveComment = res.comment; + if (resolveComment.isSome()) { + combined.push(this.commentViewToCombined(resolveComment.unwrap())); + } + let resolvePost = res.post; + if (resolvePost.isSome()) { + combined.push(this.postViewToCombined(resolvePost.unwrap())); + } + let resolveCommunity = res.community; + if (resolveCommunity.isSome()) { + combined.push( + this.communityViewToCombined(resolveCommunity.unwrap()) + ); + } + let resolveUser = res.person; + if (resolveUser.isSome()) { + combined.push(this.personViewSafeToCombined(resolveUser.unwrap())); + } + }, + none: void 0, + }); // Push the search results - pushNotNull( - combined, - this.state.searchResponse?.comments?.map(e => - this.commentViewToCombined(e) - ) - ); - pushNotNull( - combined, - this.state.searchResponse?.posts?.map(e => this.postViewToCombined(e)) - ); - pushNotNull( - combined, - this.state.searchResponse?.communities?.map(e => - this.communityViewToCombined(e) - ) - ); - pushNotNull( - combined, - this.state.searchResponse?.users?.map(e => - this.personViewSafeToCombined(e) - ) - ); + this.state.searchResponse.match({ + some: res => { + pushNotNull( + combined, + res.comments?.map(e => this.commentViewToCombined(e)) + ); + pushNotNull( + combined, + res.posts?.map(e => this.postViewToCombined(e)) + ); + pushNotNull( + combined, + res.communities?.map(e => this.communityViewToCombined(e)) + ); + pushNotNull( + combined, + res.users?.map(e => this.personViewSafeToCombined(e)) + ); + }, + none: void 0, + }); // Sort it if (this.state.sort == SortType.New) { @@ -528,18 +577,24 @@ export class Search extends Component<any, SearchState> { <PostListing key={(i.data as PostView).post.id} post_view={i.data as PostView} + duplicates={None} + moderators={None} + admins={None} showCommunity - enableDownvotes={this.state.site.enable_downvotes} - enableNsfw={this.state.site.enable_nsfw} + enableDownvotes={enableDownvotes(this.state.siteRes)} + enableNsfw={enableNsfw(this.state.siteRes)} /> )} {i.type_ == "comments" && ( <CommentNodes key={(i.data as CommentView).comment.id} nodes={[{ comment_view: i.data as CommentView }]} + moderators={None} + admins={None} + maxCommentsShown={None} locked noIndent - enableDownvotes={this.state.site.enable_downvotes} + enableDownvotes={enableDownvotes(this.state.siteRes)} /> )} {i.type_ == "communities" && ( @@ -558,15 +613,24 @@ export class Search extends Component<any, SearchState> { comments() { let comments: CommentView[] = []; - pushNotNull(comments, this.state.resolveObjectResponse?.comment); - pushNotNull(comments, this.state.searchResponse?.comments); + this.state.resolveObjectResponse.match({ + some: res => pushNotNull(comments, res.comment), + none: void 0, + }); + this.state.searchResponse.match({ + some: res => pushNotNull(comments, res.comments), + none: void 0, + }); return ( <CommentNodes nodes={commentsToFlatNodes(comments)} locked noIndent - enableDownvotes={this.state.site.enable_downvotes} + moderators={None} + admins={None} + maxCommentsShown={None} + enableDownvotes={enableDownvotes(this.state.siteRes)} /> ); } @@ -574,8 +638,14 @@ export class Search extends Component<any, SearchState> { posts() { let posts: PostView[] = []; - pushNotNull(posts, this.state.resolveObjectResponse?.post); - pushNotNull(posts, this.state.searchResponse?.posts); + this.state.resolveObjectResponse.match({ + some: res => pushNotNull(posts, res.post), + none: void 0, + }); + this.state.searchResponse.match({ + some: res => pushNotNull(posts, res.posts), + none: void 0, + }); return ( <> @@ -585,8 +655,11 @@ export class Search extends Component<any, SearchState> { <PostListing post_view={post} showCommunity - enableDownvotes={this.state.site.enable_downvotes} - enableNsfw={this.state.site.enable_nsfw} + duplicates={None} + moderators={None} + admins={None} + enableDownvotes={enableDownvotes(this.state.siteRes)} + enableNsfw={enableNsfw(this.state.siteRes)} /> </div> </div> @@ -598,8 +671,14 @@ export class Search extends Component<any, SearchState> { communities() { let communities: CommunityView[] = []; - pushNotNull(communities, this.state.resolveObjectResponse?.community); - pushNotNull(communities, this.state.searchResponse?.communities); + this.state.resolveObjectResponse.match({ + some: res => pushNotNull(communities, res.community), + none: void 0, + }); + this.state.searchResponse.match({ + some: res => pushNotNull(communities, res.communities), + none: void 0, + }); return ( <> @@ -615,8 +694,14 @@ export class Search extends Component<any, SearchState> { users() { let users: PersonViewSafe[] = []; - pushNotNull(users, this.state.resolveObjectResponse?.person); - pushNotNull(users, this.state.searchResponse?.users); + this.state.resolveObjectResponse.match({ + some: res => pushNotNull(users, res.person), + none: void 0, + }); + this.state.searchResponse.match({ + some: res => pushNotNull(users, res.users), + none: void 0, + }); return ( <> @@ -692,11 +777,14 @@ export class Search extends Component<any, SearchState> { value={this.state.creatorId} > <option value="0">{i18n.t("all")}</option> - {this.state.creator && ( - <option value={this.state.creator.person.id}> - {personSelectName(this.state.creator)} - </option> - )} + {this.state.creatorDetails.match({ + some: creator => ( + <option value={creator.person_view.person.id}> + {personSelectName(creator.person_view)} + </option> + ), + none: <></>, + })} </select> </div> </div> @@ -704,19 +792,21 @@ export class Search extends Component<any, SearchState> { } resultsCount(): number { - let res = this.state.searchResponse; - let resObj = this.state.resolveObjectResponse; - let resObjCount = - resObj?.post || resObj?.person || resObj?.community || resObj?.comment - ? 1 - : 0; - return ( - res?.posts?.length + - res?.comments?.length + - res?.communities?.length + - res?.users?.length + - resObjCount - ); + let searchCount = this.state.searchResponse + .map( + r => + r.posts?.length + + r.comments?.length + + r.communities?.length + + r.users?.length + ) + .unwrapOr(0); + + let resObjCount = this.state.resolveObjectResponse + .map(r => (r.post || r.person || r.community || r.comment ? 1 : 0)) + .unwrapOr(0); + + return resObjCount + searchCount; } handlePageChange(page: number) { @@ -724,30 +814,34 @@ export class Search extends Component<any, SearchState> { } search() { - let form: SearchForm = { + let community_id: Option<number> = + this.state.communityId == 0 ? None : Some(this.state.communityId); + let creator_id: Option<number> = + this.state.creatorId == 0 ? None : Some(this.state.creatorId); + + console.log(community_id.unwrapOr(-22)); + + let form = new SearchForm({ q: this.state.q, - type_: this.state.type_, - sort: this.state.sort, - listing_type: this.state.listingType, - page: this.state.page, - limit: fetchLimit, - auth: authField(false), - }; - if (this.state.communityId !== 0) { - form.community_id = this.state.communityId; - } - if (this.state.creatorId !== 0) { - form.creator_id = this.state.creatorId; - } + community_id, + community_name: None, + creator_id, + type_: Some(this.state.type_), + sort: Some(this.state.sort), + listing_type: Some(this.state.listingType), + page: Some(this.state.page), + limit: Some(fetchLimit), + auth: auth(false).ok(), + }); - let resolveObjectForm: ResolveObject = { + let resolveObjectForm = new ResolveObject({ q: this.state.q, - auth: authField(false), - }; + auth: auth(false).ok(), + }); if (this.state.q != "") { - this.state.searchResponse = null; - this.state.resolveObjectResponse = null; + this.state.searchResponse = None; + this.state.resolveObjectResponse = None; this.state.loading = true; this.setState(this.state); WebSocketService.Instance.send(wsClient.search(form)); @@ -890,50 +984,56 @@ export class Search extends Component<any, SearchState> { let op = wsUserOp(msg); if (msg.error) { if (msg.error == "couldnt_find_object") { - this.state.resolveObjectResponse = { - comment: null, - post: null, - community: null, - person: null, - }; + this.state.resolveObjectResponse = Some({ + comment: None, + post: None, + community: None, + person: None, + }); this.checkFinishedLoading(); } else { toast(i18n.t(msg.error), "danger"); return; } } else if (op == UserOperation.Search) { - let data = wsJsonToRes<SearchResponse>(msg).data; - this.state.searchResponse = data; + let data = wsJsonToRes<SearchResponse>(msg, SearchResponse); + this.state.searchResponse = Some(data); window.scrollTo(0, 0); this.checkFinishedLoading(); restoreScrollPosition(this.context); } else if (op == UserOperation.CreateCommentLike) { - let data = wsJsonToRes<CommentResponse>(msg).data; + let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); createCommentLikeRes( data.comment_view, - this.state.searchResponse?.comments + this.state.searchResponse.map(r => r.comments).unwrapOr([]) ); this.setState(this.state); } else if (op == UserOperation.CreatePostLike) { - let data = wsJsonToRes<PostResponse>(msg).data; - createPostLikeFindRes(data.post_view, this.state.searchResponse?.posts); + let data = wsJsonToRes<PostResponse>(msg, PostResponse); + createPostLikeFindRes( + data.post_view, + this.state.searchResponse.map(r => r.posts).unwrapOr([]) + ); this.setState(this.state); } else if (op == UserOperation.ListCommunities) { - let data = wsJsonToRes<ListCommunitiesResponse>(msg).data; + let data = wsJsonToRes<ListCommunitiesResponse>( + msg, + ListCommunitiesResponse + ); this.state.communities = data.communities; this.setState(this.state); this.setupCommunityFilter(); } else if (op == UserOperation.ResolveObject) { - let data = wsJsonToRes<ResolveObjectResponse>(msg).data; - this.state.resolveObjectResponse = data; + let data = wsJsonToRes<ResolveObjectResponse>(msg, ResolveObjectResponse); + this.state.resolveObjectResponse = Some(data); this.checkFinishedLoading(); } } checkFinishedLoading() { if ( - this.state.searchResponse != null && - this.state.resolveObjectResponse != null + this.state.searchResponse.isSome() && + this.state.resolveObjectResponse.isSome() ) { this.state.loading = false; this.setState(this.state); diff --git a/src/shared/interfaces.ts b/src/shared/interfaces.ts index e09f3bd..92fabb8 100644 --- a/src/shared/interfaces.ts +++ b/src/shared/interfaces.ts @@ -1,3 +1,4 @@ +import { Either, Option } from "@sniptt/monads"; import { CommentView, GetSiteResponse, @@ -5,6 +6,9 @@ import { PersonMentionView, } from "lemmy-js-client"; +/** + * This contains serialized data, it needs to be deserialized before use. + */ export interface IsoData { path: string; routeData: any[]; @@ -23,9 +27,9 @@ declare global { } export interface InitialFetchRequest { - auth: string; - path: string; + auth: Option<string>; client: LemmyHttp; + path: string; } export interface CommentNode { @@ -35,11 +39,10 @@ export interface CommentNode { } export interface PostFormParams { - name: string; - url?: string; - body?: string; - community_name?: string; - community_id?: number; + name: Option<string>; + url: Option<string>; + body: Option<string>; + nameOrId: Option<Either<string, number>>; } export enum CommentSortType { diff --git a/src/shared/services/UserService.ts b/src/shared/services/UserService.ts index 031cf7d..678d112 100644 --- a/src/shared/services/UserService.ts +++ b/src/shared/services/UserService.ts @@ -1,9 +1,12 @@ // import Cookies from 'js-cookie'; +import { Err, None, Ok, Option, Result, Some } from "@sniptt/monads"; import IsomorphicCookie from "isomorphic-cookie"; import jwt_decode from "jwt-decode"; import { LoginResponse, MyUserInfo } from "lemmy-js-client"; import { BehaviorSubject, Subject } from "rxjs"; import { isHttps } from "../env"; +import { i18n } from "../i18next"; +import { isBrowser, toast } from "../utils"; interface Claims { sub: number; @@ -11,11 +14,16 @@ interface Claims { iat: number; } +interface JwtInfo { + claims: Claims; + jwt: string; +} + export class UserService { private static _instance: UserService; - public myUserInfo: MyUserInfo; - public claims: Claims; - public jwtSub: Subject<string> = new Subject<string>(); + public myUserInfo: Option<MyUserInfo> = None; + public jwtInfo: Option<JwtInfo> = None; + public jwtSub: Subject<Option<JwtInfo>> = new Subject<Option<JwtInfo>>(); public unreadInboxCountSub: BehaviorSubject<number> = new BehaviorSubject<number>(0); public unreadReportCountSub: BehaviorSubject<number> = @@ -24,12 +32,7 @@ export class UserService { new BehaviorSubject<number>(0); private constructor() { - if (this.auth) { - this.setClaims(this.auth); - } else { - // setTheme(); - console.log("No JWT cookie found."); - } + this.setJwtInfo(); } public login(res: LoginResponse) { @@ -37,26 +40,42 @@ export class UserService { expires.setDate(expires.getDate() + 365); IsomorphicCookie.save("jwt", res.jwt, { expires, secure: isHttps }); console.log("jwt cookie set"); - this.setClaims(res.jwt); + this.setJwtInfo(); } public logout() { - this.claims = undefined; - this.myUserInfo = undefined; - // setTheme(); - this.jwtSub.next(""); + this.jwtInfo = None; + this.myUserInfo = None; + this.jwtSub.next(this.jwtInfo); IsomorphicCookie.remove("jwt"); // TODO is sometimes unreliable for some reason document.cookie = "jwt=; Max-Age=0; path=/; domain=" + location.host; + location.reload(); // TODO may not be necessary anymore console.log("Logged out."); } - public get auth(): string { - return IsomorphicCookie.load("jwt"); + public auth(throwErr = true): Result<string, string> { + // Can't use match to convert to result for some reason + let jwt = this.jwtInfo.map(j => j.jwt); + if (jwt.isSome()) { + return Ok(jwt.unwrap()); + } else { + let msg = "No JWT cookie found"; + if (throwErr && isBrowser()) { + console.log(msg); + toast(i18n.t("not_logged_in"), "danger"); + } + return Err(msg); + } } - private setClaims(jwt: string) { - this.claims = jwt_decode(jwt); - this.jwtSub.next(jwt); + private setJwtInfo() { + let jwt = IsomorphicCookie.load("jwt"); + + if (jwt) { + let jwtInfo: JwtInfo = { jwt, claims: jwt_decode(jwt) }; + this.jwtInfo = Some(jwtInfo); + this.jwtSub.next(this.jwtInfo); + } } public static get Instance() { diff --git a/src/shared/services/WebSocketService.ts b/src/shared/services/WebSocketService.ts index 87d8f97..7d7a46d 100644 --- a/src/shared/services/WebSocketService.ts +++ b/src/shared/services/WebSocketService.ts @@ -1,4 +1,3 @@ -import { PersonViewSafe, WebSocketJsonResponse } from "lemmy-js-client"; import { Observable } from "rxjs"; import { share } from "rxjs/operators"; import { @@ -15,9 +14,6 @@ export class WebSocketService { private ws: WS; public subject: Observable<any>; - public admins: PersonViewSafe[]; - public banned: PersonViewSafe[]; - private constructor() { let firstConnect = true; @@ -34,7 +30,7 @@ export class WebSocketService { console.log(`Connected to ${wsUri}`); if (!firstConnect) { - let res: WebSocketJsonResponse<any> = { + let res = { reconnect: true, }; obs.next(res); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 0199f47..222190a 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,3 +1,5 @@ +import { None, Option, Result, Some } from "@sniptt/monads"; +import { ClassConstructor, deserialize, serialize } from "class-transformer"; import emojiShortName from "emoji-short-name"; import { BlockCommunityResponse, @@ -5,6 +7,7 @@ import { CommentReportView, CommentView, CommunityBlockView, + CommunityModeratorView, CommunityView, GetSiteMetadata, GetSiteResponse, @@ -22,9 +25,6 @@ import { Search, SearchType, SortType, - UserOperation, - WebSocketJsonResponse, - WebSocketResponse, } from "lemmy-js-client"; import markdown_it from "markdown-it"; import markdown_it_container from "markdown-it-container"; @@ -72,6 +72,7 @@ export const elementUrl = "https://element.io"; export const postRefetchSeconds: number = 60 * 1000; export const fetchLimit = 20; +export const trendingFetchLimit = 6; export const mentionDropdownFetchLimit = 10; export const relTags = "noopener nofollow"; @@ -98,20 +99,6 @@ export function randomStr( .join(""); } -export function wsJsonToRes<ResponseType>( - msg: WebSocketJsonResponse<ResponseType> -): WebSocketResponse<ResponseType> { - return { - op: wsUserOp(msg), - data: msg.data, - }; -} - -export function wsUserOp(msg: any): UserOperation { - let opStr: string = msg.op; - return UserOperation[opStr]; -} - export const md = new markdown_it({ html: false, linkify: true, @@ -192,30 +179,135 @@ export function futureDaysToUnixTime(days: number): number { } export function canMod( - myUserInfo: MyUserInfo, - modIds: number[], + mods: Option<CommunityModeratorView[]>, + admins: Option<PersonViewSafe[]>, creator_id: number, + myUserInfo = UserService.Instance.myUserInfo, onSelf = false ): boolean { // You can do moderator actions only on the mods added after you. - if (myUserInfo) { - let yourIndex = modIds.findIndex( - id => id == myUserInfo.local_user_view.person.id - ); - if (yourIndex == -1) { - return false; - } else { - // onSelf +1 on mod actions not for yourself, IE ban, remove, etc - modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1)); - return !modIds.includes(creator_id); - } - } else { - return false; - } + let adminsThenMods = admins + .unwrapOr([]) + .map(a => a.person.id) + .concat(mods.unwrapOr([]).map(m => m.moderator.id)); + + return myUserInfo.match({ + some: me => { + let myIndex = adminsThenMods.findIndex( + id => id == me.local_user_view.person.id + ); + if (myIndex == -1) { + return false; + } else { + // onSelf +1 on mod actions not for yourself, IE ban, remove, etc + adminsThenMods = adminsThenMods.slice(0, myIndex + (onSelf ? 0 : 1)); + return !adminsThenMods.includes(creator_id); + } + }, + none: false, + }); +} + +export function canAdmin( + admins: Option<PersonViewSafe[]>, + creator_id: number, + myUserInfo = UserService.Instance.myUserInfo +): boolean { + return canMod(None, admins, creator_id, myUserInfo); +} + +export function isMod( + mods: Option<CommunityModeratorView[]>, + creator_id: number +): boolean { + return mods.match({ + some: mods => mods.map(m => m.moderator.id).includes(creator_id), + none: false, + }); +} + +export function amMod( + mods: Option<CommunityModeratorView[]>, + myUserInfo = UserService.Instance.myUserInfo +): boolean { + return myUserInfo.match({ + some: mui => isMod(mods, mui.local_user_view.person.id), + none: false, + }); } -export function isMod(modIds: number[], creator_id: number): boolean { - return modIds.includes(creator_id); +export function isAdmin( + admins: Option<PersonViewSafe[]>, + creator_id: number +): boolean { + return admins.match({ + some: admins => admins.map(a => a.person.id).includes(creator_id), + none: false, + }); +} + +export function amAdmin( + admins: Option<PersonViewSafe[]>, + myUserInfo = UserService.Instance.myUserInfo +): boolean { + return myUserInfo.match({ + some: mui => isAdmin(admins, mui.local_user_view.person.id), + none: false, + }); +} + +export function amCommunityCreator( + mods: Option<CommunityModeratorView[]>, + creator_id: number, + myUserInfo = UserService.Instance.myUserInfo +): boolean { + return mods.match({ + some: mods => + myUserInfo + .map(mui => mui.local_user_view.person.id) + .match({ + some: myId => + myId == mods[0].moderator.id && + // Don't allow mod actions on yourself + myId != creator_id, + none: false, + }), + none: false, + }); +} + +export function amSiteCreator( + admins: Option<PersonViewSafe[]>, + creator_id: number, + myUserInfo = UserService.Instance.myUserInfo +): boolean { + return admins.match({ + some: admins => + myUserInfo + .map(mui => mui.local_user_view.person.id) + .match({ + some: myId => + myId == admins[0].person.id && + // Don't allow mod actions on yourself + myId != creator_id, + none: false, + }), + none: false, + }); +} + +export function amTopMod( + mods: Option<CommunityModeratorView[]>, + myUserInfo = UserService.Instance.myUserInfo +): boolean { + return mods.match({ + some: mods => + myUserInfo.match({ + some: mui => mods[0].moderator.id == mui.local_user_view.person.id, + none: false, + }), + none: false, + }); } const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/; @@ -321,13 +413,14 @@ export function debounce(func: any, wait = 1000, immediate = false) { }; } -export function getLanguages(override?: string): string[] { - let myUserInfo = UserService.Instance.myUserInfo; - let lang = - override || - (myUserInfo?.local_user_view.local_user.lang - ? myUserInfo.local_user_view.local_user.lang - : "browser"); +export function getLanguages( + override?: string, + myUserInfo = UserService.Instance.myUserInfo +): string[] { + let myLang = myUserInfo + .map(m => m.local_user_view.local_user.lang) + .unwrapOr("browser"); + let lang = override || myLang; if (lang == "browser" && isBrowser()) { return getBrowserLanguages(); @@ -406,24 +499,26 @@ export function objectFlip(obj: any) { return ret; } -export function showAvatars(): boolean { - return ( - UserService.Instance.myUserInfo?.local_user_view.local_user.show_avatars || - !UserService.Instance.myUserInfo - ); +export function showAvatars( + myUserInfo: Option<MyUserInfo> = UserService.Instance.myUserInfo +): boolean { + return myUserInfo + .map(m => m.local_user_view.local_user.show_avatars) + .unwrapOr(true); } -export function showScores(): boolean { - return ( - UserService.Instance.myUserInfo?.local_user_view.local_user.show_scores || - !UserService.Instance.myUserInfo - ); +export function showScores( + myUserInfo: Option<MyUserInfo> = UserService.Instance.myUserInfo +): boolean { + return myUserInfo + .map(m => m.local_user_view.local_user.show_scores) + .unwrapOr(true); } export function isCakeDay(published: string): boolean { // moment(undefined) or moment.utc(undefined) returns the current date/time // moment(null) or moment.utc(null) returns null - const createDate = moment.utc(published || null).local(); + const createDate = moment.utc(published).local(); const currentDate = moment(new Date()); return ( @@ -472,7 +567,7 @@ export function pictrsDeleteToast( interface NotifyInfo { name: string; - icon?: string; + icon: Option<string>; link: string; body: string; } @@ -484,7 +579,7 @@ export function messageToastify(info: NotifyInfo, router: any) { let toast = Toastify({ text: `${htmlBody}<br />${info.name}`, - avatar: info.icon ? info.icon : null, + avatar: info.icon, backgroundColor: backgroundColor, className: "text-dark", close: true, @@ -666,16 +761,18 @@ async function communitySearch(text: string): Promise<CommunityTribute[]> { export function getListingTypeFromProps( props: any, - defaultListingType: ListingType + defaultListingType: ListingType, + myUserInfo = UserService.Instance.myUserInfo ): ListingType { return props.match.params.listing_type ? routeListingTypeToEnum(props.match.params.listing_type) - : UserService.Instance.myUserInfo - ? Object.values(ListingType)[ - UserService.Instance.myUserInfo.local_user_view.local_user - .default_listing_type - ] - : defaultListingType; + : myUserInfo.match({ + some: me => + Object.values(ListingType)[ + me.local_user_view.local_user.default_listing_type + ], + none: defaultListingType, + }); } export function getListingTypeFromPropsNoDefault(props: any): ListingType { @@ -691,15 +788,19 @@ export function getDataTypeFromProps(props: any): DataType { : DataType.Post; } -export function getSortTypeFromProps(props: any): SortType { +export function getSortTypeFromProps( + props: any, + myUserInfo = UserService.Instance.myUserInfo +): SortType { return props.match.params.sort ? routeSortTypeToEnum(props.match.params.sort) - : UserService.Instance.myUserInfo - ? Object.values(SortType)[ - UserService.Instance.myUserInfo.local_user_view.local_user - .default_sort_type - ] - : SortType.Active; + : myUserInfo.match({ + some: mui => + Object.values(SortType)[ + mui.local_user_view.local_user.default_sort_type + ], + none: SortType.Active, + }); } export function getPageFromProps(props: any): number { @@ -744,42 +845,53 @@ export function saveCommentRes(data: CommentView, comments: CommentView[]) { } } +// TODO Should only use the return now, no state? export function updatePersonBlock( - data: BlockPersonResponse -): PersonBlockView[] { - if (data.blocked) { - UserService.Instance.myUserInfo.person_blocks.push({ - person: UserService.Instance.myUserInfo.local_user_view.person, - target: data.person_view.person, - }); - toast(`${i18n.t("blocked")} ${data.person_view.person.name}`); - } else { - UserService.Instance.myUserInfo.person_blocks = - UserService.Instance.myUserInfo.person_blocks.filter( - i => i.target.id != data.person_view.person.id - ); - toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`); - } - return UserService.Instance.myUserInfo.person_blocks; + data: BlockPersonResponse, + myUserInfo = UserService.Instance.myUserInfo +): Option<PersonBlockView[]> { + return myUserInfo.match({ + some: (mui: MyUserInfo) => { + if (data.blocked) { + mui.person_blocks.push({ + person: mui.local_user_view.person, + target: data.person_view.person, + }); + toast(`${i18n.t("blocked")} ${data.person_view.person.name}`); + } else { + mui.person_blocks = mui.person_blocks.filter( + i => i.target.id != data.person_view.person.id + ); + toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`); + } + return Some(mui.person_blocks); + }, + none: None, + }); } export function updateCommunityBlock( - data: BlockCommunityResponse -): CommunityBlockView[] { - if (data.blocked) { - UserService.Instance.myUserInfo.community_blocks.push({ - person: UserService.Instance.myUserInfo.local_user_view.person, - community: data.community_view.community, - }); - toast(`${i18n.t("blocked")} ${data.community_view.community.name}`); - } else { - UserService.Instance.myUserInfo.community_blocks = - UserService.Instance.myUserInfo.community_blocks.filter( - i => i.community.id != data.community_view.community.id - ); - toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`); - } - return UserService.Instance.myUserInfo.community_blocks; + data: BlockCommunityResponse, + myUserInfo = UserService.Instance.myUserInfo +): Option<CommunityBlockView[]> { + return myUserInfo.match({ + some: (mui: MyUserInfo) => { + if (data.blocked) { + mui.community_blocks.push({ + person: mui.local_user_view.person, + community: data.community_view.community, + }); + toast(`${i18n.t("blocked")} ${data.community_view.community.name}`); + } else { + mui.community_blocks = mui.community_blocks.filter( + i => i.community.id != data.community_view.community.id + ); + toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`); + } + return Some(mui.community_blocks); + }, + none: None, + }); } export function createCommentLikeRes( @@ -910,7 +1022,8 @@ function commentSort(tree: CommentNodeI[], sort: CommentSortType) { (a, b) => +a.comment_view.comment.removed - +b.comment_view.comment.removed || +a.comment_view.comment.deleted - +b.comment_view.comment.deleted || - hotRankComment(b.comment_view) - hotRankComment(a.comment_view) + hotRankComment(b.comment_view as CommentView) - + hotRankComment(a.comment_view as CommentView) ); } @@ -953,6 +1066,7 @@ export function buildCommentsTree( let node: CommentNodeI = { comment_view: comment_view, children: [], + depth: 0, }; map.set(comment_view.comment.id, { ...node }); } @@ -960,15 +1074,18 @@ export function buildCommentsTree( for (let comment_view of comments) { let child = map.get(comment_view.comment.id); let parent_id = comment_view.comment.parent_id; - if (parent_id) { - let parent = map.get(parent_id); - // Necessary because blocked comment might not exist - if (parent) { - parent.children.push(child); - } - } else { - tree.push(child); - } + parent_id.match({ + some: parentId => { + let parent = map.get(parentId); + // Necessary because blocked comment might not exist + if (parent) { + parent.children.push(child); + } + }, + none: () => { + tree.push(child); + }, + }); setDepth(child); } @@ -993,35 +1110,41 @@ export function insertCommentIntoTree(tree: CommentNodeI[], cv: CommentView) { depth: 0, }; - if (cv.comment.parent_id) { - let parentComment = searchCommentTree(tree, cv.comment.parent_id); - if (parentComment) { - node.depth = parentComment.depth + 1; - parentComment.children.unshift(node); - } - } else { - tree.unshift(node); - } + cv.comment.parent_id.match({ + some: parentId => { + let parentComment = searchCommentTree(tree, parentId); + parentComment.match({ + some: pComment => { + node.depth = pComment.depth + 1; + pComment.children.unshift(node); + }, + none: void 0, + }); + }, + none: () => { + tree.unshift(node); + }, + }); } export function searchCommentTree( tree: CommentNodeI[], id: number -): CommentNodeI { +): Option<CommentNodeI> { for (let node of tree) { if (node.comment_view.comment.id === id) { - return node; + return Some(node); } for (const child of node.children) { - const res = searchCommentTree([child], id); + let res = searchCommentTree([child], id); - if (res) { + if (res.isSome()) { return res; } } } - return null; + return None; } export const colorList: string[] = [ @@ -1044,7 +1167,7 @@ export function hostname(url: string): string { export function validTitle(title?: string): boolean { // Initial title is null, minimum length is taken care of by textarea's minLength={3} - if (title === null || title.length < 3) return true; + if (!title || title.length < 3) return true; const regex = new RegExp(/.*\S.*/, "g"); @@ -1068,11 +1191,51 @@ export function isBrowser() { return typeof window !== "undefined"; } -export function setIsoData(context: any): IsoData { - let isoData: IsoData = isBrowser() - ? window.isoData - : context.router.staticContext; - return isoData; +export function setIsoData<Type1, Type2, Type3, Type4, Type5>( + context: any, + cls1?: ClassConstructor<Type1>, + cls2?: ClassConstructor<Type2>, + cls3?: ClassConstructor<Type3>, + cls4?: ClassConstructor<Type4>, + cls5?: ClassConstructor<Type5> +): IsoData { + // If its the browser, you need to deserialize the data from the window + if (isBrowser()) { + let json = window.isoData; + let routeData = json.routeData; + let routeDataOut: any[] = []; + + // Can't do array looping because of specific type constructor required + if (routeData[0]) { + routeDataOut[0] = convertWindowJson(cls1, routeData[0]); + } + if (routeData[1]) { + routeDataOut[1] = convertWindowJson(cls2, routeData[1]); + } + if (routeData[2]) { + routeDataOut[2] = convertWindowJson(cls3, routeData[2]); + } + if (routeData[3]) { + routeDataOut[3] = convertWindowJson(cls4, routeData[3]); + } + if (routeData[4]) { + routeDataOut[4] = convertWindowJson(cls5, routeData[4]); + } + + let isoData: IsoData = { + path: json.path, + site_res: convertWindowJson(GetSiteResponse, json.site_res), + routeData: routeDataOut, + }; + return isoData; + } else return context.router.staticContext; +} + +/** + * Necessary since window ISOData can't store function types like Option + */ +export function convertWindowJson<T>(cls: ClassConstructor<T>, data: any): T { + return deserialize(cls, serialize(data)); } export function wsSubscribe(parseMessage: any): Subscription { @@ -1089,24 +1252,6 @@ export function wsSubscribe(parseMessage: any): Subscription { } } -export function setOptionalAuth(obj: any, auth = UserService.Instance.auth) { - if (auth) { - obj.auth = auth; - } -} - -export function authField( - throwErr = true, - auth = UserService.Instance.auth -): string { - if (auth == null && throwErr) { - toast(i18n.t("not_logged_in"), "danger"); - throw "Not logged in"; - } else { - return auth; - } -} - moment.updateLocale("en", { relativeTime: { future: "in %s", @@ -1141,7 +1286,9 @@ export function restoreScrollPosition(context: any) { } export function showLocal(isoData: IsoData): boolean { - return isoData.site_res.federated_instances?.linked.length > 0; + return isoData.site_res.federated_instances + .map(f => f.linked.length > 0) + .unwrapOr(false); } interface ChoicesValue { @@ -1168,12 +1315,15 @@ export function personToChoice(pvs: PersonViewSafe): ChoicesValue { export async function fetchCommunities(q: string) { let form: Search = { q, - type_: SearchType.Communities, - sort: SortType.TopAll, - listing_type: ListingType.All, - page: 1, - limit: fetchLimit, - auth: authField(false), + type_: Some(SearchType.Communities), + sort: Some(SortType.TopAll), + listing_type: Some(ListingType.All), + page: Some(1), + limit: Some(fetchLimit), + community_id: None, + community_name: None, + creator_id: None, + auth: auth(false).ok(), }; let client = new LemmyHttp(httpBase); return client.search(form); @@ -1182,12 +1332,15 @@ export async function fetchCommunities(q: string) { export async function fetchUsers(q: string) { let form: Search = { q, - type_: SearchType.Users, - sort: SortType.TopAll, - listing_type: ListingType.All, - page: 1, - limit: fetchLimit, - auth: authField(false), + type_: Some(SearchType.Users), + sort: Some(SortType.TopAll), + listing_type: Some(ListingType.All), + page: Some(1), + limit: Some(fetchLimit), + community_id: None, + community_name: None, + creator_id: None, + auth: auth(false).ok(), }; let client = new LemmyHttp(httpBase); return client.search(form); @@ -1233,7 +1386,7 @@ export function communitySelectName(cv: CommunityView): string { } export function personSelectName(pvs: PersonViewSafe): string { - let pName = pvs.person.display_name || pvs.person.name; + let pName = pvs.person.display_name.unwrapOr(pvs.person.name); return pvs.person.local ? pName : `${hostname(pvs.person.actor_id)}/${pName}`; } @@ -1254,9 +1407,11 @@ export function numToSI(value: number): string { } export function isBanned(ps: PersonSafe): boolean { + let expires = ps.ban_expires; // Add Z to convert from UTC date - if (ps.ban_expires) { - if (ps.banned && new Date(ps.ban_expires + "Z") > new Date()) { + // TODO this check probably isn't necessary anymore + if (expires.isSome()) { + if (ps.banned && new Date(expires.unwrap() + "Z") > new Date()) { return true; } else { return false; @@ -1271,3 +1426,15 @@ export function pushNotNull(array: any[], new_item?: any) { array.push(...new_item); } } + +export function auth(throwErr = true): Result<string, string> { + return UserService.Instance.auth(throwErr); +} + +export function enableDownvotes(siteRes: GetSiteResponse): boolean { + return siteRes.site_view.map(s => s.site.enable_downvotes).unwrapOr(true); +} + +export function enableNsfw(siteRes: GetSiteResponse): boolean { + return siteRes.site_view.map(s => s.site.enable_nsfw).unwrapOr(false); +} diff --git a/tsconfig.json b/tsconfig.json index cd9bc8d..c708247 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "skipLibCheck": true, "noUnusedParameters": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "experimentalDecorators": true, + "noFallthroughCasesInSwitch": true }, "include": [ "src/**/*", diff --git a/yarn.lock b/yarn.lock index 551a452..2b2fba4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -95,6 +95,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.18.2": + version "7.18.2" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.2.tgz#33873d6f89b21efe2da63fe554460f3df1c5880d" + integrity sha512-W1lG5vUwFvfMd8HVXqdfbuG7RuaSrTCCD8cl8fP8wOivdbtbIg2Db3IWUcgvfxKbbn6ZBGYRW/Zk1MIwK49mgw== + dependencies: + "@babel/types" "^7.18.2" + "@jridgewell/gen-mapping" "^0.3.0" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz#bb2339a7534a9c128e3102024c60760a3a7f3862" @@ -156,6 +165,19 @@ "@babel/helper-replace-supers" "^7.16.7" "@babel/helper-split-export-declaration" "^7.16.7" +"@babel/helper-create-class-features-plugin@^7.18.0": + version "7.18.0" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.0.tgz#fac430912606331cb075ea8d82f9a4c145a4da19" + integrity sha512-Kh8zTGR9de3J63e5nS0rQUdRs/kbtwoeQQ0sriS0lItjC96u8XXZN6lKpuyWd2coKSU13py/y+LTmThLuVX0Pg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.16.7" + "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-function-name" "^7.17.9" + "@babel/helper-member-expression-to-functions" "^7.17.7" + "@babel/helper-optimise-call-expression" "^7.16.7" + "@babel/helper-replace-supers" "^7.16.7" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/helper-create-regexp-features-plugin@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.16.7.tgz#0cb82b9bac358eb73bfbd73985a776bfa6b14d48" @@ -185,6 +207,11 @@ dependencies: "@babel/types" "^7.16.7" +"@babel/helper-environment-visitor@^7.18.2": + version "7.18.2" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.2.tgz#8a6d2dedb53f6bf248e31b4baf38739ee4a637bd" + integrity sha512-14GQKWkX9oJzPiQQ7/J36FTXcD4kSp8egKjO9nINlSKiHITRA9q/R74qu8S9xlc/b/yjsJItQUeeh3xnGN0voQ== + "@babel/helper-explode-assignable-expression@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.16.7.tgz#12a6d8522fdd834f194e868af6354e8650242b7a" @@ -230,6 +257,13 @@ dependencies: "@babel/types" "^7.16.7" +"@babel/helper-member-expression-to-functions@^7.17.7": + version "7.17.7" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.17.7.tgz#a34013b57d8542a8c4ff8ba3f747c02452a4d8c4" + integrity sha512-thxXgnQ8qQ11W2wVUObIqDL4p148VMxkt5T/qpN5k2fboRyzFGFmKsTGViquyM5QHKUy48OZoca8kw4ajaDPyw== + dependencies: + "@babel/types" "^7.17.0" + "@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz#25612a8091a999704461c8a222d0efec5d091437" @@ -277,6 +311,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== +"@babel/helper-plugin-utils@^7.17.12": + version "7.17.12" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.17.12.tgz#86c2347da5acbf5583ba0a10aed4c9bf9da9cf96" + integrity sha512-JDkf04mqtN3y4iAbO1hv9U2ARpPyPL1zqyWs/2WG1pgSq9llHFjStX5jdxb84himgJm+8Ng+x0oiWF/nw/XQKA== + "@babel/helper-remap-async-to-generator@^7.16.8": version "7.16.8" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.16.8.tgz#29ffaade68a367e2ed09c90901986918d25e57e3" @@ -297,6 +336,17 @@ "@babel/traverse" "^7.16.7" "@babel/types" "^7.16.7" +"@babel/helper-replace-supers@^7.18.2": + version "7.18.2" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.2.tgz#41fdfcc9abaf900e18ba6e5931816d9062a7b2e0" + integrity sha512-XzAIyxx+vFnrOxiQrToSUOzUOn0e1J2Li40ntddek1Y69AXUTXoDJ40/D5RdjFu7s7qHiaeoTiempZcbuVXh2Q== + dependencies: + "@babel/helper-environment-visitor" "^7.18.2" + "@babel/helper-member-expression-to-functions" "^7.17.7" + "@babel/helper-optimise-call-expression" "^7.16.7" + "@babel/traverse" "^7.18.2" + "@babel/types" "^7.18.2" + "@babel/helper-simple-access@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.16.7.tgz#d656654b9ea08dbb9659b69d61063ccd343ff0f7" @@ -387,6 +437,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.9.tgz#9c94189a6062f0291418ca021077983058e171ef" integrity sha512-vqUSBLP8dQHFPdPi9bc5GK9vRkYHJ49fsZdtoJ8EQ8ibpwk5rPKfvNIwChB0KVXcIjcepEBBd2VHC5r9Gy8ueg== +"@babel/parser@^7.18.5": + version "7.18.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.5.tgz#337062363436a893a2d22faa60be5bb37091c83c" + integrity sha512-YZWVaglMiplo7v8f1oMQ5ZPQr0vn7HPeZXxXWsxXJRjGVrzUFn9OxFQl1sb5wzfootjA/yChhW84BV+383FSOw== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.16.7.tgz#4eda6d6c2a0aa79c70fa7b6da67763dfe2141050" @@ -429,6 +484,18 @@ "@babel/helper-plugin-utils" "^7.16.7" "@babel/plugin-syntax-class-static-block" "^7.14.5" +"@babel/plugin-proposal-decorators@^7.18.2": + version "7.18.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.18.2.tgz#dbe4086d2d42db489399783c3aa9272e9700afd4" + integrity sha512-kbDISufFOxeczi0v4NQP3p5kIeW6izn/6klfWBrIIdGZZe4UpHR+QU03FAoWjGGd9SUXAwbw2pup1kaL4OQsJQ== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.18.0" + "@babel/helper-plugin-utils" "^7.17.12" + "@babel/helper-replace-supers" "^7.18.2" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/plugin-syntax-decorators" "^7.17.12" + charcodes "^0.2.0" + "@babel/plugin-proposal-dynamic-import@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.16.7.tgz#c19c897eaa46b27634a00fee9fb7d829158704b2" @@ -552,6 +619,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" +"@babel/plugin-syntax-decorators@^7.17.12": + version "7.17.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.17.12.tgz#02e8f678602f0af8222235271efea945cfdb018a" + integrity sha512-D1Hz0qtGTza8K2xGyEdVNCYLdVHukAcbQr4K3/s6r/esadyEriZovpJimQOpu8ju4/jV8dW/1xdaE0UpDroidw== + dependencies: + "@babel/helper-plugin-utils" "^7.17.12" + "@babel/plugin-syntax-dynamic-import@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" @@ -1092,6 +1166,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.18.2": + version "7.18.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.5.tgz#94a8195ad9642801837988ab77f36e992d9a20cd" + integrity sha512-aKXj1KT66sBj0vVzk6rEeAO6Z9aiiQ68wfDgge3nHhA/my6xMM/7HGQUNumKZaoa2qUPQ5whJG9aAifsxUKfLA== + dependencies: + "@babel/code-frame" "^7.16.7" + "@babel/generator" "^7.18.2" + "@babel/helper-environment-visitor" "^7.18.2" + "@babel/helper-function-name" "^7.17.9" + "@babel/helper-hoist-variables" "^7.16.7" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/parser" "^7.18.5" + "@babel/types" "^7.18.4" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7", "@babel/types@^7.0.0-beta.54", "@babel/types@^7.16.0", "@babel/types@^7.16.7", "@babel/types@^7.16.8", "@babel/types@^7.4.4": version "7.16.8" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.16.8.tgz#0ba5da91dd71e0a4e7781a30f22770831062e3c1" @@ -1108,6 +1198,14 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" +"@babel/types@^7.18.2", "@babel/types@^7.18.4": + version "7.18.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.4.tgz#27eae9b9fd18e9dccc3f9d6ad051336f307be354" + integrity sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw== + dependencies: + "@babel/helper-validator-identifier" "^7.16.7" + to-fast-properties "^2.0.0" + "@discoveryjs/json-ext@^0.5.0": version "0.5.6" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" @@ -1151,11 +1249,25 @@ update-notifier "^2.2.0" yargs "^8.0.2" +"@jridgewell/gen-mapping@^0.3.0": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.1.tgz#cf92a983c83466b8c0ce9124fadeaf09f7c66ea9" + integrity sha512-GcHwniMlA2z+WFPWuY8lp3fsza0I8xPFMWL5+n8LYyP6PSvPrXf4+n8stDHZY2DM0zy9sVkRDy1jDI4XGzYVqg== + dependencies: + "@jridgewell/set-array" "^1.0.0" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" + "@jridgewell/resolve-uri@^3.0.3": version "3.0.5" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz#68eb521368db76d040a6315cdb24bf2483037b9c" integrity sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew== +"@jridgewell/set-array@^1.0.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.1.tgz#36a6acc93987adcf0ba50c66908bd0b70de8afea" + integrity sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ== + "@jridgewell/sourcemap-codec@^1.4.10": version "1.4.11" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz#771a1d8d744eeb71b6adb35808e1a6c7b9b8c8ec" @@ -1169,6 +1281,14 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jridgewell/trace-mapping@^0.3.9": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz#dcfe3e95f224c8fe97a87a5235defec999aa92ea" + integrity sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.3.tgz#0300943770e04231041a51bd39f0439b5c7ab4f0" @@ -1200,6 +1320,11 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.2.tgz#830beaec4b4091a9e9398ac50f865ddea52186b9" integrity sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA== +"@sniptt/monads@^0.5.10": + version "0.5.10" + resolved "https://registry.yarnpkg.com/@sniptt/monads/-/monads-0.5.10.tgz#a80cd00738bbd682d36d36dd36bdc0bddc96eb76" + integrity sha512-+agDOv9DpDV+9e2zN/Vmdk+XaqGx5Sykl0fqhqgiJ90r18nsBkxe44DmZ2sA1HYK+MSsBeZBiAr6pq4w+5uhfw== + "@types/autosize@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/autosize/-/autosize-4.0.1.tgz#999a7c305b96766248044ebaac1a0299961f3b61" @@ -2254,6 +2379,11 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +charcodes@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/charcodes/-/charcodes-0.2.0.tgz#5208d327e6cc05f99eb80ffc814707572d1f14e4" + integrity sha512-Y4kiDb+AM4Ecy58YkuZrrSRJBDQdQ2L+NyS1vHHFtNtUjgutcZfx3yp1dAONI/oPaPmyGfCLx5CxL+zauIMyKQ== + check-password-strength@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/check-password-strength/-/check-password-strength-2.0.5.tgz#bb10da01d24bd69e5e629c5cea2a6b729e5061af" @@ -2323,6 +2453,11 @@ cidr-regex@1.0.6: resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-1.0.6.tgz#74abfd619df370b9d54ab14475568e97dd64c0c1" integrity sha1-dKv9YZ3zcLnVSrFEdVaOl91kwME= +class-transformer@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" + integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== + classnames@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" @@ -4813,10 +4948,10 @@ lcid@^1.0.0: dependencies: invert-kv "^1.0.0" -lemmy-js-client@0.16.4: - version "0.16.4" - resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.16.4.tgz#d24bae2b0d93c4d13eb4a5e5ddceaa2999f94740" - integrity sha512-EFHl6tbFZ0jk8VE68bgZOXoWuNHVzfcsyoAEZeHP6f8PkQ1g9zjxB/e3b5cIG2fFzOLsYIDh2w/SJy21WkFiiA== +lemmy-js-client@0.17.0-rc.30: + version "0.17.0-rc.30" + resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.0-rc.30.tgz#91cc926e662a5cd27f87cd2e6cdfcd210176745a" + integrity sha512-AcG8IZNNTa54BAXEqsL/QNlyPPwLntRLWpIOw9S3u84824d5inL7UCKnyx0UMbQklUuH/D3E2K9WNmZiUdvr3A== levn@^0.4.1: version "0.4.1" @@ -6673,6 +6808,11 @@ redux@^4.1.2: dependencies: "@babel/runtime" "^7.9.2" +reflect-metadata@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + regenerate-unicode-properties@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-9.0.0.tgz#54d09c7115e1f53dc2314a974b32c1c344efe326"