mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-10 02:54:15 +00:00
411 lines
12 KiB
JavaScript
411 lines
12 KiB
JavaScript
import React from "react"
|
|
import * as antd from "antd"
|
|
import { Swiper } from "antd-mobile"
|
|
import { Icons } from "components/Icons"
|
|
import { Image, LikeButton } from "components"
|
|
import moment from "moment"
|
|
import classnames from "classnames"
|
|
import loadable from "@loadable/component"
|
|
|
|
import { processString } from "utils"
|
|
|
|
import CSSMotion from "rc-animate/lib/CSSMotion"
|
|
import useLayoutEffect from "rc-util/lib/hooks/useLayoutEffect"
|
|
|
|
import "./index.less"
|
|
|
|
const mediaTypes = {
|
|
"jpg": "image",
|
|
"jpeg": "image",
|
|
"png": "image",
|
|
"gif": "image",
|
|
"mp4": "video",
|
|
"webm": "video",
|
|
"ogv": "video",
|
|
"mov": "video",
|
|
"avi": "video",
|
|
"mkv": "video",
|
|
"ogg": "audio",
|
|
"mp3": "audio",
|
|
"wav": "audio",
|
|
"flac": "audio",
|
|
"aac": "audio",
|
|
"m4a": "audio",
|
|
}
|
|
|
|
const ContentFailed = () => {
|
|
return <div className="contentFailed">
|
|
<Icons.MdCloudOff />
|
|
</div>
|
|
}
|
|
|
|
const getCurrentHeight = (node) => ({ height: node.offsetHeight })
|
|
|
|
const getMaxHeight = (node) => {
|
|
return { height: node.scrollHeight }
|
|
}
|
|
|
|
const getCollapsedHeight = () => ({ height: 0, opacity: 0 })
|
|
|
|
function PostHeader(props) {
|
|
const [timeAgo, setTimeAgo] = React.useState(0)
|
|
|
|
const goToProfile = () => {
|
|
window.app.goToAccount(props.postData.user?.username)
|
|
}
|
|
|
|
const updateTimeAgo = () => {
|
|
setTimeAgo(moment(props.postData.created_at ?? "").fromNow())
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
updateTimeAgo()
|
|
|
|
const interval = setInterval(() => {
|
|
updateTimeAgo()
|
|
}, 10000)
|
|
|
|
return () => {
|
|
clearInterval(interval)
|
|
}
|
|
}, [props.postData.created_at])
|
|
|
|
return <div className="postHeader">
|
|
<div className="userInfo">
|
|
<div className="avatar">
|
|
<Image
|
|
alt="Avatar"
|
|
src={props.postData.user?.avatar}
|
|
/>
|
|
</div>
|
|
<div className="info">
|
|
<div>
|
|
<h1 onClick={goToProfile}>
|
|
{props.postData.user?.fullName ?? `@${props.postData.user?.username}`}
|
|
{props.postData.user?.verified && <Icons.verifiedBadge />}
|
|
</h1>
|
|
</div>
|
|
|
|
<div>
|
|
{timeAgo}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="postStatistics">
|
|
<div className="item">
|
|
<Icons.Heart className={classnames("icon", { ["filled"]: props.isLiked })} />
|
|
<div className="value">
|
|
{props.likes}
|
|
</div>
|
|
</div>
|
|
<div className="item">
|
|
<Icons.MessageSquare />
|
|
<div className="value">
|
|
{props.comments}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
const PostContent = React.memo((props) => {
|
|
let { message, additions } = props.data
|
|
|
|
let carruselRef = React.useRef(null)
|
|
|
|
// first filter if is an string
|
|
additions = additions.filter(file => typeof file === "string")
|
|
|
|
// then filter if is an uri
|
|
additions = additions.filter(file => /^(http|https):\/\//.test(file))
|
|
|
|
additions = additions.map((uri, index) => {
|
|
const MediaRender = loadable(async () => {
|
|
let extension = null
|
|
|
|
try {
|
|
// get media type by parsing the uri
|
|
const mediaTypeExt = /\.([a-zA-Z0-9]+)$/.exec(uri)
|
|
|
|
if (mediaTypeExt) {
|
|
extension = mediaTypeExt[1]
|
|
} else {
|
|
// try to get media by creating requesting the uri
|
|
const response = await fetch(uri, {
|
|
method: "HEAD",
|
|
})
|
|
|
|
extension = response.headers.get("content-type").split("/")[1]
|
|
}
|
|
|
|
extension = extension.toLowerCase()
|
|
|
|
const mediaType = mediaTypes[extension]
|
|
const mimeType = `${mediaType}/${extension}`
|
|
|
|
if (!mediaType) {
|
|
return () => <ContentFailed />
|
|
}
|
|
|
|
switch (mediaType.split("/")[0]) {
|
|
case "image": {
|
|
return () => <img src={uri} />
|
|
}
|
|
case "video": {
|
|
return () => <video controls>
|
|
<source src={uri} type={mimeType} />
|
|
</video>
|
|
}
|
|
case "audio": {
|
|
return () => <audio controls>
|
|
<source src={uri} type={mimeType} />
|
|
</audio>
|
|
}
|
|
|
|
default: {
|
|
return () => <h4>
|
|
Unsupported media type [{mediaType}/{mediaTypeExt}]
|
|
</h4>
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
return () => <ContentFailed />
|
|
}
|
|
})
|
|
|
|
return <div key={index} className="addition">
|
|
<React.Suspense fallback={<div>Loading</div>} >
|
|
<MediaRender />
|
|
</React.Suspense>
|
|
</div>
|
|
})
|
|
|
|
// parse message
|
|
const regexs = [
|
|
{
|
|
regex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi,
|
|
fn: (key, result) => {
|
|
return <a key={key} href={result[1]} target="_blank" rel="noopener noreferrer">{result[1]}</a>
|
|
}
|
|
},
|
|
{
|
|
regex: /(@[a-zA-Z0-9_]+)/gi,
|
|
fn: (key, result) => {
|
|
return <a key={key} onClick={() => window.app.setLocation(`/@${result[1].substr(1)}`)}>{result[1]}</a>
|
|
}
|
|
},
|
|
]
|
|
|
|
message = processString(regexs)(message)
|
|
|
|
return <div className="content">
|
|
<div className="message">
|
|
{message}
|
|
</div>
|
|
|
|
{additions.length > 0 &&
|
|
<div className="additions">
|
|
<antd.Carousel
|
|
ref={carruselRef}
|
|
arrows={true}
|
|
nextArrow={<Icons.ChevronRight />}
|
|
prevArrow={<Icons.ChevronLeft />}
|
|
autoplay={props.autoCarrousel}
|
|
>
|
|
{additions}
|
|
</antd.Carousel>
|
|
</div>
|
|
}
|
|
</div>
|
|
})
|
|
|
|
const PostActions = (props) => {
|
|
const handleSelfMenuAction = async (event) => {
|
|
const fn = props.actions[event.key]
|
|
|
|
if (typeof fn === "function") {
|
|
await fn()
|
|
}
|
|
}
|
|
|
|
return <div className="actions">
|
|
<div className="action" id="likes">
|
|
<div className="icon">
|
|
<LikeButton defaultLiked={props.defaultLiked} onClick={props.onClickLike} />
|
|
</div>
|
|
</div>
|
|
<div className="action" id="comments" onClick={props.onClickComments}>
|
|
<div className="icon">
|
|
<Icons.MessageSquare className="icon" />
|
|
</div>
|
|
</div>
|
|
<div className="action" id="save" onClick={props.onClickSave}>
|
|
<div className="icon">
|
|
<Icons.Bookmark />
|
|
</div>
|
|
</div>
|
|
{props.isSelf && <div className="action" id="selfMenu" onClick={props.onClickSelfMenu}>
|
|
<antd.Dropdown
|
|
overlay={<antd.Menu
|
|
onClick={handleSelfMenuAction}
|
|
>
|
|
<antd.Menu.Item icon={<Icons.Edit />} key="edit">
|
|
Edit
|
|
</antd.Menu.Item>
|
|
<antd.Menu.Divider />
|
|
<antd.Menu.Item icon={<Icons.Trash />} key="delete">
|
|
Delete
|
|
</antd.Menu.Item>
|
|
</antd.Menu>}
|
|
trigger={["click"]}
|
|
>
|
|
<div className="icon">
|
|
<Icons.MoreVertical />
|
|
</div>
|
|
</antd.Dropdown>
|
|
</div>}
|
|
</div>
|
|
}
|
|
|
|
export const PostCard = React.memo(({
|
|
selfId,
|
|
expansibleActions = window.app.settings.get("postCard_expansible_actions"),
|
|
autoCarrousel = window.app.settings.get("postCard_carrusel_auto"),
|
|
data = {},
|
|
events = {}
|
|
}) => {
|
|
const [loading, setLoading] = React.useState(true)
|
|
const [likes, setLikes] = React.useState(data.likes ?? [])
|
|
const [comments, setComments] = React.useState(data.comments ?? [])
|
|
const [hasLiked, setHasLiked] = React.useState(false)
|
|
|
|
const onClickDelete = async () => {
|
|
if (typeof events.onClickDelete !== "function") {
|
|
console.warn("onClickDelete event is not a function")
|
|
return
|
|
}
|
|
|
|
return await events.onClickDelete(data)
|
|
}
|
|
|
|
const onClickLike = async () => {
|
|
if (typeof events.onClickLike !== "function") {
|
|
console.warn("onClickLike event is not a function")
|
|
return
|
|
}
|
|
|
|
return await events.onClickLike(data)
|
|
}
|
|
|
|
const onDataUpdate = (data) => {
|
|
setLikes(data.likes)
|
|
setComments(data.comments)
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
// first listen to post changes
|
|
window.app.ws.listen(`post.dataUpdate.${data._id}`, onDataUpdate)
|
|
|
|
// proccess post info
|
|
// {...}
|
|
|
|
// then load
|
|
setLoading(false)
|
|
|
|
return () => {
|
|
// remove the listener
|
|
window.app.ws.unlisten(`post.dataUpdate.${data._id}`, onDataUpdate)
|
|
}
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
// check if the post has liked by you
|
|
const hasLiked = likes.includes(selfId)
|
|
//console.log(`[${data._id}] CHECKING LIKE OF USER ${selfId} > ${hasLiked}`)
|
|
|
|
setHasLiked(hasLiked)
|
|
})
|
|
|
|
if (loading) {
|
|
return <antd.Skeleton active />
|
|
}
|
|
|
|
return <div
|
|
key={data.key ?? data._id}
|
|
id={data._id}
|
|
className={classnames(
|
|
"postCard",
|
|
{ ["liked"]: hasLiked },
|
|
{ ["noHide"]: !expansibleActions }
|
|
)}
|
|
>
|
|
<div className="wrapper">
|
|
<PostHeader
|
|
postData={data}
|
|
isLiked={hasLiked}
|
|
likes={likes.length}
|
|
comments={comments.length}
|
|
/>
|
|
<PostContent
|
|
data={data}
|
|
autoCarrousel={autoCarrousel}
|
|
/>
|
|
</div>
|
|
<div className="actionsIndicatorWrapper">
|
|
<div className="actionsIndicator">
|
|
<Icons.MoreHorizontal />
|
|
</div>
|
|
</div>
|
|
<div className="actionsWrapper">
|
|
<PostActions
|
|
isSelf={selfId === data.user_id}
|
|
defaultLiked={hasLiked}
|
|
onClickLike={onClickLike}
|
|
actions={{
|
|
delete: onClickDelete,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
})
|
|
|
|
export const PostCardAnimated = (props, ref,) => {
|
|
const motionRef = React.useRef(false)
|
|
|
|
useLayoutEffect(() => {
|
|
return () => {
|
|
if (motionRef.current) {
|
|
props.onAppear()
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
return <CSSMotion
|
|
ref={ref}
|
|
motionName="motion"
|
|
motionAppear={props.motionAppear}
|
|
onAppearStart={getCollapsedHeight}
|
|
onAppearActive={node => {
|
|
motionRef.current = true
|
|
return getMaxHeight(node)
|
|
}}
|
|
onAppearEnd={props.onAppear}
|
|
onLeaveStart={getCurrentHeight}
|
|
onLeaveActive={getCollapsedHeight}
|
|
onLeaveEnd={() => {
|
|
props.onLeave(id)
|
|
}}
|
|
>
|
|
{(_args, passedMotionRef) => {
|
|
return <PostCard
|
|
ref={passedMotionRef}
|
|
{...props}
|
|
/>
|
|
}}
|
|
</CSSMotion>
|
|
}
|
|
|
|
export const ForwardedPostCardAnimated = React.forwardRef(PostCardAnimated)
|
|
|
|
export default PostCard |