ui.components: split user component up to fix duplicate requests

Replace componentWillReceiveProps with getDerivedState and pass state as props to new component
This commit is contained in:
derek 2020-07-12 04:00:10 -04:00
parent d71897620c
commit f1d01f4fa0
3 changed files with 385 additions and 261 deletions

270
ui/src/components/user-details.tsx vendored Normal file
View file

@ -0,0 +1,270 @@
import { Component } from 'inferno';
import { WebSocketService, UserService } from '../services';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take, last } from 'rxjs/operators';
import { i18n } from '../i18next';
import {
UserOperation,
Post,
Comment,
CommunityUser,
SortType,
UserDetailsResponse,
UserView,
WebSocketJsonResponse,
UserDetailsView,
CommentResponse,
BanUserResponse,
PostResponse,
AddAdminResponse,
} from '../interfaces';
import {
wsJsonToRes,
toast,
commentsToFlatNodes,
setupTippy,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
} from '../utils';
import { PostListing } from './post-listing';
import { CommentNodes } from './comment-nodes';
interface UserDetailsProps {
username?: string;
user_id?: number;
page: number;
limit: number;
sort: string;
enableDownvotes: boolean;
enableNsfw: boolean;
view: UserDetailsView;
}
interface UserDetailsState {
follows: Array<CommunityUser>;
moderates: Array<CommunityUser>;
comments: Array<Comment>;
posts: Array<Post>;
saved?: Array<Post>;
admins: Array<UserView>;
}
export class UserDetails extends Component<UserDetailsProps, UserDetailsState> {
private subscription: Subscription;
constructor(props: any, context: any) {
super(props, context);
this.state = {
follows: [],
moderates: [],
comments: [],
posts: [],
saved: [],
admins: [],
};
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
componentDidMount() {
this.fetchUserData();
}
componentDidUpdate(lastProps: UserDetailsProps) {
for (const key of Object.keys(lastProps)) {
if (lastProps[key] !== this.props[key]) {
this.fetchUserData();
break;
}
}
setupTippy();
}
fetchUserData() {
WebSocketService.Instance.getUserDetails({
user_id: this.props.user_id,
username: this.props.username,
sort: this.props.sort,
saved_only: this.props.view === UserDetailsView.Saved,
page: this.props.page,
limit: this.props.limit,
});
}
render() {
return this.viewSelector(this.props.view);
}
viewSelector(view: UserDetailsView) {
if (view === UserDetailsView.Overview || view === UserDetailsView.Saved) {
return this.overview();
}
if (view === UserDetailsView.Comments) {
return this.comments();
}
if (view === UserDetailsView.Posts) {
return this.posts();
}
}
overview() {
const comments = this.state.comments.map((c: Comment) => {
return { type: 'comments', data: c };
});
const posts = this.state.posts.map((p: Post) => {
return { type: 'posts', data: p };
});
const combined: Array<{ type: string; data: Comment | Post }> = [
...comments,
...posts,
];
// Sort it
if (SortType[this.props.sort] === SortType.New) {
combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
} else {
combined.sort((a, b) => b.data.score - a.data.score);
}
return (
<div>
{combined.map(i => (
<div>
{i.type === 'posts' ? (
<PostListing
post={i.data as Post}
admins={this.state.admins}
showCommunity
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
/>
) : (
<CommentNodes
nodes={[{ comment: i.data as Comment }]}
admins={this.state.admins}
noIndent
showContext
enableDownvotes={this.props.enableDownvotes}
/>
)}
</div>
))}
</div>
);
}
comments() {
return (
<div>
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
admins={this.state.admins}
noIndent
showContext
enableDownvotes={this.props.enableDownvotes}
/>
</div>
);
}
posts() {
return (
<div>
{this.state.posts.map(post => (
<PostListing
post={post}
admins={this.state.admins}
showCommunity
enableDownvotes={this.props.enableDownvotes}
enableNsfw={this.props.enableNsfw}
/>
))}
</div>
);
}
parseMessage(msg: WebSocketJsonResponse) {
const res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
if (msg.error == 'couldnt_find_that_username_or_email') {
this.context.router.history.push('/');
}
return;
} else if (msg.reconnect) {
this.fetchUserData();
} else if (res.op == UserOperation.GetUserDetails) {
const data = res.data as UserDetailsResponse;
this.setState({
comments: data.comments,
follows: data.follows,
moderates: data.moderates,
posts: data.posts,
admins: data.admins,
});
} else if (res.op == UserOperation.CreateCommentLike) {
const data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.comments);
this.setState({
comments: this.state.comments,
});
} else if (res.op == UserOperation.EditComment) {
const data = res.data as CommentResponse;
editCommentRes(data, this.state.comments);
this.setState({
comments: this.state.comments,
});
} else if (res.op == UserOperation.CreateComment) {
const data = res.data as CommentResponse;
if (
UserService.Instance.user &&
data.comment.creator_id == UserService.Instance.user.id
) {
toast(i18n.t('reply_sent'));
}
} else if (res.op == UserOperation.SaveComment) {
const data = res.data as CommentResponse;
saveCommentRes(data, this.state.comments);
this.setState({
comments: this.state.comments,
});
} else if (res.op == UserOperation.CreatePostLike) {
const data = res.data as PostResponse;
createPostLikeFindRes(data, this.state.posts);
this.setState({
posts: this.state.posts,
});
} else if (res.op == UserOperation.BanUser) {
const data = res.data as BanUserResponse;
this.state.comments
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned = data.banned));
this.state.posts
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned = data.banned));
this.setState({
posts: this.state.posts,
comments: this.state.comments,
});
} else if (res.op == UserOperation.AddAdmin) {
const data = res.data as AddAdminResponse;
this.setState({
admins: data.admins,
});
}
}
}

View file

@ -4,24 +4,18 @@ import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
Post,
Comment,
CommunityUser,
GetUserDetailsForm,
SortType,
ListingType,
UserDetailsResponse,
UserView,
CommentResponse,
UserSettingsForm,
LoginResponse,
BanUserResponse,
AddAdminResponse,
DeleteAccountForm,
PostResponse,
WebSocketJsonResponse,
GetSiteResponse,
Site,
UserDetailsView,
UserDetailsResponse,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import {
@ -34,28 +28,15 @@ import {
languages,
showAvatars,
toast,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
commentsToFlatNodes,
setupTippy,
} from '../utils';
import { PostListing } from './post-listing';
import { UserListing } from './user-listing';
import { SortSelect } from './sort-select';
import { ListingTypeSelect } from './listing-type-select';
import { CommentNodes } from './comment-nodes';
import { MomentTime } from './moment-time';
import { i18n } from '../i18next';
import moment from 'moment';
enum View {
Overview,
Comments,
Posts,
Saved,
}
import { UserDetails } from './user-details';
interface UserState {
user: UserView;
@ -63,11 +44,7 @@ interface UserState {
username: string;
follows: Array<CommunityUser>;
moderates: Array<CommunityUser>;
comments: Array<Comment>;
posts: Array<Post>;
saved?: Array<Post>;
admins: Array<UserView>;
view: View;
view: UserDetailsView;
sort: SortType;
page: number;
loading: boolean;
@ -102,14 +79,11 @@ export class User extends Component<any, UserState> {
username: null,
follows: [],
moderates: [],
comments: [],
posts: [],
admins: [],
loading: true,
loading: false,
avatarLoading: false,
view: this.getViewFromProps(this.props),
sort: this.getSortTypeFromProps(this.props),
page: this.getPageFromProps(this.props),
view: User.getViewFromProps(this.props.match.view),
sort: User.getSortTypeFromProps(this.props.match.sort),
page: User.getPageFromProps(this.props.match.page),
userSettingsForm: {
show_nsfw: null,
theme: null,
@ -145,7 +119,7 @@ export class User extends Component<any, UserState> {
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.state = Object.assign({}, this.emptyState);
this.handleSortChange = this.handleSortChange.bind(this);
this.handleUserSettingsSortTypeChange = this.handleUserSettingsSortTypeChange.bind(
this
@ -154,7 +128,7 @@ export class User extends Component<any, UserState> {
this
);
this.state.user_id = Number(this.props.match.params.id);
this.state.user_id = Number(this.props.match.params.id) || null;
this.state.username = this.props.match.params.username;
this.subscription = WebSocketService.Instance.subject
@ -165,7 +139,6 @@ export class User extends Component<any, UserState> {
() => console.log('complete')
);
this.refetch();
WebSocketService.Instance.getSite();
}
@ -176,38 +149,32 @@ export class User extends Component<any, UserState> {
);
}
getViewFromProps(props: any): View {
return props.match.params.view
? View[capitalizeFirstLetter(props.match.params.view)]
: View.Overview;
static getViewFromProps(view: any): UserDetailsView {
return view
? UserDetailsView[capitalizeFirstLetter(view)]
: UserDetailsView.Overview;
}
getSortTypeFromProps(props: any): SortType {
return props.match.params.sort
? routeSortTypeToEnum(props.match.params.sort)
: SortType.New;
static getSortTypeFromProps(sort: any): SortType {
return sort ? routeSortTypeToEnum(sort) : SortType.New;
}
getPageFromProps(props: any): number {
return props.match.params.page ? Number(props.match.params.page) : 1;
static getPageFromProps(page: any): number {
return page ? Number(page) : 1;
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
// Necessary for back button for some reason
componentWillReceiveProps(nextProps: any) {
if (
nextProps.history.action == 'POP' ||
nextProps.history.action == 'PUSH'
) {
this.state.view = this.getViewFromProps(nextProps);
this.state.sort = this.getSortTypeFromProps(nextProps);
this.state.page = this.getPageFromProps(nextProps);
this.setState(this.state);
this.refetch();
}
static getDerivedStateFromProps(props) {
return {
view: this.getViewFromProps(props.match.params.view),
sort: this.getSortTypeFromProps(props.match.params.sort),
page: this.getPageFromProps(props.match.params.page),
user_id: Number(props.match.params.id) || null,
username: props.match.params.username,
};
}
componentDidUpdate(lastProps: any, _lastState: UserState, _snapshot: any) {
@ -219,6 +186,8 @@ export class User extends Component<any, UserState> {
// Couldnt get a refresh working. This does for now.
location.reload();
}
document.title = `/u/${this.state.username} - ${this.state.site.name}`;
setupTippy();
}
render() {
@ -242,13 +211,20 @@ export class User extends Component<any, UserState> {
class="rounded-circle mr-2"
/>
)}
<span>/u/{this.state.user.name}</span>
<span>/u/{this.state.username}</span>
</h5>
{this.selects()}
{this.state.view == View.Overview && this.overview()}
{this.state.view == View.Comments && this.comments()}
{this.state.view == View.Posts && this.posts()}
{this.state.view == View.Saved && this.overview()}
<UserDetails
user_id={this.state.user_id}
username={this.state.username}
sort={SortType[this.state.sort]}
page={this.state.page}
limit={fetchLimit}
enableDownvotes={this.state.site.enable_downvotes}
enableNsfw={this.state.site.enable_nsfw}
view={this.state.view}
key={this.state.user_id || this.state.username}
/>
{this.paginator()}
</div>
<div class="col-12 col-md-4">
@ -268,52 +244,52 @@ export class User extends Component<any, UserState> {
<div class="btn-group btn-group-toggle">
<label
className={`btn btn-sm btn-secondary pointer btn-outline-light
${this.state.view == View.Overview && 'active'}
${this.state.view == UserDetailsView.Overview && 'active'}
`}
>
<input
type="radio"
value={View.Overview}
checked={this.state.view == View.Overview}
value={UserDetailsView.Overview}
checked={this.state.view === UserDetailsView.Overview}
onChange={linkEvent(this, this.handleViewChange)}
/>
{i18n.t('overview')}
</label>
<label
className={`btn btn-sm btn-secondary pointer btn-outline-light
${this.state.view == View.Comments && 'active'}
${this.state.view == UserDetailsView.Comments && 'active'}
`}
>
<input
type="radio"
value={View.Comments}
checked={this.state.view == View.Comments}
value={UserDetailsView.Comments}
checked={this.state.view == UserDetailsView.Comments}
onChange={linkEvent(this, this.handleViewChange)}
/>
{i18n.t('comments')}
</label>
<label
className={`btn btn-sm btn-secondary pointer btn-outline-light
${this.state.view == View.Posts && 'active'}
${this.state.view == UserDetailsView.Posts && 'active'}
`}
>
<input
type="radio"
value={View.Posts}
checked={this.state.view == View.Posts}
value={UserDetailsView.Posts}
checked={this.state.view == UserDetailsView.Posts}
onChange={linkEvent(this, this.handleViewChange)}
/>
{i18n.t('posts')}
</label>
<label
className={`btn btn-sm btn-secondary pointer btn-outline-light
${this.state.view == View.Saved && 'active'}
${this.state.view == UserDetailsView.Saved && 'active'}
`}
>
<input
type="radio"
value={View.Saved}
checked={this.state.view == View.Saved}
value={UserDetailsView.Saved}
checked={this.state.view == UserDetailsView.Saved}
onChange={linkEvent(this, this.handleViewChange)}
/>
{i18n.t('saved')}
@ -347,84 +323,6 @@ export class User extends Component<any, UserState> {
);
}
overview() {
let combined: Array<{ type_: string; data: Comment | Post }> = [];
let comments = this.state.comments.map(e => {
return { type_: 'comments', data: e };
});
let posts = this.state.posts.map(e => {
return { type_: 'posts', data: e };
});
combined.push(...comments);
combined.push(...posts);
// Sort it
if (this.state.sort == SortType.New) {
combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
} else {
combined.sort((a, b) => b.data.score - a.data.score);
}
return (
<div>
{combined.map(i => (
<div>
{i.type_ == 'posts' ? (
<PostListing
post={i.data as Post}
admins={this.state.admins}
showCommunity
enableDownvotes={this.state.site.enable_downvotes}
enableNsfw={this.state.site.enable_nsfw}
/>
) : (
<CommentNodes
nodes={[{ comment: i.data as Comment }]}
admins={this.state.admins}
noIndent
showCommunity
showContext
enableDownvotes={this.state.site.enable_downvotes}
/>
)}
</div>
))}
</div>
);
}
comments() {
return (
<div>
<CommentNodes
nodes={commentsToFlatNodes(this.state.comments)}
admins={this.state.admins}
noIndent
showCommunity
showContext
enableDownvotes={this.state.site.enable_downvotes}
/>
</div>
);
}
posts() {
return (
<div>
{this.state.posts.map(post => (
<PostListing
post={post}
admins={this.state.admins}
showCommunity
enableDownvotes={this.state.site.enable_downvotes}
enableNsfw={this.state.site.enable_nsfw}
/>
))}
</div>
);
}
userInfo() {
let user = this.state.user;
return (
@ -920,58 +818,43 @@ export class User extends Component<any, UserState> {
}
updateUrl() {
let viewStr = View[this.state.view].toLowerCase();
let viewStr = UserDetailsView[this.state.view].toLowerCase();
let sortStr = SortType[this.state.sort].toLowerCase();
this.props.history.push(
`/u/${this.state.user.name}/view/${viewStr}/sort/${sortStr}/page/${this.state.page}`
`/u/${this.state.username}/view/${viewStr}/sort/${sortStr}/page/${this.state.page}`
);
this.setState(this.state);
}
nextPage(i: User) {
i.state.page++;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
prevPage(i: User) {
i.state.page--;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
refetch() {
let form: GetUserDetailsForm = {
user_id: this.state.user_id,
username: this.state.username,
sort: SortType[this.state.sort],
saved_only: this.state.view == View.Saved,
page: this.state.page,
limit: fetchLimit,
};
WebSocketService.Instance.getUserDetails(form);
}
handleSortChange(val: SortType) {
this.state.sort = val;
this.state.page = 1;
this.setState(this.state);
this.updateUrl();
this.refetch();
}
handleViewChange(i: User, event: any) {
i.state.view = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.updateUrl();
i.refetch();
}
handleUserSettingsShowNsfwChange(i: User, event: any) {
i.state.userSettingsForm.show_nsfw = event.target.checked;
i.setState(i.state);
i.setState({
userSettingsForm: {
...i.state.userSettingsForm,
show_nsfw: event.target.checked,
},
});
}
handleUserSettingsShowAvatarsChange(i: User, event: any) {
@ -1136,29 +1019,28 @@ export class User extends Component<any, UserState> {
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
const res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.state.deleteAccountLoading = false;
this.state.avatarLoading = false;
this.state.userSettingsLoading = false;
if (msg.error == 'couldnt_find_that_username_or_email') {
this.context.router.history.push('/');
}
this.setState(this.state);
this.setState({
deleteAccountLoading: false,
avatarLoading: false,
userSettingsLoading: false,
});
return;
} else if (msg.reconnect) {
this.refetch();
} else if (res.op == UserOperation.GetUserDetails) {
let data = res.data as UserDetailsResponse;
// Since the UserDetails 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
const data = res.data as UserDetailsResponse;
if (this.state.user.id !== data.user.id) {
this.state.user = data.user;
this.state.comments = data.comments;
this.state.follows = data.follows;
this.state.moderates = data.moderates;
this.state.posts = data.posts;
this.state.admins = data.admins;
this.state.loading = false;
if (this.isCurrentUser) {
this.state.userSettingsForm.show_nsfw =
UserService.Instance.user.show_nsfw;
@ -1177,61 +1059,26 @@ export class User extends Component<any, UserState> {
UserService.Instance.user.show_avatars;
this.state.userSettingsForm.matrix_user_id = this.state.user.matrix_user_id;
}
document.title = `/u/${this.state.user.name} - ${this.state.site.name}`;
window.scrollTo(0, 0);
this.setState(this.state);
setupTippy();
} else if (res.op == UserOperation.EditComment) {
let data = res.data as CommentResponse;
editCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
if (
UserService.Instance.user &&
data.comment.creator_id == UserService.Instance.user.id
) {
toast(i18n.t('reply_sent'));
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
saveCommentRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
createCommentLikeRes(data, this.state.comments);
this.setState(this.state);
} else if (res.op == UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
createPostLikeFindRes(data, this.state.posts);
this.setState(this.state);
} else if (res.op == UserOperation.BanUser) {
let data = res.data as BanUserResponse;
this.state.comments
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned = data.banned));
this.state.posts
.filter(c => c.creator_id == data.user.id)
.forEach(c => (c.banned = data.banned));
this.setState(this.state);
} else if (res.op == UserOperation.AddAdmin) {
let data = res.data as AddAdminResponse;
this.state.admins = data.admins;
this.setState(this.state);
} else if (res.op == UserOperation.SaveUserSettings) {
let data = res.data as LoginResponse;
this.state.userSettingsLoading = false;
this.setState(this.state);
const data = res.data as LoginResponse;
UserService.Instance.login(data);
this.setState({
userSettingsLoading: false,
});
window.scrollTo(0, 0);
} else if (res.op == UserOperation.DeleteAccount) {
this.state.deleteAccountLoading = false;
this.state.deleteAccountShowConfirm = false;
this.setState(this.state);
this.setState({
deleteAccountLoading: false,
deleteAccountShowConfirm: false,
});
this.context.router.history.push('/');
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
const data = res.data as GetSiteResponse;
this.setState({
site: data.site,
});
}
}
}

View file

@ -937,3 +937,10 @@ export interface WebSocketJsonResponse {
error?: string;
reconnect?: boolean;
}
export enum UserDetailsView {
Overview,
Comments,
Posts,
Saved,
}