Refactor PostsList to functional component and update post loading logic

This commit is contained in:
srgooglo 2025-06-30 20:37:34 +02:00
parent 79fbb74f32
commit f79db03a1b
5 changed files with 276 additions and 489 deletions

View File

@ -7,9 +7,8 @@ export default React.forwardRef((props, ref) => {
const { const {
className, className,
children, children,
hasMore, hasMore = false,
loadingComponent, loadingComponent,
noResultComponent,
contentProps = {}, contentProps = {},
} = props } = props
@ -18,7 +17,7 @@ export default React.forwardRef((props, ref) => {
const insideViewportCb = (entries) => { const insideViewportCb = (entries) => {
const { fetching, onBottom } = props const { fetching, onBottom } = props
entries.forEach(element => { entries.forEach((element) => {
if (element.intersectionRatio > 0 && !fetching) { if (element.intersectionRatio > 0 && !fetching) {
onBottom() onBottom()
} }
@ -41,11 +40,8 @@ export default React.forwardRef((props, ref) => {
} }
}, []) }, [])
return <div return (
ref={ref} <div ref={ref} className={classnames(className)} {...contentProps}>
className={classnames(className)}
{...contentProps}
>
{children} {children}
<div style={{ clear: "both" }} /> <div style={{ clear: "both" }} />
@ -58,4 +54,5 @@ export default React.forwardRef((props, ref) => {
{loadingComponent && React.createElement(loadingComponent)} {loadingComponent && React.createElement(loadingComponent)}
</div> </div>
</div> </div>
)
}) })

View File

@ -1,5 +1,6 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import lodash from "lodash"
import { AnimatePresence } from "motion/react" import { AnimatePresence } from "motion/react"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
@ -23,353 +24,8 @@ const LoadingComponent = () => {
) )
} }
const NoResultComponent = () => { const PostActions = {
return ( onClickLike: async (data) => {
<antd.Empty
description="No more post here"
style={{
width: "100%",
}}
/>
)
}
const typeToComponent = {
post: (args) => <PostCard {...args} />,
//"playlist": (args) => <PlaylistTimelineEntry {...args} />,
}
const Entry = React.memo((props) => {
const { data } = props
return React.createElement(
typeToComponent[data.type ?? "post"] ?? PostCard,
{
key: data._id,
data: data,
disableReplyTag: props.disableReplyTag,
disableHasReplies: props.disableHasReplies,
events: {
onClickLike: props.onLikePost,
onClickSave: props.onSavePost,
onClickDelete: props.onDeletePost,
onClickEdit: props.onEditPost,
onClickReply: props.onReplyPost,
onDoubleClick: props.onDoubleClick,
},
},
)
})
const PostList = React.forwardRef((props, ref) => {
return (
<LoadMore
ref={ref}
className="post-list"
loadingComponent={LoadingComponent}
noResultComponent={NoResultComponent}
hasMore={props.hasMore}
fetching={props.loading}
onBottom={props.onLoadMore}
>
{!props.realtimeUpdates && !app.isMobile && (
<div className="resume_btn_wrapper">
<antd.Button
type="primary"
shape="round"
onClick={props.onResumeRealtimeUpdates}
loading={props.resumingLoading}
icon={<Icons.FiSyncOutlined />}
>
Resume
</antd.Button>
</div>
)}
<AnimatePresence>
{props.list.map((data) => {
return <Entry key={data._id} data={data} {...props} />
})}
</AnimatePresence>
</LoadMore>
)
})
export class PostsListsComponent extends React.Component {
state = {
openPost: null,
loading: false,
resumingLoading: false,
initialLoading: true,
scrollingToTop: false,
topVisible: true,
realtimeUpdates: true,
hasMore: true,
list: this.props.list ?? [],
pageCount: 0,
}
parentRef = this.props.innerRef
listRef = React.createRef()
timelineWsEvents = {
"post:new": (data) => {
console.log("[WS] Recived a post >", data)
this.setState({
list: [data, ...this.state.list],
})
},
"post:delete": (id) => {
console.log("[WS] Received a post delete >", id)
this.setState({
list: this.state.list.filter((post) => {
return post._id !== id
}),
})
},
"post:update": (data) => {
console.log("[WS] Received a post update >", data)
this.setState({
list: this.state.list.map((post) => {
if (post._id === data._id) {
return data
}
return post
}),
})
},
}
handleLoad = async (fn, params = {}) => {
if (this.state.loading === true) {
console.warn(`Please wait to load the post before load more`)
return
}
this.setState({
loading: true,
})
let payload = {
page: this.state.pageCount,
limit: app.cores.settings.get("feed_max_fetch"),
}
if (this.props.loadFromModelProps) {
payload = {
...payload,
...this.props.loadFromModelProps,
}
}
const result = await fn(payload).catch((err) => {
console.error(err)
app.message.error("Failed to load more posts")
return null
})
if (result) {
if (result.length === 0) {
return this.setState({
hasMore: false,
})
}
if (params.replace) {
this.setState({
list: result,
pageCount: 0,
})
} else {
this.setState({
list: [...this.state.list, ...result],
pageCount: this.state.pageCount + 1,
})
}
}
this.setState({
loading: false,
})
}
addPost = (post) => {
this.setState({
list: [post, ...this.state.list],
})
}
removePost = (id) => {
this.setState({
list: this.state.list.filter((post) => {
return post._id !== id
}),
})
}
_hacks = {
addPost: this.addPost,
removePost: this.removePost,
addRandomPost: () => {
const randomId = Math.random().toString(36).substring(7)
this.addPost({
_id: randomId,
message: `Random post ${randomId}`,
user: {
_id: randomId,
username: "random user",
avatar: `https://api.dicebear.com/7.x/thumbs/svg?seed=${randomId}`,
},
})
},
listRef: this.listRef,
}
onResumeRealtimeUpdates = async () => {
console.log("Resuming realtime updates")
this.setState({
resumingLoading: true,
scrollingToTop: true,
})
this.listRef.current.scrollTo({
top: 0,
behavior: "smooth",
})
// reload posts
await this.handleLoad(this.props.loadFromModel, {
replace: true,
})
this.setState({
realtimeUpdates: true,
resumingLoading: false,
})
}
onScrollList = (e) => {
const { scrollTop } = e.target
if (this.state.scrollingToTop && scrollTop === 0) {
this.setState({
scrollingToTop: false,
})
}
if (scrollTop > 200) {
if (this.state.topVisible) {
this.setState({
topVisible: false,
})
if (typeof this.props.onTopVisibility === "function") {
this.props.onTopVisibility(false)
}
}
if (
!this.props.realtime ||
this.state.resumingLoading ||
this.state.scrollingToTop
) {
return null
}
this.setState({
realtimeUpdates: false,
})
} else {
if (!this.state.topVisible) {
this.setState({
topVisible: true,
})
if (typeof this.props.onTopVisibility === "function") {
this.props.onTopVisibility(true)
}
// if (this.props.realtime || !this.state.realtimeUpdates && !this.state.resumingLoading && scrollTop < 5) {
// this.onResumeRealtimeUpdates()
// }
}
}
}
componentDidMount = async () => {
if (typeof this.props.loadFromModel === "function") {
await this.handleLoad(this.props.loadFromModel)
}
this.setState({
initialLoading: false,
})
if (this.props.realtime) {
for (const [event, handler] of Object.entries(
this.timelineWsEvents,
)) {
app.cores.api.listenEvent(event, handler, "posts")
}
app.cores.api.joinTopic(
"posts",
this.props.customTopic ?? "realtime:feed",
)
}
if (this.listRef && this.listRef.current) {
this.listRef.current.addEventListener("scroll", this.onScrollList)
}
window._hacks = this._hacks
}
componentWillUnmount = async () => {
if (this.props.realtime) {
for (const [event, handler] of Object.entries(
this.timelineWsEvents,
)) {
app.cores.api.unlistenEvent(event, handler, "posts")
}
app.cores.api.leaveTopic(
"posts",
this.props.customTopic ?? "realtime:feed",
)
}
if (this.listRef && this.listRef.current) {
this.listRef.current.removeEventListener(
"scroll",
this.onScrollList,
)
}
window._hacks = null
}
componentDidUpdate = async (prevProps, prevState) => {
if (prevProps.list !== this.props.list) {
this.setState({
list: this.props.list,
})
}
}
onLikePost = async (data) => {
let result = await PostModel.toggleLike({ post_id: data._id }).catch( let result = await PostModel.toggleLike({ post_id: data._id }).catch(
() => { () => {
antd.message.error("Failed to like post") antd.message.error("Failed to like post")
@ -379,9 +35,8 @@ export class PostsListsComponent extends React.Component {
) )
return result return result
} },
onClickSave: async (data) => {
onSavePost = async (data) => {
let result = await PostModel.toggleSave({ post_id: data._id }).catch( let result = await PostModel.toggleSave({ post_id: data._id }).catch(
() => { () => {
antd.message.error("Failed to save post") antd.message.error("Failed to save post")
@ -391,25 +46,8 @@ export class PostsListsComponent extends React.Component {
) )
return result return result
} },
onClickDelete: async (data) => {
onEditPost = (data) => {
app.controls.openPostCreator({
edit_post: data._id,
})
}
onReplyPost = (data) => {
app.controls.openPostCreator({
reply_to: data._id,
})
}
onDoubleClickPost = (data) => {
app.navigation.goToPost(data._id)
}
onDeletePost = async (data) => {
antd.Modal.confirm({ antd.Modal.confirm({
title: "Are you sure you want to delete this post?", title: "Are you sure you want to delete this post?",
content: "This action is irreversible", content: "This action is irreversible",
@ -422,74 +60,218 @@ export class PostsListsComponent extends React.Component {
}) })
}, },
}) })
} },
onClickEdit: async (data) => {
app.controls.openPostCreator({
edit_post: data._id,
})
},
onClickReply: async (data) => {
app.controls.openPostCreator({
reply_to: data._id,
})
},
onDoubleClick: async (data) => {
app.navigation.goToPost(data._id)
},
}
ontoggleOpen = (to, data) => { const Entry = (props) => {
if (typeof this.props.onOpenPost === "function") { const { data } = props
this.props.onOpenPost(to, data)
}
}
onLoadMore = async () => {
if (typeof this.props.onLoadMore === "function") {
return this.handleLoad(this.props.onLoadMore)
} else if (this.props.loadFromModel) {
return this.handleLoad(this.props.loadFromModel)
}
}
render() {
if (this.state.initialLoading) {
return <antd.Skeleton active />
}
if (this.state.list.length === 0) {
if (typeof this.props.emptyListRender === "function") {
return React.createElement(this.props.emptyListRender)
}
return ( return (
<div className="no_more_posts"> <PostCard
<antd.Empty /> key={data._id}
<h1>Whoa, nothing on here...</h1> data={data}
</div> disableReplyTag={props.disableReplyTag}
disableHasReplies={props.disableHasReplies}
events={PostActions}
/>
)
}
const PostList = React.forwardRef((props, ref) => {
return (
<LoadMore
ref={ref}
className="post-list"
loadingComponent={LoadingComponent}
hasMore={props.hasMore}
onBottom={props.onLoadMore}
>
<AnimatePresence>
{props.list.map((data) => {
return <Entry key={data._id} data={data} {...props} />
})}
</AnimatePresence>
</LoadMore>
)
})
const PostsListsComponent = (props) => {
const [list, setList] = React.useState([])
const [hasMore, setHasMore] = React.useState(true)
// Refs
const firstLoad = React.useRef(true)
const loading = React.useRef(false)
const page = React.useRef(0)
const listRef = React.useRef(null)
const loadModelPropsRef = React.useRef({})
const timelineWsEvents = React.useRef({
"post:new": (data) => {
console.debug("post:new", data)
setList((prev) => {
return [data, ...prev]
})
},
"post:delete": (data) => {
console.debug("post:delete", data)
setList((prev) => {
return prev.filter((item) => {
return item._id !== data._id
})
})
},
"post:update": (data) => {
console.debug("post:update", data)
setList((prev) => {
return prev.map((item) => {
if (item._id === data._id) {
return data
}
return item
})
})
},
})
// Logic
async function handleLoad(fn, params = {}) {
if (loading.current === true) {
console.warn(`Please wait to load the post before load more`)
return
}
loading.current = true
let payload = {
page: page.current,
limit: app.cores.settings.get("feed_max_fetch"),
}
if (loadModelPropsRef.current) {
payload = {
...payload,
...loadModelPropsRef.current,
}
}
const result = await fn(payload).catch((err) => {
console.error(err)
app.message.error("Failed to load more posts")
return null
})
loading.current = false
firstLoad.current = false
if (result) {
setHasMore(result.has_more)
if (result.items?.length > 0) {
if (params.replace) {
setList(result.items)
page.current = 0
} else {
setList((prev) => {
return [...prev, ...result.items]
})
page.current = page.current + 1
}
}
}
}
const onLoadMore = React.useCallback(() => {
if (typeof props.onLoadMore === "function") {
return handleLoad(props.onLoadMore)
} else if (props.loadFromModel) {
return handleLoad(props.loadFromModel)
}
}, [props])
React.useEffect(() => {
if (
!lodash.isEqual(props.loadFromModelProps, loadModelPropsRef.current)
) {
loadModelPropsRef.current = props.loadFromModelProps
page.current = 0
loading.current = false
setHasMore(true)
setList([])
handleLoad(props.loadFromModel)
}
}, [
props.loadFromModel,
props.loadFromModelProps,
firstLoad.current === false,
])
React.useEffect(() => {
if (props.loadFromModelProps) {
loadModelPropsRef.current = props.loadFromModelProps
}
if (typeof props.loadFromModel === "function") {
handleLoad(props.loadFromModel)
}
if (props.realtime) {
for (const [event, handler] of Object.entries(
timelineWsEvents.current,
)) {
app.cores.api.listenEvent(event, handler, "posts")
}
app.cores.api.joinTopic(
"posts",
props.customTopic ?? "realtime:feed",
) )
} }
const PostListProps = { return () => {
list: this.state.list, if (props.realtime) {
for (const [event, handler] of Object.entries(
disableReplyTag: this.props.disableReplyTag, timelineWsEvents.current,
disableHasReplies: this.props.disableHasReplies, )) {
app.cores.api.unlistenEvent(event, handler, "posts")
onLikePost: this.onLikePost,
onSavePost: this.onSavePost,
onDeletePost: this.onDeletePost,
onEditPost: this.onEditPost,
onReplyPost: this.onReplyPost,
onDoubleClick: this.onDoubleClickPost,
onLoadMore: this.onLoadMore,
hasMore: this.state.hasMore,
loading: this.state.loading,
realtimeUpdates: this.state.realtimeUpdates,
resumingLoading: this.state.resumingLoading,
onResumeRealtimeUpdates: this.onResumeRealtimeUpdates,
} }
if (app.isMobile) { app.cores.api.leaveTopic(
return <PostList ref={this.listRef} {...PostListProps} /> "posts",
props.customTopic ?? "realtime:feed",
)
} }
}
}, [])
return ( return (
<div className="post-list_wrapper"> <div className="post-list_wrapper">
<PostList ref={this.listRef} {...PostListProps} /> <PostList
ref={listRef}
list={list}
hasMore={hasMore}
onLoadMore={onLoadMore}
/>
</div> </div>
) )
}
} }
export default React.forwardRef((props, ref) => ( export default PostsListsComponent
<PostsListsComponent innerRef={ref} {...props} />
))

View File

@ -1,6 +1,8 @@
import React from "react" import React from "react"
import { Result } from "antd" import { Result } from "antd"
import { useNavigation } from "react-router"
import PostsList from "@components/PostsList" import PostsList from "@components/PostsList"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
@ -14,18 +16,17 @@ const emptyListRender = () => {
) )
} }
export default class UserPosts extends React.Component { const UserPosts = (props) => {
render() {
console.log(this.props.state)
return ( return (
<PostsList <PostsList
onTopVisibility={this.props.onTopVisibility} onTopVisibility={props.onTopVisibility}
emptyListRender={emptyListRender} emptyListRender={emptyListRender}
loadFromModel={PostModel.getUserPosts} loadFromModel={PostModel.getUserPosts}
loadFromModelProps={{ loadFromModelProps={{
user_id: this.props.state.user._id, user_id: props.state.user._id,
}} }}
/> />
) )
}
} }
export default UserPosts

View File

@ -19,6 +19,7 @@ export default async (payload = {}) => {
} }
let posts = [] let posts = []
let total_posts = 0
if (post_id) { if (post_id) {
try { try {
@ -33,6 +34,8 @@ export default async (payload = {}) => {
.sort(sort) .sort(sort)
.limit(limit) .limit(limit)
.skip(limit * page) .skip(limit * page)
total_posts = await Post.countDocuments({ ...query })
} }
// fullfill data // fullfill data
@ -50,5 +53,9 @@ export default async (payload = {}) => {
return posts[0] return posts[0]
} }
return posts return {
items: posts,
total_items: total_posts,
has_more: total_posts > limit * page + 1,
}
} }

View File

@ -42,11 +42,9 @@ export default async (payload = {}) => {
// broadcast post to all users // broadcast post to all users
if (post.visibility === "public") { if (post.visibility === "public") {
global.websockets.senders.toTopic( global.websockets.senders.toTopic("realtime:feed", "post:delete", {
"realtime:feed", _id: post_id,
"post:delete", })
post_id,
)
} }
if (post.visibility === "private") { if (post.visibility === "private") {
@ -55,7 +53,9 @@ export default async (payload = {}) => {
) )
for (const userSocket of userSockets) { for (const userSocket of userSockets) {
userSocket.emit(`post:delete`, post_id) userSocket.emit(`post:delete`, {
_id: post_id,
})
} }
} }