You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

898 lines
26 KiB

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { retryWhen, delay, take } from 'rxjs/operators';
import useSWR from 'swr';
import {
UserOperation,
GetFollowedCommunitiesResponse,
SortType,
GetSiteResponse,
ListingType,
DataType,
PostResponse,
Post,
Comment,
GetCommentsResponse,
CommentResponse,
AddAdminResponse,
AddSitemodResponse,
BanUserResponse,
WebSocketJsonResponse,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { PostListings } from './post-listings';
import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select';
import { ListingTypeSelect } from './listing-type-select';
import { DataTypeSelect } from './data-type-select';
import { SiteForm } from './site-form';
import { UserListing } from './user-listing';
import { CommunityLink } from './community-link';
import {
wsJsonToRes,
repoUrl,
mdToHtml,
fetchLimit,
toast,
getListingTypeFromProps,
getPageFromProps,
getSortTypeFromProps,
getDataTypeFromProps,
editCommentRes,
saveCommentRes,
createCommentLikeRes,
createPostLikeFindRes,
editPostFindRes,
commentsToFlatNodes,
isCommentChanged,
isPostChanged,
fetcher,
getMoscowTime,
siteName,
api,
savePostFindRes,
setBannedPCList,
} from '../utils';
import { BASE_PATH } from '../isProduction';
import { i18n } from '../i18next';
import { Trans, withTranslation } from 'react-i18next';
import { PATREON_URL } from '../constants';
import { Icon } from './icon';
import { Box, Flex, Heading, Link as ThemeLink } from 'theme-ui';
import Button, { ResponsiveButton } from './elements/Button';
import Card from './elements/Card';
import Block from './elements/Block';
import { SpinnerSection } from './Spinner';
import { useInterval } from '../hooks/useInterval';
import StyledLink from '../StyledLink';
import Tooltip from './Tooltip';
import Header, { Separator } from './Header';
import { siteSubject } from '../services/SiteService';
import FeaturedPosts from './FeaturedPosts';
import { generateFullTagline, getRandomRawTagline } from '../tagline';
import { List } from 'immutable';
const tagline = generateFullTagline(getRandomRawTagline());
function MoscowTime() {
const [time, setTime] = useState(null);
useInterval(
() => {
setTime(getMoscowTime);
},
1000,
true
);
return <>It is currently {time} in Moscow</>;
}
interface UrlParams {
listingType?: string;
dataType?: string;
sort?: string;
page?: number;
}
function Main(props) {
const [subscribedCommunities, setSubscribedCommunities] = useState([]);
const [siteRes, setSiteRes] = useState<GetSiteResponse>({
site: {
id: null,
name: null,
creator_id: null,
published: null,
creator_name: null,
number_of_users: null,
number_of_posts: null,
number_of_comments: null,
number_of_communities: null,
enable_downvotes: null,
enable_create_communities: null,
open_registration: null,
enable_nsfw: null,
autosubscribe_comms: [],
},
admins: [],
sitemods: [],
online: null,
});
const [showEditSite, setShowEditSite] = useState(false);
const [listingType, setListingType] = useState<ListingType>(
getListingTypeFromProps(props) || ListingType.Subscribed
);
const [dataType, setDataType] = useState(
getDataTypeFromProps(props) || DataType.Post
);
const [sort, setSort] = useState<SortType>(
getSortTypeFromProps(props) || SortType.Hot
);
const [page, setPage] = useState(getPageFromProps(props));
const [filtersOpen, setFiltersOpen] = useState(false);
let params = new URLSearchParams({
page: page,
limit: fetchLimit,
sort: SortType[sort],
type_: ListingType[listingType],
} as any);
if (UserService.Instance.auth) {
params.append('auth', UserService.Instance.auth);
}
const immutFetcher = url => api.get(url).then(res => List(res.data.posts));
const {
data: postList,
mutate: postListMutate,
isValidating: postListValidating,
} = useSWR(
dataType === DataType.Post ? `post/list?${params.toString()}` : null,
immutFetcher,
{
revalidateOnFocus: false,
}
) as { data: List<Post>; mutate: any; isValidating: boolean };
const {
data: featuredPostList,
mutate: featuredPostListMutate,
isValidating: featuredPostListValidating,
} = useSWR(
dataType === DataType.Post
? `post/featured${
UserService.Instance.auth ? `?auth=${UserService.Instance.auth}` : ''
}`
: null,
immutFetcher,
{
revalidateOnFocus: false,
}
) as { data: List<Post>; mutate: any; isValidating: boolean };
params = new URLSearchParams({
type_: ListingType[listingType],
sort: SortType[sort],
page: page,
limit: fetchLimit,
} as any);
if (UserService.Instance.auth) {
params.append('auth', UserService.Instance.auth);
}
const commentFetcher = url =>
api.get(url).then(res => List(res.data.comments));
const {
data: commentsList,
mutate: commentsListMutate,
isValidating: commentsListValidating,
} = useSWR(
dataType === DataType.Comment ? `comment/list?${params.toString()}` : null,
commentFetcher,
{
revalidateOnFocus: false,
}
) as { data: List<Comment>; mutate: any; isValidating: boolean };
const parseMessage = useCallback(
(msg: WebSocketJsonResponse) => {
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op === UserOperation.GetFollowedCommunities) {
let data = res.data as GetFollowedCommunitiesResponse;
setSubscribedCommunities(data.communities);
} else if (res.op === UserOperation.CreatePost) {
let data = res.data as PostResponse;
// If you're on subscribed, only push it if you're subscribed.
if (sort == SortType.New) {
if (listingType === ListingType.Subscribed) {
if (
subscribedCommunities
.map(c => c.community_id)
.includes(data.post.community_id)
) {
postListMutate(prevList => prevList.unshift(data.post), false);
}
} else {
// NSFW posts
let nsfw = data.post.nsfw || data.post.community_nsfw;
// Don't push the post if its nsfw, and don't have that setting on
if (
!nsfw ||
(nsfw &&
UserService.Instance.user &&
UserService.Instance.user.show_nsfw)
) {
postListMutate(prevList => prevList.unshift(data.post), false);
}
}
}
} else if (isPostChanged(res.op)) {
let data = res.data as PostResponse;
postListMutate(prevList => editPostFindRes(data, prevList), false);
featuredPostListMutate(
prevList => editPostFindRes(data, prevList),
false
);
} else if (res.op === UserOperation.CreatePostLike) {
let data = res.data as PostResponse;
postListMutate(
prevList => createPostLikeFindRes(data, prevList),
false
);
featuredPostListMutate(
prevList => createPostLikeFindRes(data, prevList),
false
);
} else if (res.op == UserOperation.SavePost) {
let data = res.data as PostResponse;
postListMutate(prevList => savePostFindRes(data, prevList), false);
featuredPostListMutate(
prevList => savePostFindRes(data, prevList),
false
);
} else if (res.op === UserOperation.AddAdmin) {
let data = res.data as AddAdminResponse;
setSiteRes(s => ({
...s,
admins: data.admins,
}));
} else if (res.op === UserOperation.AddSitemod) {
let data = res.data as AddSitemodResponse;
setSiteRes(s => ({
...s,
sitemods: data.sitemods,
}));
} else if (res.op === UserOperation.BanUser) {
let data = res.data as BanUserResponse;
postListMutate(
prevList => setBannedPCList(data, prevList) as List<Post>,
false
);
} else if (res.op === UserOperation.GetComments) {
let data = res.data as GetCommentsResponse;
commentsListMutate(List(data.comments), false);
} else if (isCommentChanged(res.op)) {
let data = res.data as CommentResponse;
commentsListMutate(prevList => editCommentRes(data, prevList), false);
} else if (res.op == UserOperation.CreateComment) {
let data = res.data as CommentResponse;
// Necessary since it might be a user reply
// we also want to only push new comments to the front if we're sorting by new
if (data.recipient_ids.length == 0 && sort == SortType.New) {
// If you're on subscribed, only push it if you're subscribed.
if (listingType == ListingType.Subscribed) {
if (
subscribedCommunities
.map(c => c.community_id)
.includes(data.comment.community_id)
) {
commentsListMutate(
prevList => prevList.unshift(data.comment),
false
);
}
} else {
commentsListMutate(
prevList => prevList.unshift(data.comment),
false
);
}
}
} else if (res.op == UserOperation.SaveComment) {
let data = res.data as CommentResponse;
commentsListMutate(prevList => saveCommentRes(data, prevList), false);
} else if (res.op == UserOperation.CreateCommentLike) {
let data = res.data as CommentResponse;
commentsListMutate(
prevList => createCommentLikeRes(data, prevList),
false
);
}
},
[
commentsListMutate,
featuredPostListMutate,
listingType,
postListMutate,
sort,
subscribedCommunities,
]
);
useEffect(() => {
let siteSub = siteSubject.subscribe(res => {
setSiteRes(res);
});
if (UserService.Instance.user) {
WebSocketService.Instance.getFollowedCommunities();
}
WebSocketService.Instance.communityJoinRoom(0);
return function unmount() {
siteSub.unsubscribe();
WebSocketService.Instance.leaveAll();
};
}, []);
useEffect(() => {
let subscription;
if (
!(
commentsListValidating ||
postListValidating ||
featuredPostListValidating
)
) {
subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => parseMessage(msg),
err => console.error(err),
() => console.log('complete')
);
}
return () => {
if (subscription) {
subscription.unsubscribe();
}
};
}, [
parseMessage,
commentsListValidating,
postListValidating,
featuredPostListValidating,
]);
//whenever props updates
useEffect(() => {
setListingType(getListingTypeFromProps(props));
setDataType(getDataTypeFromProps(props));
setSort(getSortTypeFromProps(props));
setPage(getPageFromProps(props));
}, [props]);
params = new URLSearchParams({
sort: SortType[SortType.Hot],
limit: 6,
} as any);
const {
data: trendingCommunitiesList,
} = useSWR(`community/list?${params.toString()}`, url =>
fetcher(url).then(data => data.communities)
);
const canAdmin = () => {
return (
UserService.Instance.user &&
siteRes.admins.map(a => a.id).includes(UserService.Instance.user.id)
);
};
const updateUrl = (paramUpdates: UrlParams) => {
const listingTypeStr =
paramUpdates.listingType || ListingType[listingType].toLowerCase();
const dataTypeStr =
paramUpdates.dataType || DataType[dataType].toLowerCase();
const sortStr = paramUpdates.sort || SortType[sort].toLowerCase();
const pageUrl = paramUpdates.page || page;
props.history.push(
`/home/data_type/${dataTypeStr}/listing_type/${listingTypeStr}/sort/${sortStr}/page/${pageUrl}`
);
};
const nextPage = () => {
setPage(page + 1);
updateUrl({ page: page + 1 });
window.scrollTo(0, 0);
};
const prevPage = () => {
setPage(page - 1);
updateUrl({ page: page - 1 });
window.scrollTo(0, 0);
};
const handleSortChange = (val: SortType) => {
setSort(val);
updateUrl({ sort: SortType[val].toLowerCase(), page: 1 });
window.scrollTo(0, 0);
};
const handleListingTypeChange = (val: ListingType) => {
setListingType(val);
updateUrl({ listingType: ListingType[val].toLowerCase(), page: 1 });
window.scrollTo(0, 0);
};
const handleDataTypeChange = (val: DataType) => {
setDataType(val);
updateUrl({ dataType: DataType[val].toLowerCase(), page: 1 });
window.scrollTo(0, 0);
};
const trendingCommunities = () => {
return (
<div>
<Heading color="text" as="h5">
<Trans i18nKey="trending_communities">
#<StyledLink to="/communities">#</StyledLink>
</Trans>
</Heading>
<ul className="list-inline">
{trendingCommunitiesList.map(community => (
<li key={community.id} className="list-inline-item">
<CommunityLink community={community} />
</li>
))}
</ul>
</div>
);
};
const siteInfo = () => {
return (
<div>
<Card>
<Heading mb={4} className="text-center" color="text" as="h3">
{siteRes.site.name}
</Heading>
{/* <h5 className="mb-4 text-center h3">{`${this.state.siteRes.site.name}`}</h5> */}
<img
className="img-fluid mb-2"
src={`${BASE_PATH}hexbear-logo.png`}
alt="hexbear logo"
/>
{/* <img
src={`${BASE_PATH}welcome.gif`}
className="img-fluid"
style={{ width: '100%' }}
alt="welcome sign"
/> */}
{canAdmin() && (
<ul className="list-inline mb-1 text-muted font-weight-bold">
<li className="list-inline-item-action">
<span
className="pointer"
onClick={() => setShowEditSite(true)}
data-tippy-content={i18n.t('edit')}
>
<Icon name="edit" />
</span>
</li>
</ul>
)}
<div className="my-2">
<MoscowTime />
</div>
<ul className="my-2 list-inline">
<li className="list-inline-item user-online-badge mb-1">
<Icon name="hexbear" className="mr-2" />{' '}
{i18n.t('number_online', { count: siteRes.online })}
</li>
<li className="list-inline-item badge badge-secondary">
{i18n.t('number_of_users', {
count: siteRes.site.number_of_users,
})}
</li>
<li className="list-inline-item badge badge-secondary">
{i18n.t('number_of_communities', {
count: siteRes.site.number_of_communities,
})}
</li>
<li className="list-inline-item badge badge-secondary">
{i18n.t('number_of_posts', {
count: siteRes.site.number_of_posts,
})}
</li>
<li className="list-inline-item badge badge-secondary">
{i18n.t('number_of_comments', {
count: siteRes.site.number_of_comments,
})}
</li>
<li className="list-inline-item">
<Link className="badge badge-secondary" to="/modlog">
{i18n.t('modlog')}
</Link>
</li>
</ul>
<ul className="mt-1 list-inline small mb-0">
<li className="list-inline-item">{i18n.t('admins')}:</li>
{siteRes.admins.map(admin => (
<li key={admin.id} className="list-inline-item">
<UserListing
user={{
name: admin.name,
avatar: admin.avatar,
local: admin.local,
actor_id: admin.actor_id,
id: admin.id,
}}
/>
</li>
))}
</ul>
<ul className="mt-1 list-inline small mb-0">
<li className="list-inline-item">{i18n.t('sitemods')}:</li>
{siteRes.sitemods.map(sitemod => (
<li key={sitemod.id} className="list-inline-item">
<UserListing
user={{
name: sitemod.name,
avatar: sitemod.avatar,
local: sitemod.local,
actor_id: sitemod.actor_id,
id: sitemod.id,
}}
/>
</li>
))}
</ul>
</Card>
{siteRes.site.description && (
<Card>
<div
className="md-div"
dangerouslySetInnerHTML={mdToHtml(siteRes.site.description)}
/>
</Card>
)}
</div>
);
};
const sidebar = () => {
return (
<div>
{!showEditSite ? (
siteInfo()
) : (
<SiteForm
site={siteRes.site}
onCancel={() => setShowEditSite(false)}
/>
)}
</div>
);
};
const donations = () => {
return (
<Card>
<p>Our Soros stipend only gets us so far.</p>
<ThemeLink href={PATREON_URL}>
<Heading as="h4">Support Us on Liberapay</Heading>
</ThemeLink>
</Card>
);
};
const landing = () => {
return (
<Card>
<Heading as="h5">
{i18n.t('powered_by')}
<svg className="icon mx-2">
<use xlinkHref="#icon-mouse">#</use>
</svg>
<ThemeLink href={repoUrl}>Hexbear</ThemeLink>
</Heading>
<p className="mb-0">
<Trans i18nKey="landing_0">
#
<ThemeLink href="https://en.wikipedia.org/wiki/Social_network_aggregation">
#
</ThemeLink>
<ThemeLink href="https://en.wikipedia.org/wiki/Fediverse">
#
</ThemeLink>
<br className="big" />
<code>#</code>
<br />
<b>#</b>
<br className="big" />
<ThemeLink href={repoUrl}>#</ThemeLink>
<br className="big" />
<ThemeLink href="https://www.rust-lang.org">#</ThemeLink>
<ThemeLink href="https://actix.rs/">#</ThemeLink>
<ThemeLink href="https://infernojs.org">#</ThemeLink>
<ThemeLink href="https://www.typescriptlang.org/">#</ThemeLink>
<br className="big" />
<ThemeLink href="https://github.com/LemmyNet/lemmy/graphs/contributors?type=a">
#
</ThemeLink>
</Trans>
</p>
</Card>
);
};
const my_sidebar = () => {
return (
<div>
{trendingCommunitiesList && (
<div>
<Card>
{trendingCommunities()}
{UserService.Instance.user && subscribedCommunities.length > 0 && (
<div>
<Heading color="text" as="h5">
<Trans i18nKey="subscribed_to_communities">
#<StyledLink to="/communities">#</StyledLink>
</Trans>
</Heading>
<ul className="list-inline">
{subscribedCommunities.map(community => (
<li key={community.id} className="list-inline-item">
<CommunityLink
community={{
name: community.community_name,
id: community.community_id,
local: community.community_local,
actor_id: community.community_actor_id,
}}
/>
</li>
))}
</ul>
</div>
)}
<Flex>
<Button
css={{ width: '100%', color: '#fff !important' }}
as={Link}
// @ts-ignore
to="/create_post"
>
{i18n.t('create_post')}
</Button>
</Flex>
</Card>
{sidebar()}
{donations()}
{landing()}
</div>
)}
</div>
);
};
const listings = () => {
return dataType == DataType.Post ? (
<>
<FeaturedPosts posts={featuredPostList} />
<PostListings
// filter out sticky posts from main and announcements otherwise they show up twice
posts={postList}
sort={sort}
showCommunity
enableDownvotes
enableNsfw
/>
<Box my={4}>
{page > 1 && (
<Button mr={1} variant="muted" onClick={prevPage}>
{i18n.t('prev')}
</Button>
)}
{postList.size > 0 && (
<Button variant="muted" onClick={nextPage}>
{i18n.t('next')}
</Button>
)}
</Box>
</>
) : (
<CommentNodes
nodes={commentsToFlatNodes(commentsList)}
noIndent
showCommunity
sortType={sort}
showContext
enableDownvotes={siteRes.site.enable_downvotes}
/>
);
};
const selects = () => {
const isMobile = window.innerWidth < 768;
return (
<div>
<Block display="flex" alignItems="center" flexWrap="wrap" mb={3}>
<SortSelect sort={sort} onChange={handleSortChange} />
{listingType == ListingType.All && (
<a
href={`/feeds/all.xml?sort=${SortType[sort]}`}
target="_blank"
title="RSS"
rel="noopener"
className="mr-2"
>
<Icon name="rss" />
</a>
)}
{UserService.Instance.user && listingType == ListingType.Subscribed && (
<a
href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${SortType[sort]}`}
className="mr-2"
target="_blank"
title="RSS"
>
<Icon name="rss" className="icon text-muted small" />
</a>
)}
{isMobile && (
<button
className="btn text-right mr-2"
onClick={() => setFiltersOpen(!filtersOpen)}
style={{ padding: 0 }}
>
<svg className="icon text-muted">
<use xlinkHref="#icon-settings">#</use>
</svg>
</button>
)}
{/* {isMobile && (
<Block marginLeft="auto">
<Link to="/create_post">
<Tooltip
label={i18n.t('create_post')}
aria-label={i18n.t('create_post')}
>
<Button
variant="primary"
title={i18n.t('create_post')}
css={{ paddingBottom: '10px' }}
>
<Icon name="plus" className="icon custom-icon" />
</Button>
</Tooltip>
</Link>
</Block>
)} */}
{(!isMobile || (isMobile && filtersOpen)) && (
<Block display="flex" mt={isMobile ? 3 : 0}>
<Block mr={2}>
<ListingTypeSelect
type_={listingType}
onChange={handleListingTypeChange}
/>
</Block>
<Block mr={2}>
<DataTypeSelect
type_={dataType}
onChange={handleDataTypeChange}
/>
</Block>
</Block>
)}
</Block>
</div>
);
};
const paginator = () => {
// Post listings use their own paginator
if (dataType === DataType.Post) {
return null;
}
return (
<div className="my-2">
{page > 1 && (
<Button mr={1} variant="muted" onClick={prevPage}>
{i18n.t('prev')}
</Button>
)}
{commentsList.size > 0 && (
<Button variant="muted" onClick={nextPage}>
{i18n.t('next')}
</Button>
)}
</div>
);
};
const posts = () => {
return (
<div className="main-content-wrapper">
{selects()}
{(dataType == DataType.Post && (!postList || !featuredPostList)) ||
(dataType == DataType.Comment && !commentsList) ? (
<SpinnerSection />
) : (
<div>
{listings()}
{paginator()}
</div>
)}
</div>
);
};
return (
<>
<div className="container" style={{ maxWidth: '100%' }}>
<Helmet>
<title>{siteName}</title>
</Helmet>
<Header
title="Home"
subtitle={tagline}
subtitleHtml
details={{
[i18n.t('number_online_label')]: siteRes.online,
[i18n.t('members')]: siteRes.site.number_of_users,
}}
>
<Flex>
<Separator />
<Tooltip label={i18n.t('create_post')}>
<ResponsiveButton
as={StyledLink}
to="/create_post"
variant="primary"
mobileText={<Icon name="plus" />}
>
{i18n.t('create_post')}
</ResponsiveButton>
</Tooltip>
</Flex>
</Header>
<div className="row">
<main role="main" className="col-12 col-md-8">
{posts()}
</main>
<aside className="col-12 col-md-4 sidebar">{my_sidebar()}</aside>
</div>
</div>
</>
);
}
// @ts-ignore
export default withTranslation()(withRouter(Main));