From b96e16b4e9a8baeddc3c97f449ef6c1cad1fbe29 Mon Sep 17 00:00:00 2001 From: Dessalines <dessalines@users.noreply.github.com> Date: Thu, 30 Dec 2021 10:26:45 -0500 Subject: [PATCH] Private instances (#523) * Updating translations. * Adding registration applications. * Updating translations. * Adding verify email route. * Fix missing signup question bug. * Updating translations. * A few fixes from comments on lemmy PR. * v0.15.0-rc.4 * Some suggestions from PR. * v0.15.0-rc.5 * Adding optional auth to modlog fetches. * v0.15.0-rc.6 * Hide deny / approve buttons --- lemmy-translations | 2 +- package.json | 4 +- src/server/index.tsx | 6 +- src/shared/components/app/navbar.tsx | 86 +++++- ...{comment_report.tsx => comment-report.tsx} | 11 +- .../components/common/markdown-textarea.tsx | 2 +- .../common/registration-application.tsx | 150 +++++++++++ src/shared/components/common/symbols.tsx | 3 + src/shared/components/home/home.tsx | 1 + src/shared/components/home/login.tsx | 2 - src/shared/components/home/signup.tsx | 100 +++++-- src/shared/components/home/site-form.tsx | 138 ++++++++-- src/shared/components/modlog.tsx | 5 + .../password-change.tsx} | 0 .../person/registration-applications.tsx | 249 ++++++++++++++++++ src/shared/components/person/reports.tsx | 4 +- src/shared/components/person/settings.tsx | 5 + src/shared/components/person/verify-email.tsx | 97 +++++++ .../post/{post_report.tsx => post-report.tsx} | 11 +- src/shared/routes.ts | 13 +- src/shared/services/UserService.ts | 2 + src/shared/utils.ts | 15 ++ yarn.lock | 8 +- 23 files changed, 849 insertions(+), 65 deletions(-) rename src/shared/components/comment/{comment_report.tsx => comment-report.tsx} (92%) create mode 100644 src/shared/components/common/registration-application.tsx rename src/shared/components/{home/password_change.tsx => person/password-change.tsx} (100%) create mode 100644 src/shared/components/person/registration-applications.tsx create mode 100644 src/shared/components/person/verify-email.tsx rename src/shared/components/post/{post_report.tsx => post-report.tsx} (92%) diff --git a/lemmy-translations b/lemmy-translations index 0412b6b..1e0bb99 160000 --- a/lemmy-translations +++ b/lemmy-translations @@ -1 +1 @@ -Subproject commit 0412b6b349e5e8d6ac3ed88801187833e95c72c9 +Subproject commit 1e0bb9920cda13bb128c87e85125b98ab8f319b6 diff --git a/package.json b/package.json index 7621abb..ba0665a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lemmy-ui", "description": "An isomorphic UI for lemmy", - "version": "0.14.5", + "version": "0.15.0-rc.6", "author": "Dessalines <tyhou13@gmx.com>", "license": "AGPL-3.0", "scripts": { @@ -72,7 +72,7 @@ "husky": "^7.0.4", "import-sort-style-module": "^6.0.0", "iso-639-1": "^2.1.10", - "lemmy-js-client": "0.14.0-rc.1", + "lemmy-js-client": "0.15.0-rc.6", "lint-staged": "^12.1.2", "mini-css-extract-plugin": "^2.4.5", "node-fetch": "^2.6.1", diff --git a/src/server/index.tsx b/src/server/index.tsx index 5bf79f1..82d0379 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -91,7 +91,11 @@ server.get("/*", async (req, res) => { if (routeData[0] && routeData[0].error) { let errCode = routeData[0].error; console.error(errCode); - return res.redirect(`/404?err=${errCode}`); + if (errCode == "instance_is_private") { + return res.redirect(`/signup`); + } else { + return res.redirect(`/404?err=${errCode}`); + } } let isoData: IsoData = { diff --git a/src/shared/components/app/navbar.tsx b/src/shared/components/app/navbar.tsx index 2a40915..5755541 100644 --- a/src/shared/components/app/navbar.tsx +++ b/src/shared/components/app/navbar.tsx @@ -7,6 +7,8 @@ import { GetSiteResponse, GetUnreadCount, GetUnreadCountResponse, + GetUnreadRegistrationApplicationCount, + GetUnreadRegistrationApplicationCountResponse, PrivateMessageResponse, UserOperation, } from "lemmy-js-client"; @@ -41,6 +43,7 @@ interface NavbarState { expanded: boolean; unreadInboxCount: number; unreadReportCount: number; + unreadApplicationCount: number; searchParam: string; toggleSearch: boolean; showDropdown: boolean; @@ -52,11 +55,13 @@ export class Navbar extends Component<NavbarProps, NavbarState> { private userSub: Subscription; private unreadInboxCountSub: Subscription; private unreadReportCountSub: Subscription; + private unreadApplicationCountSub: Subscription; private searchTextField: RefObject<HTMLInputElement>; emptyState: NavbarState = { isLoggedIn: !!this.props.site_res.my_user, unreadInboxCount: 0, unreadReportCount: 0, + unreadApplicationCount: 0, expanded: false, searchParam: "", toggleSearch: false, @@ -115,6 +120,11 @@ export class Navbar extends Component<NavbarProps, NavbarState> { UserService.Instance.unreadReportCountSub.subscribe(res => { this.setState({ unreadReportCount: res }); }); + // Subscribe to unread application count + this.unreadApplicationCountSub = + UserService.Instance.unreadApplicationCountSub.subscribe(res => { + this.setState({ unreadApplicationCount: res }); + }); } } @@ -123,6 +133,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> { this.userSub.unsubscribe(); this.unreadInboxCountSub.unsubscribe(); this.unreadReportCountSub.unsubscribe(); + this.unreadApplicationCountSub.unsubscribe(); } updateUrl() { @@ -215,6 +226,31 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </li> </ul> )} + {UserService.Instance.myUserInfo?.local_user_view.person + .admin && ( + <ul class="navbar-nav ml-1"> + <li className="nav-item"> + <NavLink + to="/registration_applications" + className="p-1 navbar-toggler nav-link border-0" + onMouseUp={linkEvent(this, this.handleHideExpandNavbar)} + title={i18n.t("unread_registration_applications", { + count: this.state.unreadApplicationCount, + formattedCount: numToSI( + this.state.unreadApplicationCount + ), + })} + > + <Icon icon="clipboard" /> + {this.state.unreadApplicationCount > 0 && ( + <span class="mx-1 badge badge-light"> + {numToSI(this.state.unreadApplicationCount)} + </span> + )} + </NavLink> + </li> + </ul> + )} </> )} <button @@ -366,6 +402,31 @@ export class Navbar extends Component<NavbarProps, NavbarState> { </li> </ul> )} + {UserService.Instance.myUserInfo?.local_user_view.person + .admin && ( + <ul class="navbar-nav my-2"> + <li className="nav-item"> + <NavLink + to="/registration_applications" + className="nav-link" + onMouseUp={linkEvent(this, this.handleHideExpandNavbar)} + title={i18n.t("unread_registration_applications", { + count: this.state.unreadApplicationCount, + formattedCount: numToSI( + this.state.unreadApplicationCount + ), + })} + > + <Icon icon="clipboard" /> + {this.state.unreadApplicationCount > 0 && ( + <span class="mx-1 badge badge-light"> + {numToSI(this.state.unreadApplicationCount)} + </span> + )} + </NavLink> + </li> + </ul> + )} <ul class="navbar-nav"> <li class="nav-item dropdown"> <button @@ -537,6 +598,12 @@ export class Navbar extends Component<NavbarProps, NavbarState> { 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; + 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; @@ -586,7 +653,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> { let unreadForm: GetUnreadCount = { auth: authField(), }; - WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm)); console.log("Fetching reports..."); @@ -594,8 +660,18 @@ export class Navbar extends Component<NavbarProps, NavbarState> { let reportCountForm: GetReportCount = { auth: authField(), }; - WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm)); + + if (UserService.Instance.myUserInfo?.local_user_view.person.admin) { + console.log("Fetching applications..."); + + let applicationCountForm: GetUnreadRegistrationApplicationCount = { + auth: authField(), + }; + WebSocketService.Instance.send( + wsClient.getUnreadRegistrationApplicationCount(applicationCountForm) + ); + } } get currentLocation() { @@ -612,6 +688,12 @@ export class Navbar extends Component<NavbarProps, NavbarState> { ); } + sendApplicationUnread() { + UserService.Instance.unreadApplicationCountSub.next( + this.state.unreadApplicationCount + ); + } + get canAdmin(): boolean { return ( UserService.Instance.myUserInfo && diff --git a/src/shared/components/comment/comment_report.tsx b/src/shared/components/comment/comment-report.tsx similarity index 92% rename from src/shared/components/comment/comment_report.tsx rename to src/shared/components/comment/comment-report.tsx index 87f6ebc..8e04962 100644 --- a/src/shared/components/comment/comment_report.tsx +++ b/src/shared/components/comment/comment-report.tsx @@ -25,6 +25,9 @@ export class CommentReport extends Component<CommentReportProps, any> { render() { let r = this.props.report; let comment = r.comment; + let tippyContent = i18n.t( + r.comment_report.resolved ? "unresolve_report" : "resolve_report" + ); // Set the original post data ( a troll could change it ) comment.content = r.comment_report.original_comment_text; @@ -78,12 +81,8 @@ export class CommentReport extends Component<CommentReportProps, any> { <button className="btn btn-link btn-animate text-muted py-0" onClick={linkEvent(this, this.handleResolveReport)} - data-tippy-content={ - r.comment_report.resolved ? "unresolve_report" : "resolve_report" - } - aria-label={ - r.comment_report.resolved ? "unresolve_report" : "resolve_report" - } + data-tippy-content={tippyContent} + aria-label={tippyContent} > <Icon icon="check" diff --git a/src/shared/components/common/markdown-textarea.tsx b/src/shared/components/common/markdown-textarea.tsx index ea5bea1..5b1c8ee 100644 --- a/src/shared/components/common/markdown-textarea.tsx +++ b/src/shared/components/common/markdown-textarea.tsx @@ -17,7 +17,7 @@ import { import { Icon, Spinner } from "./icon"; interface MarkdownTextAreaProps { - initialContent: string; + initialContent?: string; finished?: boolean; buttonTitle?: string; replyType?: boolean; diff --git a/src/shared/components/common/registration-application.tsx b/src/shared/components/common/registration-application.tsx new file mode 100644 index 0000000..cad47b8 --- /dev/null +++ b/src/shared/components/common/registration-application.tsx @@ -0,0 +1,150 @@ +import { Component, linkEvent } from "inferno"; +import { T } from "inferno-i18next-dess"; +import { + ApproveRegistrationApplication, + RegistrationApplicationView, +} from "lemmy-js-client"; +import { i18n } from "../../i18next"; +import { WebSocketService } from "../../services"; +import { authField, mdToHtml, wsClient } from "../../utils"; +import { PersonListing } from "../person/person-listing"; +import { MarkdownTextArea } from "./markdown-textarea"; +import { MomentTime } from "./moment-time"; + +interface RegistrationApplicationProps { + application: RegistrationApplicationView; +} + +interface RegistrationApplicationState { + denyReason?: string; + denyExpanded: boolean; +} + +export class RegistrationApplication extends Component< + RegistrationApplicationProps, + RegistrationApplicationState +> { + private emptyState: RegistrationApplicationState = { + denyReason: this.props.application.registration_application.deny_reason, + denyExpanded: false, + }; + + constructor(props: any, context: any) { + super(props, context); + + this.state = this.emptyState; + this.handleDenyReasonChange = this.handleDenyReasonChange.bind(this); + } + + render() { + let a = this.props.application; + let ra = this.props.application.registration_application; + let accepted = a.creator_local_user.accepted_application; + + return ( + <div> + <div> + {i18n.t("applicant")}: <PersonListing person={a.creator} /> + </div> + <div> + {i18n.t("created")}: <MomentTime showAgo data={ra} /> + </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"> + # + <PersonListing person={a.admin} /> + </T> + <div> + {i18n.t("deny_reason")}:{" "} + <div + className="md-div d-inline-flex" + dangerouslySetInnerHTML={mdToHtml(ra.deny_reason || "")} + /> + </div> + </div> + )} + </div> + )} + + {this.state.denyExpanded && ( + <div class="form-group row"> + <label class="col-sm-2 col-form-label"> + {i18n.t("deny_reason")} + </label> + <div class="col-sm-10"> + <MarkdownTextArea + initialContent={this.state.denyReason} + onContentChange={this.handleDenyReasonChange} + hideNavigationWarnings + /> + </div> + </div> + )} + {(!ra.admin_id || (ra.admin_id && !accepted)) && ( + <button + className="btn btn-secondary mr-2 my-2" + onClick={linkEvent(this, this.handleApprove)} + aria-label={i18n.t("approve")} + > + {i18n.t("approve")} + </button> + )} + {(!ra.admin_id || (ra.admin_id && accepted)) && ( + <button + className="btn btn-secondary mr-2" + onClick={linkEvent(this, this.handleDeny)} + aria-label={i18n.t("deny")} + > + {i18n.t("deny")} + </button> + )} + </div> + ); + } + + handleApprove(i: RegistrationApplication) { + i.setState({ denyExpanded: false }); + let form: ApproveRegistrationApplication = { + id: i.props.application.registration_application.id, + deny_reason: "", + approve: true, + auth: authField(), + }; + WebSocketService.Instance.send( + wsClient.approveRegistrationApplication(form) + ); + } + + handleDeny(i: RegistrationApplication) { + if (i.state.denyExpanded) { + i.setState({ denyExpanded: false }); + let form: ApproveRegistrationApplication = { + id: i.props.application.registration_application.id, + approve: false, + deny_reason: i.state.denyReason, + auth: authField(), + }; + WebSocketService.Instance.send( + wsClient.approveRegistrationApplication(form) + ); + } else { + i.setState({ denyExpanded: true }); + } + } + + handleDenyReasonChange(val: string) { + this.state.denyReason = val; + this.setState(this.state); + } +} diff --git a/src/shared/components/common/symbols.tsx b/src/shared/components/common/symbols.tsx index 2035d3c..d730c15 100644 --- a/src/shared/components/common/symbols.tsx +++ b/src/shared/components/common/symbols.tsx @@ -12,6 +12,9 @@ export const SYMBOLS = ( xmlnsXlink="http://www.w3.org/1999/xlink" > <defs> + <symbol id="icon-clipboard" viewBox="0 0 24 24"> + <path d="M7 5c0 0.552 0.225 1.053 0.586 1.414s0.862 0.586 1.414 0.586h6c0.552 0 1.053-0.225 1.414-0.586s0.586-0.862 0.586-1.414h1c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707v14c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-12c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-14c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293zM9 1c-0.552 0-1.053 0.225-1.414 0.586s-0.586 0.862-0.586 1.414h-1c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v14c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h12c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-14c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879h-1c0-0.552-0.225-1.053-0.586-1.414s-0.862-0.586-1.414-0.586zM9 3h6v2h-6z"></path> + </symbol> <symbol id="icon-shield" viewBox="0 0 24 24"> <path d="M12 20.862c-1.184-0.672-4.42-2.695-6.050-5.549-0.079-0.138-0.153-0.276-0.223-0.417-0.456-0.911-0.727-1.878-0.727-2.896v-6.307l7-2.625 7 2.625v6.307c0 1.018-0.271 1.985-0.726 2.897-0.070 0.14-0.145 0.279-0.223 0.417-1.631 2.854-4.867 4.876-6.050 5.549zM12.447 22.894c0 0 4.989-2.475 7.34-6.589 0.096-0.168 0.188-0.34 0.276-0.515 0.568-1.135 0.937-2.408 0.937-3.79v-7c0-0.426-0.267-0.79-0.649-0.936l-8-3c-0.236-0.089-0.485-0.082-0.702 0l-8 3c-0.399 0.149-0.646 0.527-0.649 0.936v7c0 1.382 0.369 2.655 0.938 3.791 0.087 0.175 0.179 0.346 0.276 0.515 2.351 4.114 7.34 6.589 7.34 6.589 0.292 0.146 0.62 0.136 0.894 0z"></path> </symbol> diff --git a/src/shared/components/home/home.tsx b/src/shared/components/home/home.tsx index f44d4cc..d145145 100644 --- a/src/shared/components/home/home.tsx +++ b/src/shared/components/home/home.tsx @@ -239,6 +239,7 @@ export class Home extends Component<any, HomeState> { sort: SortType.Hot, limit: 6, }; + setOptionalAuth(trendingCommunitiesForm, req.auth); promises.push(req.client.listCommunities(trendingCommunitiesForm)); return promises; diff --git a/src/shared/components/home/login.tsx b/src/shared/components/home/login.tsx index 73d7dbe..5a61d13 100644 --- a/src/shared/components/home/login.tsx +++ b/src/shared/components/home/login.tsx @@ -185,8 +185,6 @@ export class Login extends Component<any, State> { if (msg.error) { toast(i18n.t(msg.error), "danger"); this.state = this.emptyState; - // Refetch another captcha - WebSocketService.Instance.send(wsClient.getCaptcha()); this.setState(this.state); return; } else { diff --git a/src/shared/components/home/signup.tsx b/src/shared/components/home/signup.tsx index c3fcd20..75e7856 100644 --- a/src/shared/components/home/signup.tsx +++ b/src/shared/components/home/signup.tsx @@ -17,6 +17,7 @@ import { authField, isBrowser, joinLemmyUrl, + mdToHtml, setIsoData, toast, validEmail, @@ -27,6 +28,7 @@ import { } from "../../utils"; import { HtmlTags } from "../common/html-tags"; import { Icon, Spinner } from "../common/icon"; +import { MarkdownTextArea } from "../common/markdown-textarea"; const passwordStrengthOptions: Options<string> = [ { @@ -77,6 +79,7 @@ export class Signup extends Component<any, State> { captcha_uuid: undefined, captcha_answer: undefined, honeypot: undefined, + answer: undefined, }, registerLoading: false, captcha: undefined, @@ -88,6 +91,7 @@ export class Signup extends Component<any, State> { super(props, context); this.state = this.emptyState; + this.handleAnswerChange = this.handleAnswerChange.bind(this); this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); @@ -104,7 +108,13 @@ export class Signup extends Component<any, State> { } get documentTitle(): string { - return `${i18n.t("login")} - ${this.state.site_view.site.name}`; + return `${this.titleName} - ${this.state.site_view.site.name}`; + } + + get titleName(): string { + return `${i18n.t( + this.state.site_view.site.private_instance ? "apply_to_join" : "sign_up" + )}`; } get isLemmyMl(): boolean { @@ -128,7 +138,7 @@ export class Signup extends Component<any, State> { registerForm() { return ( <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}> - <h5>{i18n.t("sign_up")}</h5> + <h5>{this.titleName}</h5> <div class="form-group row"> <label class="col-sm-2 col-form-label" htmlFor="register-username"> @@ -159,18 +169,24 @@ export class Signup extends Component<any, State> { type="email" id="register-email" class="form-control" - placeholder={i18n.t("optional")} + 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} /> - {!validEmail(this.state.registerForm.email) && ( - <div class="mt-2 mb-0 alert alert-light" role="alert"> - <Icon icon="alert-triangle" classes="icon-inline mr-2" /> - {i18n.t("no_password_reset")} - </div> - )} + {!this.state.site_view.site.require_email_verification && + !validEmail(this.state.registerForm.email) && ( + <div class="mt-2 mb-0 alert alert-light" role="alert"> + <Icon icon="alert-triangle" classes="icon-inline mr-2" /> + {i18n.t("no_password_reset")} + </div> + )} </div> </div> @@ -219,6 +235,40 @@ export class Signup extends Component<any, State> { </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-light" role="alert"> + <Icon icon="alert-triangle" classes="icon-inline mr-2" /> + {i18n.t("fill_out_application")} + </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> + </> + )} + {this.state.captcha && ( <div class="form-group row"> <label class="col-sm-2" htmlFor="register-captcha"> @@ -286,7 +336,7 @@ export class Signup extends Component<any, State> { <div class="form-group row"> <div class="col-sm-10"> <button type="submit" class="btn btn-secondary"> - {this.state.registerLoading ? <Spinner /> : i18n.t("sign_up")} + {this.state.registerLoading ? <Spinner /> : this.titleName} </button> </div> </div> @@ -382,6 +432,11 @@ export class Signup extends Component<any, State> { i.setState(i.state); } + handleAnswerChange(val: string) { + this.state.registerForm.answer = val; + this.setState(this.state); + } + handleHoneyPotChange(i: Signup, event: any) { i.state.registerForm.honeypot = event.target.value; i.setState(i.state); @@ -434,13 +489,24 @@ export class Signup extends Component<any, State> { let data = wsJsonToRes<LoginResponse>(msg).data; this.state = this.emptyState; this.setState(this.state); - UserService.Instance.login(data); - WebSocketService.Instance.send( - wsClient.userJoin({ - auth: authField(), - }) - ); - this.props.history.push("/communities"); + // Only log them in if a jwt was set + if (data.jwt) { + UserService.Instance.login(data); + WebSocketService.Instance.send( + wsClient.userJoin({ + auth: authField(), + }) + ); + this.props.history.push("/communities"); + } else { + if (data.verify_email_sent) { + toast(i18n.t("verify_email_sent")); + } + if (data.registration_created) { + toast(i18n.t("registration_application_sent")); + } + this.props.history.push("/"); + } } else if (op == UserOperation.GetCaptcha) { let data = wsJsonToRes<GetCaptchaResponse>(msg).data; if (data.ok) { diff --git a/src/shared/components/home/site-form.tsx b/src/shared/components/home/site-form.tsx index 6bfed42..ba1ce38 100644 --- a/src/shared/components/home/site-form.tsx +++ b/src/shared/components/home/site-form.tsx @@ -3,12 +3,7 @@ import { Prompt } from "inferno-router"; import { CreateSite, EditSite, Site } from "lemmy-js-client"; import { i18n } from "../../i18next"; import { WebSocketService } from "../../services"; -import { - authField, - capitalizeFirstLetter, - randomStr, - wsClient, -} from "../../utils"; +import { authField, capitalizeFirstLetter, wsClient } from "../../utils"; import { Spinner } from "../common/icon"; import { ImageUploadForm } from "../common/image-upload-form"; import { MarkdownTextArea } from "../common/markdown-textarea"; @@ -24,7 +19,6 @@ interface SiteFormState { } export class SiteForm extends Component<SiteFormProps, SiteFormState> { - private id = `site-form-${randomStr()}`; private emptyState: SiteFormState = { siteForm: { enable_downvotes: true, @@ -33,6 +27,10 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { name: null, icon: null, banner: null, + require_email_verification: null, + require_application: null, + application_question: null, + private_instance: null, auth: authField(), }, loading: false, @@ -43,6 +41,8 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { this.state = this.emptyState; this.handleSiteSidebarChange = this.handleSiteSidebarChange.bind(this); + this.handleSiteApplicationQuestionChange = + this.handleSiteApplicationQuestionChange.bind(this); this.handleIconUpload = this.handleIconUpload.bind(this); this.handleIconRemove = this.handleIconRemove.bind(this); @@ -51,17 +51,21 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { this.handleBannerRemove = this.handleBannerRemove.bind(this); if (this.props.site) { + let site = this.props.site; this.state.siteForm = { - name: this.props.site.name, - sidebar: this.props.site.sidebar, - description: this.props.site.description, - enable_downvotes: this.props.site.enable_downvotes, - open_registration: this.props.site.open_registration, - enable_nsfw: this.props.site.enable_nsfw, - community_creation_admin_only: - this.props.site.community_creation_admin_only, - icon: this.props.site.icon, - banner: this.props.site.banner, + 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, auth: authField(), }; } @@ -79,6 +83,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { !this.props.site && (this.state.siteForm.name || this.state.siteForm.sidebar || + this.state.siteForm.application_question || this.state.siteForm.description) ) { window.onbeforeunload = () => true; @@ -100,6 +105,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { !this.props.site && (this.state.siteForm.name || this.state.siteForm.sidebar || + this.state.siteForm.application_question || this.state.siteForm.description) } message={i18n.t("block_leaving")} @@ -162,9 +168,7 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { </div> </div> <div class="form-group row"> - <label class="col-12 col-form-label" htmlFor={this.id}> - {i18n.t("sidebar")} - </label> + <label class="col-12 col-form-label">{i18n.t("sidebar")}</label> <div class="col-12"> <MarkdownTextArea initialContent={this.state.siteForm.sidebar} @@ -173,6 +177,20 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { /> </div> </div> + {this.state.siteForm.require_application && ( + <div class="form-group row"> + <label class="col-12 col-form-label"> + {i18n.t("application_questionnaire")} + </label> + <div class="col-12"> + <MarkdownTextArea + initialContent={this.state.siteForm.application_question} + onContentChange={this.handleSiteApplicationQuestionChange} + hideNavigationWarnings + /> + </div> + </div> + )} <div class="form-group row"> <div class="col-12"> <div class="form-check"> @@ -255,6 +273,66 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { </div> </div> </div> + <div class="form-group row"> + <div class="col-12"> + <div class="form-check"> + <input + class="form-check-input" + id="create-site-require-email-verification" + type="checkbox" + checked={this.state.siteForm.require_email_verification} + onChange={linkEvent( + this, + this.handleSiteRequireEmailVerification + )} + /> + <label + class="form-check-label" + htmlFor="create-site-require-email-verification" + > + {i18n.t("require_email_verification")} + </label> + </div> + </div> + </div> + <div class="form-group row"> + <div class="col-12"> + <div class="form-check"> + <input + class="form-check-input" + id="create-site-require-application" + type="checkbox" + checked={this.state.siteForm.require_application} + onChange={linkEvent(this, this.handleSiteRequireApplication)} + /> + <label + class="form-check-label" + htmlFor="create-site-require-application" + > + {i18n.t("require_registration_application")} + </label> + </div> + </div> + </div> + <div class="form-group row"> + <div class="col-12"> + <div class="form-check"> + <input + class="form-check-input" + id="create-site-private-instance" + type="checkbox" + checked={this.state.siteForm.private_instance} + onChange={linkEvent(this, this.handleSitePrivateInstance)} + /> + <label + class="form-check-label" + htmlFor="create-site-private-instance" + > + {i18n.t("private_instance")} + </label> + </div> + </div> + </div> <div class="form-group row"> <div class="col-12"> <button @@ -311,6 +389,11 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { this.setState(this.state); } + handleSiteApplicationQuestionChange(val: string) { + this.state.siteForm.application_question = val; + this.setState(this.state); + } + handleSiteDescChange(i: SiteForm, event: any) { i.state.siteForm.description = event.target.value; i.setState(i.state); @@ -336,6 +419,21 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> { i.setState(i.state); } + handleSiteRequireApplication(i: SiteForm, event: any) { + i.state.siteForm.require_application = event.target.checked; + i.setState(i.state); + } + + handleSiteRequireEmailVerification(i: SiteForm, event: any) { + i.state.siteForm.require_email_verification = event.target.checked; + i.setState(i.state); + } + + handleSitePrivateInstance(i: SiteForm, event: any) { + i.state.siteForm.private_instance = event.target.checked; + i.setState(i.state); + } + handleCancel(i: SiteForm) { i.props.onCancel(); } diff --git a/src/shared/components/modlog.tsx b/src/shared/components/modlog.tsx index 2e8527d..cc3c7a1 100644 --- a/src/shared/components/modlog.tsx +++ b/src/shared/components/modlog.tsx @@ -25,9 +25,11 @@ import { i18n } from "../i18next"; import { InitialFetchRequest } from "../interfaces"; import { UserService, WebSocketService } from "../services"; import { + authField, fetchLimit, isBrowser, setIsoData, + setOptionalAuth, toast, wsClient, wsJsonToRes, @@ -482,6 +484,7 @@ export class Modlog extends Component<any, ModlogState> { community_id: this.state.communityId, page: this.state.page, limit: fetchLimit, + auth: authField(false), }; WebSocketService.Instance.send(wsClient.getModlog(modlogForm)); @@ -507,6 +510,7 @@ export class Modlog extends Component<any, ModlogState> { if (communityId) { modlogForm.community_id = Number(communityId); } + setOptionalAuth(modlogForm, req.auth); promises.push(req.client.getModlog(modlogForm)); @@ -514,6 +518,7 @@ export class Modlog extends Component<any, ModlogState> { let communityForm: GetCommunity = { id: Number(communityId), }; + setOptionalAuth(communityForm, req.auth); promises.push(req.client.getCommunity(communityForm)); } return promises; diff --git a/src/shared/components/home/password_change.tsx b/src/shared/components/person/password-change.tsx similarity index 100% rename from src/shared/components/home/password_change.tsx rename to src/shared/components/person/password-change.tsx diff --git a/src/shared/components/person/registration-applications.tsx b/src/shared/components/person/registration-applications.tsx new file mode 100644 index 0000000..9009f74 --- /dev/null +++ b/src/shared/components/person/registration-applications.tsx @@ -0,0 +1,249 @@ +import { Component, linkEvent } from "inferno"; +import { + ListRegistrationApplications, + ListRegistrationApplicationsResponse, + RegistrationApplicationResponse, + RegistrationApplicationView, + SiteView, + UserOperation, +} from "lemmy-js-client"; +import { Subscription } from "rxjs"; +import { i18n } from "../../i18next"; +import { InitialFetchRequest } from "../../interfaces"; +import { UserService, WebSocketService } from "../../services"; +import { + authField, + fetchLimit, + isBrowser, + setIsoData, + setupTippy, + toast, + updateRegistrationApplicationRes, + wsClient, + wsJsonToRes, + wsSubscribe, + wsUserOp, +} from "../../utils"; +import { HtmlTags } from "../common/html-tags"; +import { Spinner } from "../common/icon"; +import { Paginator } from "../common/paginator"; +import { RegistrationApplication } from "../common/registration-application"; + +enum UnreadOrAll { + Unread, + All, +} + +interface RegistrationApplicationsState { + applications: RegistrationApplicationView[]; + page: number; + site_view: SiteView; + unreadOrAll: UnreadOrAll; + loading: boolean; +} + +export class RegistrationApplications extends Component< + any, + RegistrationApplicationsState +> { + private isoData = setIsoData(this.context); + private subscription: Subscription; + private emptyState: RegistrationApplicationsState = { + unreadOrAll: UnreadOrAll.Unread, + applications: [], + page: 1, + site_view: this.isoData.site_res.site_view, + loading: true, + }; + + constructor(props: any, context: any) { + super(props, context); + + this.state = this.emptyState; + this.handlePageChange = this.handlePageChange.bind(this); + + if (!UserService.Instance.myUserInfo && isBrowser()) { + toast(i18n.t("not_logged_in"), "danger"); + this.context.router.history.push(`/login`); + } + + 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) { + this.state.applications = + this.isoData.routeData[0].registration_applications || []; // TODO test + this.state.loading = false; + } else { + this.refetch(); + } + } + + componentDidMount() { + setupTippy(); + } + + componentWillUnmount() { + if (isBrowser()) { + this.subscription.unsubscribe(); + } + } + + get documentTitle(): string { + return `@${ + UserService.Instance.myUserInfo.local_user_view.person.name + } ${i18n.t("registration_applications")} - ${ + this.state.site_view.site.name + }`; + } + + render() { + return ( + <div class="container"> + {this.state.loading ? ( + <h5> + <Spinner large /> + </h5> + ) : ( + <div class="row"> + <div class="col-12"> + <HtmlTags + title={this.documentTitle} + path={this.context.router.route.match.url} + /> + <h5 class="mb-2">{i18n.t("registration_applications")}</h5> + {this.selects()} + {this.applicationList()} + <Paginator + page={this.state.page} + onChange={this.handlePageChange} + /> + </div> + </div> + )} + </div> + ); + } + + unreadOrAllRadios() { + return ( + <div class="btn-group btn-group-toggle flex-wrap mb-2"> + <label + className={`btn btn-outline-secondary pointer + ${this.state.unreadOrAll == UnreadOrAll.Unread && "active"} + `} + > + <input + type="radio" + value={UnreadOrAll.Unread} + checked={this.state.unreadOrAll == UnreadOrAll.Unread} + onChange={linkEvent(this, this.handleUnreadOrAllChange)} + /> + {i18n.t("unread")} + </label> + <label + className={`btn btn-outline-secondary pointer + ${this.state.unreadOrAll == UnreadOrAll.All && "active"} + `} + > + <input + type="radio" + value={UnreadOrAll.All} + checked={this.state.unreadOrAll == UnreadOrAll.All} + onChange={linkEvent(this, this.handleUnreadOrAllChange)} + /> + {i18n.t("all")} + </label> + </div> + ); + } + + selects() { + return ( + <div className="mb-2"> + <span class="mr-3">{this.unreadOrAllRadios()}</span> + </div> + ); + } + + applicationList() { + return ( + <div> + {this.state.applications.map(ra => ( + <> + <hr /> + <RegistrationApplication + key={ra.registration_application.id} + application={ra} + /> + </> + ))} + </div> + ); + } + + handleUnreadOrAllChange(i: RegistrationApplications, event: any) { + i.state.unreadOrAll = Number(event.target.value); + i.state.page = 1; + i.setState(i.state); + i.refetch(); + } + + handlePageChange(page: number) { + this.setState({ page }); + this.refetch(); + } + + static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { + let promises: Promise<any>[] = []; + + let form: ListRegistrationApplications = { + unread_only: true, + page: 1, + limit: fetchLimit, + auth: req.auth, + }; + promises.push(req.client.listRegistrationApplications(form)); + + return promises; + } + + refetch() { + let unread_only = this.state.unreadOrAll == UnreadOrAll.Unread; + let form: ListRegistrationApplications = { + unread_only: unread_only, + page: this.state.page, + limit: fetchLimit, + auth: authField(), + }; + WebSocketService.Instance.send(wsClient.listRegistrationApplications(form)); + } + + parseMessage(msg: any) { + let op = wsUserOp(msg); + console.log(msg); + if (msg.error) { + toast(i18n.t(msg.error), "danger"); + return; + } else if (msg.reconnect) { + this.refetch(); + } else if (op == UserOperation.ListRegistrationApplications) { + let data = wsJsonToRes<ListRegistrationApplicationsResponse>(msg).data; + this.state.applications = data.registration_applications; + this.state.loading = false; + window.scrollTo(0, 0); + this.setState(this.state); + } else if (op == UserOperation.ApproveRegistrationApplication) { + let data = wsJsonToRes<RegistrationApplicationResponse>(msg).data; + updateRegistrationApplicationRes( + data.registration_application, + this.state.applications + ); + let uacs = UserService.Instance.unreadApplicationCountSub; + // Minor bug, where if the application switches from deny to approve, the count will still go down + uacs.next(uacs.getValue() - 1); + this.setState(this.state); + } + } +} diff --git a/src/shared/components/person/reports.tsx b/src/shared/components/person/reports.tsx index 2c83ff2..99edf96 100644 --- a/src/shared/components/person/reports.tsx +++ b/src/shared/components/person/reports.tsx @@ -29,11 +29,11 @@ import { wsSubscribe, wsUserOp, } from "../../utils"; -import { CommentReport } from "../comment/comment_report"; +import { CommentReport } from "../comment/comment-report"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; import { Paginator } from "../common/paginator"; -import { PostReport } from "../post/post_report"; +import { PostReport } from "../post/post-report"; enum UnreadOrAll { Unread, diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx index dff2959..322dfa9 100644 --- a/src/shared/components/person/settings.tsx +++ b/src/shared/components/person/settings.tsx @@ -1108,6 +1108,11 @@ export class Settings extends Component<any, SettingsState> { let op = wsUserOp(msg); console.log(msg); if (msg.error) { + this.setState({ + saveUserSettingsLoading: false, + changePasswordLoading: false, + deleteAccountLoading: false, + }); toast(i18n.t(msg.error), "danger"); return; } else if (op == UserOperation.SaveUserSettings) { diff --git a/src/shared/components/person/verify-email.tsx b/src/shared/components/person/verify-email.tsx new file mode 100644 index 0000000..d27a8bb --- /dev/null +++ b/src/shared/components/person/verify-email.tsx @@ -0,0 +1,97 @@ +import { Component } from "inferno"; +import { + SiteView, + UserOperation, + VerifyEmail as VerifyEmailForm, + VerifyEmailResponse, +} from "lemmy-js-client"; +import { Subscription } from "rxjs"; +import { i18n } from "../../i18next"; +import { WebSocketService } from "../../services"; +import { + isBrowser, + setIsoData, + toast, + wsClient, + wsJsonToRes, + wsSubscribe, + wsUserOp, +} from "../../utils"; +import { HtmlTags } from "../common/html-tags"; + +interface State { + verifyEmailForm: VerifyEmailForm; + site_view: SiteView; +} + +export class VerifyEmail extends Component<any, State> { + private isoData = setIsoData(this.context); + private subscription: Subscription; + + emptyState: State = { + verifyEmailForm: { + token: this.props.match.params.token, + }, + site_view: this.isoData.site_res.site_view, + }; + + constructor(props: any, context: any) { + super(props, context); + + this.state = this.emptyState; + + this.parseMessage = this.parseMessage.bind(this); + this.subscription = wsSubscribe(this.parseMessage); + } + + componentDidMount() { + WebSocketService.Instance.send( + wsClient.verifyEmail(this.state.verifyEmailForm) + ); + } + + componentWillUnmount() { + if (isBrowser()) { + this.subscription.unsubscribe(); + } + } + + get documentTitle(): string { + return `${i18n.t("verify_email")} - ${this.state.site_view.site.name}`; + } + + render() { + return ( + <div class="container"> + <HtmlTags + title={this.documentTitle} + path={this.context.router.route.match.url} + /> + <div class="row"> + <div class="col-12 col-lg-6 offset-lg-3 mb-4"> + <h5>{i18n.t("verify_email")}</h5> + </div> + </div> + </div> + ); + } + + parseMessage(msg: any) { + let op = wsUserOp(msg); + console.log(msg); + if (msg.error) { + toast(i18n.t(msg.error), "danger"); + this.setState(this.state); + this.props.history.push("/"); + return; + } else if (op == UserOperation.VerifyEmail) { + let data = wsJsonToRes<VerifyEmailResponse>(msg).data; + if (data) { + toast(i18n.t("email_verified")); + this.state = this.emptyState; + this.setState(this.state); + this.props.history.push("/login"); + } + } + } +} diff --git a/src/shared/components/post/post_report.tsx b/src/shared/components/post/post-report.tsx similarity index 92% rename from src/shared/components/post/post_report.tsx rename to src/shared/components/post/post-report.tsx index f2e1734..ff3368e 100644 --- a/src/shared/components/post/post_report.tsx +++ b/src/shared/components/post/post-report.tsx @@ -20,6 +20,9 @@ export class PostReport extends Component<PostReportProps, any> { render() { let r = this.props.report; let post = r.post; + let tippyContent = i18n.t( + r.post_report.resolved ? "unresolve_report" : "resolve_report" + ); // Set the original post data ( a troll could change it ) post.name = r.post_report.original_post_name; @@ -70,12 +73,8 @@ export class PostReport extends Component<PostReportProps, any> { <button className="btn btn-link btn-animate text-muted py-0" onClick={linkEvent(this, this.handleResolveReport)} - data-tippy-content={ - r.post_report.resolved ? "unresolve_report" : "resolve_report" - } - aria-label={ - r.post_report.resolved ? "unresolve_report" : "resolve_report" - } + data-tippy-content={tippyContent} + aria-label={tippyContent} > <Icon icon="check" diff --git a/src/shared/routes.ts b/src/shared/routes.ts index ddc3b62..86c0b3c 100644 --- a/src/shared/routes.ts +++ b/src/shared/routes.ts @@ -6,14 +6,16 @@ import { AdminSettings } from "./components/home/admin-settings"; import { Home } from "./components/home/home"; import { Instances } from "./components/home/instances"; import { Login } from "./components/home/login"; -import { PasswordChange } from "./components/home/password_change"; import { Setup } from "./components/home/setup"; import { Signup } from "./components/home/signup"; import { Modlog } from "./components/modlog"; import { Inbox } from "./components/person/inbox"; +import { PasswordChange } from "./components/person/password-change"; import { Profile } from "./components/person/profile"; +import { RegistrationApplications } from "./components/person/registration-applications"; import { Reports } from "./components/person/reports"; import { Settings } from "./components/person/settings"; +import { VerifyEmail } from "./components/person/verify-email"; import { CreatePost } from "./components/post/create-post"; import { Post } from "./components/post/post"; import { CreatePrivateMessage } from "./components/private_message/create-private-message"; @@ -128,6 +130,11 @@ export const routes: IRoutePropsWithFetch[] = [ component: Reports, fetchInitialData: req => Reports.fetchInitialData(req), }, + { + path: `/registration_applications`, + component: RegistrationApplications, + fetchInitialData: req => RegistrationApplications.fetchInitialData(req), + }, { path: `/search/q/:q/type/:type/sort/:sort/listing_type/:listing_type/community_id/:community_id/creator_id/:creator_id/page/:page`, component: Search, @@ -142,5 +149,9 @@ export const routes: IRoutePropsWithFetch[] = [ path: `/password_change/:token`, component: PasswordChange, }, + { + path: `/verify_email/:token`, + component: VerifyEmail, + }, { path: `/instances`, component: Instances }, ]; diff --git a/src/shared/services/UserService.ts b/src/shared/services/UserService.ts index 0c87f7d..031cf7d 100644 --- a/src/shared/services/UserService.ts +++ b/src/shared/services/UserService.ts @@ -20,6 +20,8 @@ export class UserService { new BehaviorSubject<number>(0); public unreadReportCountSub: BehaviorSubject<number> = new BehaviorSubject<number>(0); + public unreadApplicationCountSub: BehaviorSubject<number> = + new BehaviorSubject<number>(0); private constructor() { if (this.auth) { diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 28929a2..784a30f 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -18,6 +18,7 @@ import { PostReportView, PostView, PrivateMessageView, + RegistrationApplicationView, Search, SearchResponse, SearchType, @@ -1105,6 +1106,20 @@ export function updateCommentReportRes( } } +export function updateRegistrationApplicationRes( + data: RegistrationApplicationView, + applications: RegistrationApplicationView[] +) { + let found = applications.find( + ra => ra.registration_application.id == data.registration_application.id + ); + if (found) { + found.registration_application = data.registration_application; + found.admin = data.admin; + found.creator_local_user = data.creator_local_user; + } +} + export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] { let nodes: CommentNodeI[] = []; for (let comment of comments) { diff --git a/yarn.lock b/yarn.lock index 62df36c..0815ba3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4997,10 +4997,10 @@ lcid@^1.0.0: dependencies: invert-kv "^1.0.0" -lemmy-js-client@0.14.0-rc.1: - version "0.14.0-rc.1" - resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.14.0-rc.1.tgz#c714d5f308fa20d5244db3844630f7b197eafa1c" - integrity sha512-UF3I+80WTYWwQg2+96HTl0O2Yv0wy6rYFjlLNyzfqMXUZBnsr1O/SdJD1/9yAFPFbGkKgWusdncLoGgzFyn8eg== +lemmy-js-client@0.15.0-rc.6: + version "0.15.0-rc.6" + resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.15.0-rc.6.tgz#5f8552488ed82b8c0962c158edccb8ce1d56389e" + integrity sha512-eSEZ5+F2ScKVtx+wwjdReHirJBNLQL2YdTV4aMCBWaSsxfsXUcz18/urbNxo+fNMc7Q4u0aRd3737yKBeMP9Kw== levn@^0.4.1: version "0.4.1" -- 2.44.1