web/front/ArticleList.tsx
import React from 'react'
import Link from 'next/link'
import Router, { useRouter } from 'next/router'
import lodash from 'lodash'
import CustomLink from 'front/CustomLink'
import LikeArticleButton from 'front/LikeArticleButton'
import Pagination, { PaginationPropsUrlFunc } from 'front/Pagination'
import ShowBody from 'front/ShowBody'
import UserLinkWithImage from 'front/UserLinkWithImage'
import {
AnnounceIcon,
ArticleCreatedUpdatedPills,
ArticleIcon,
DiscussionIcon,
LikeIcon,
TimeIcon,
TopicIcon,
UnlistedIcon,
UserIcon,
getShortFragFromLongForPath,
shortFragGoTo,
} from 'front'
import {
articleLimit,
} from 'front/config'
import routes from 'front/routes'
import { TRI_ALL, TRI_FALSE } from 'front/js'
import { ArticleType } from 'front/types/ArticleType'
import { ItemBody } from 'front/ItemBody'
import { IssueType } from 'front/types/IssueType'
import { TopicType } from 'front/types/TopicType'
import { UserType } from 'front/types/UserType'
import { encodeGetParams, QUERY_FALSE_VAL, QUERY_TRUE_VAL } from 'ourbigbook/web_api'
import {
AT_MENTION_CHAR,
UNICODE_SEARCH_CHAR,
formatDate,
} from 'ourbigbook'
import {
USER_FINISHED_TYPING_MS
} from 'ourbigbook/runtime_common'
function getKey(
itemType: string,
article,
issueArticle=undefined
) {
if (itemType === 'discussion') {
let curIssueArticle
if (issueArticle) {
curIssueArticle = issueArticle
} else {
curIssueArticle = article.article
}
return `${article.number}/${curIssueArticle.slug}`
} else {
return itemType === 'article' ? article.slug :
itemType === 'like' ? `${article.likedBy.username}/${article.slug}` :
article.topicId
}
}
export type ArticleListProps = {
// TODO not ideal. Only Articles are really possible. This is to appease ArticleList.
articles: (ArticleType & IssueType & TopicType)[];
articlesCount: number;
followed?: boolean;
handleShortFragmentSkipOnce?: React.MutableRefObject<boolean>;
hasUnlisted?: boolean;
issueArticle?: ArticleType;
itemType?: 'article' | 'discussion' | 'like' | 'topic';
list?: boolean;
loggedInUser?: UserType,
page?: number;
paginationUrlFunc?: PaginationPropsUrlFunc;
showAuthor: boolean;
showBody?: boolean,
showControls?: boolean;
showFullBody?: boolean,
what?: string;
}
const ArticleList = ({
articles,
articlesCount,
followed=false,
itemType='article',
handleShortFragmentSkipOnce,
hasUnlisted,
issueArticle,
list,
loggedInUser,
page,
paginationUrlFunc,
showAuthor,
showBody=true,
showControls=true,
showFullBody=false,
what='all',
}: ArticleListProps) => {
const router = useRouter();
const { pathname, query } = router
const { uid } = query;
const itemTypeHasShowBody = itemType === 'article'
// This almost works to have discussion body previews. The only missing problem is that
// "render" contains full body including h1 which we don't want. We'd need to convert
// render to be body without h1 like in Article for it to work well.
// || itemType === 'discussion'
let showBodyInit
if (itemTypeHasShowBody) {
if (query.body === QUERY_TRUE_VAL) {
showBodyInit = true
} else if (query.body === QUERY_FALSE_VAL) {
showBodyInit = false
} else {
showBodyInit = showBody
}
} else {
showBodyInit = false
}
const [showBodyState, setShowBodyState] = React.useState(showBodyInit)
const [search, setSearch] = React.useState(query.search || '')
const resetShowBodyGetString = encodeGetParams(lodash.omit(query, 'body'))
React.useEffect(() => {
// Reset on tab change.
setShowBodyState(showBodyInit)
}, [pathname, resetShowBodyGetString, showBodyInit])
let isIssue
let hasSearch
switch (itemType) {
case 'article':
switch (what) {
case 'all':
case 'user-articles':
if (followed) {
hasSearch = false
} else {
hasSearch = true
}
break
default:
hasSearch = false
break
}
break
case 'discussion':
isIssue = true
hasSearch = false
break
case 'topic':
showAuthor = false
hasSearch = true
break
}
let pagination
let emptyMessage
if (articles.length === 0) {
let voice
if (loggedInUser?.username === uid) {
voice = "You have not"
} else {
voice = "This user has not"
}
switch (what) {
case 'likes':
emptyMessage = `${voice} liked any articles yet.`
break
case 'user-articles':
emptyMessage = `${voice} published any articles yet.`
break
case 'all':
if (followed) {
emptyMessage = `Follow some users to see their posts here.`
} else {
emptyMessage = <>
There are no matching {isIssue ? 'discussions' : 'articles'}{isIssue ? ' on this article' : ''}.
{((!isIssue) || (isIssue && issueArticle)) &&
<>
{' '}
Why don't you
{' '}
<CustomLink
href={isIssue ? routes.issueNew(issueArticle.slug) : routes.articleNew()}
>
create a new one
</CustomLink>?
</>
}
</>
}
break
default:
emptyMessage = `There are currently no matching ${isIssue ? 'discussions' : itemType === 'like' ? 'likes' : 'articles'}.`
}
} else {
if (showControls) {
pagination = <Pagination {...{
currentPage: page,
what: isIssue ? 'discussions' : itemType === 'like' ? 'likes' : itemType === 'topic' ? 'topics' : 'articles',
itemsCount: articlesCount,
itemsPerPage: articleLimit,
urlFunc: paginationUrlFunc,
}} />
} else {
pagination = <></>
}
}
const aElemToMetaMap = React.useRef(new Set())
const handleSearchI = React.useRef(0)
let handleSearchIClosure = handleSearchI.current
const handleSearch = (e) => {
const search = e.target.value
setSearch(search)
const url = new URL(window.location.href)
const query = Object.fromEntries(url.searchParams)
if (search) {
query.search = search
} else {
delete query.search
}
delete query.page
Router.push(
`${url.pathname}${encodeGetParams(query)}`,
undefined,
{ shallow: true }
)
handleSearchI.current++
setTimeout(() => {
if (handleSearchIClosure + 1 === handleSearchI.current) {
Router.push(
`${url.pathname}${encodeGetParams(query)}`,
undefined,
)
}
}, USER_FINISHED_TYPING_MS)
}
return (
<div className={`article-list`}>
<div className="list-nav-container">
<nav className="content-not-ourbigbook controls">
{(hasSearch) &&
<input
className="search"
onChange={handleSearch}
placeholder={`${UNICODE_SEARCH_CHAR} Search`}
//ref={displayNameInputElem}
type="text"
value={search}
/>
}
{(itemTypeHasShowBody && articles.length !== 0) ? <ShowBody {...{ setShowBodyState, showBodyState, showBody }}/> : <></>}
</nav>
{ (articles.length === 0)
? <div className="list-container content-not-ourbigbook">
{emptyMessage}
</div>
: <>
<div className="content-not-ourbigbook">
<div className={`list-container${showBodyState ? ' show-body' : '' }`}>
{showBodyState
? articles?.map((article, i) => {
let curIssueArticle
if (issueArticle) {
curIssueArticle = issueArticle
} else {
curIssueArticle = article.article
}
return (
<div
key={getKey(itemType, article) }
className="item"
>
<div className={`item-header content-not-ourbigbook${article.render ? '' : ' empty-body'}`}>
<LikeArticleButton {...{
article,
isIssue,
issueArticle,
loggedInUser,
showText: false,
}} />
{' '}
<CustomLink
href={itemType === 'discussion' ? routes.issue(curIssueArticle.slug, article.number) :
itemType === 'article' ? routes.article(article.slug) :
routes.topic(article.topicId, { sort: 'score' })
}
>
<span
className="ourbigbook-title title"
dangerouslySetInnerHTML={{
__html: itemType === 'article' ? article.titleRenderWithScope : article.titleRender
}}
/>
</CustomLink>
{' '}
{showAuthor &&
<>
by
{' '}
<UserLinkWithImage showUsername={false} user={article.author} />
{' '}
</>
}
<ArticleCreatedUpdatedPills article={article} />
{article.announcedAt && <>
{' '}
<span className="pill" title="Announced">
<AnnounceIcon />
{' '}
{formatDate(article.updatedAt)}
</span>
</>}
</div>
{article.render &&
<ItemBody {...{ showFullBody }}>
<div
className=" ourbigbook"
dangerouslySetInnerHTML={{ __html: article.render }}
ref={(elem) => {
if (elem) {
const as = elem.getElementsByTagName('a')
for (let i = 0; i < as.length; i++) {
const a = as[i]
if (!aElemToMetaMap.current.has(a)) {
const href = a.href
aElemToMetaMap.current.add(a)
let url
let urlOrigin
try {
url = new URL(href, document.baseURI)
urlOrigin = new URL(document.baseURI)
} catch(e) {}
if (
url &&
urlOrigin &&
// Don't do processing for external links.
url.origin === urlOrigin.origin
) {
let frag
let longFrag
let goToTargetInPage = false
let targetElem
const urlFrag = routes.decodeUrlPath(url.hash.slice(1))
const urlPath = routes.decodeUrlPath(url.pathname.slice(1))
if (url.hash) {
frag = urlFrag
targetElem = document.getElementById(frag)
longFrag = AT_MENTION_CHAR + frag
if (targetElem) {
goToTargetInPage = true
a.href = '#' + routes.encodeUrlFragment(longFrag)
}
}
if (!goToTargetInPage) {
const frag = getShortFragFromLongForPath(urlFrag, urlPath)
a.href = url.pathname + (frag ? ('#' + routes.encodeUrlFragment(frag)) : '')
}
a.addEventListener('click', e => {
if (
// Don't capture Ctrl + Click, as that means user wants link to open on a separate page.
// https://stackoverflow.com/questions/16190455/how-to-detect-controlclick-in-javascript-from-an-onclick-div-attribute
!e.ctrlKey
) {
e.preventDefault()
if (
// This is needed to prevent a blowup when clicking the "parent" link of a direct child of the toplevel page of an issue.
// For artiles all works fine because each section is rendered separately and thus has a non empty href.
// But issues currently work more like static renderings, and use empty ID for the toplevel header. This is even though
// the toplevel header does have already have an ID. We should instead of doing this actually make those hrefs correct.
// But lazy now.
!href
) {
window.location.hash = ''
} else {
if (goToTargetInPage) {
shortFragGoTo(handleShortFragmentSkipOnce, frag, longFrag, targetElem)
} else {
Router.push(a.href)
}
}
}
})
}
}
}
}
}}
/>
</ItemBody>
}
<div className="item-footer content-not-ourbigbook">
<CustomLink
href={itemType === 'discussion' ? routes.issue(curIssueArticle.slug, article.number) :
itemType === 'article' ? routes.article(article.slug) :
routes.topic(article.topicId, { sort: 'score' })
}
>
<ArticleIcon /> Read the full article
</CustomLink>
</div>
</div>
)})
: <table className="list">
<thead>
<tr>
{itemType === 'like' &&
<>
<th className="shrink"><TimeIcon /> Date</th>
<th className="shrink"><UserIcon /> Liked by</th>
</>
}
{itemType === 'topic' &&
<th className="shrink right"><ArticleIcon /> Articles</th>
}
{(() => {
const score = itemType === 'topic'
? <></>
: <th className="shrink center"><LikeIcon /> Score</th>
const title = <>
{isIssue &&
<th className="shrink">
<span className="icon">#</span> id
</th>
}
<th className="expand">
{ itemType === 'discussion' ? <DiscussionIcon /> :
itemType === 'topic' ? <TopicIcon /> :
<ArticleIcon />
}
{' '}
Title
</th>
</>
if (itemType === 'like') {
return <>{title}{score}</>
} else {
return <>{score}{title}</>
}
})()
}
{itemType === 'topic' &&
<th className="shrink center"><TopicIcon /> Id</th>
}
{showAuthor &&
<th className="shrink"><UserIcon /> Author</th>
}
{(itemType !== 'topic') &&
<th className="shrink"><DiscussionIcon /> { isIssue ? 'Comments' : 'Discussions' }</th>
}
<th className="shrink"><TimeIcon /> Created</th>
<th className="shrink"><TimeIcon /> Updated</th>
</tr>
</thead>
<tbody>
{articles?.map(article => {
let curIssueArticle
if (issueArticle) {
curIssueArticle = issueArticle
} else {
curIssueArticle = article.article
}
const mainHref =
itemType === 'article' || itemType === 'like' ? routes.article(article.slug) :
itemType === 'discussion' ? routes.issue(curIssueArticle.slug, article.number) :
itemType === 'topic' ? routes.topic(article.topicId, { sort: 'score' }) :
null
return <tr
key={getKey(itemType, article, issueArticle) }
>
{itemType === 'like' &&
<>
<td className="shrink right">{formatDate(article.likedByDate)}</td>
<td className="shrink ">
<UserLinkWithImage showUsername={false} user={article.likedBy} />
</td>
</>
}
{(itemType === 'topic') &&
<td className="shrink right bold">
<CustomLink href={mainHref}>{article.articleCount}</CustomLink>
</td>
}
{(() => {
const score = <>
{(itemType === 'topic')
? <></>
: <td className="shrink center like">
<LikeArticleButton {...{
article,
isIssue,
issueArticle: curIssueArticle,
loggedInUser,
showText: false,
}} />
</td>
}
</>
const title = <>
{isIssue &&
<td className="shrink bold">
<CustomLink href={mainHref}>
{issueArticle ? '' : curIssueArticle.slug }#{article.number}
</CustomLink>
</td>
}
<td className="expand bold">
<CustomLink href={mainHref} >
<span
className="ourbigbook-title"
dangerouslySetInnerHTML={{
__html: itemType === 'article' ? article.titleRenderWithScope : article.titleRender
}}
/>
</CustomLink>
</td>
</>
if (itemType === 'like') {
return <>{title}{score}</>
} else {
return <>{score}{title}</>
}
})()}
{showAuthor &&
<td className="shrink">
<UserLinkWithImage showUsername={false} user={article.author} />
</td>
}
{itemType === 'topic' &&
<th className="shrink left">
<CustomLink href={mainHref}>{article.topicId}</CustomLink>
</th>
}
{(itemType !== 'topic') &&
<td className="shrink right bold">
<CustomLink href={isIssue ? routes.issueComments(curIssueArticle.slug, article.number) : routes.articleIssues(article.slug)}>
{isIssue ? article.commentCount : article.issueCount}
</CustomLink>
</td>
}
<td className="shrink">{formatDate(article.createdAt)}</td>
<td className="shrink">{formatDate(article.updatedAt)}</td>
</tr>
})}
</tbody>
</table>
}
</div>
</div>
<div className="controls">
{pagination}
</div>
</>
}
</div>
{((itemType === 'article' || itemType === 'discussion') && hasUnlisted === true) &&
<p className="content-not-ourbigbook">
<UnlistedIcon />{' '}
{list === true
? <>
{`There are unlisted ${itemType === 'discussion' ? 'discussions' : 'articles'},`}
{' '}
<Link
href={routes.currentPageHref(router, { listed: TRI_ALL })}
>
also show them
</Link>
{' '}or{' '}
<Link
href={routes.currentPageHref(router, { listed: TRI_FALSE })}
>
only show them
</Link>.
</>
: <>
{list === false
? `Only unlisted ${itemType === 'discussion' ? 'discussions' : 'articles'} are being shown`
: `Unlisted ${itemType === 'discussion' ? 'discussions' : 'articles'} are being shown`},
{' '}
<Link
href={routes.currentPageHref(router, { listed: undefined })}
>
{`click here to show only listed ${itemType === 'discussion' ? 'discussions' : 'articles'}`}
</Link>.
</>
}
</p>
}
</div>
)
}
export default ArticleList;