mirror of https://github.com/Qortal/q-tube
Browse Source
Follow, Like, and Dislike buttons Added to Video, Playlist, and Channel pagespull/24/head^2
38 changed files with 1839 additions and 1210 deletions
@ -0,0 +1,159 @@ |
|||||||
|
import { Box, Button, ButtonProps } from "@mui/material"; |
||||||
|
import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip"; |
||||||
|
import { MouseEvent, useEffect, useState } from "react"; |
||||||
|
import { styled } from "@mui/material/styles"; |
||||||
|
|
||||||
|
interface FollowButtonProps extends ButtonProps { |
||||||
|
followerName: string; |
||||||
|
} |
||||||
|
|
||||||
|
export type FollowData = { |
||||||
|
userName: string; |
||||||
|
followerName: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export const FollowButton = ({ followerName, ...props }: FollowButtonProps) => { |
||||||
|
const [followingList, setFollowingList] = useState<string[]>([]); |
||||||
|
const [followingSize, setFollowingSize] = useState<string>(""); |
||||||
|
const [followingItemCount, setFollowingItemCount] = useState<string>(""); |
||||||
|
const isFollowingName = () => { |
||||||
|
return followingList.includes(followerName); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
qortalRequest({ |
||||||
|
action: "GET_LIST_ITEMS", |
||||||
|
list_name: "followedNames", |
||||||
|
}).then(followList => { |
||||||
|
setFollowingList(followList); |
||||||
|
}); |
||||||
|
getFollowSize(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const followName = () => { |
||||||
|
if (followingList.includes(followerName) === false) { |
||||||
|
qortalRequest({ |
||||||
|
action: "ADD_LIST_ITEM", |
||||||
|
list_name: "followedNames", |
||||||
|
item: followerName, |
||||||
|
}).then(response => { |
||||||
|
if (response === false) console.log("followName failed"); |
||||||
|
else { |
||||||
|
setFollowingList([...followingList, followerName]); |
||||||
|
console.log("following Name: ", followerName); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
const unfollowName = () => { |
||||||
|
if (followingList.includes(followerName)) { |
||||||
|
qortalRequest({ |
||||||
|
action: "DELETE_LIST_ITEM", |
||||||
|
list_name: "followedNames", |
||||||
|
item: followerName, |
||||||
|
}).then(response => { |
||||||
|
if (response === false) console.log("unfollowName failed"); |
||||||
|
else { |
||||||
|
const listWithoutName = followingList.filter( |
||||||
|
item => followerName !== item |
||||||
|
); |
||||||
|
setFollowingList(listWithoutName); |
||||||
|
console.log("unfollowing Name: ", followerName); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const manageFollow = (e: MouseEvent<HTMLButtonElement>) => { |
||||||
|
e.preventDefault(); |
||||||
|
e.stopPropagation(); |
||||||
|
isFollowingName() ? unfollowName() : followName(); |
||||||
|
}; |
||||||
|
|
||||||
|
const verticalPadding = "3px"; |
||||||
|
const horizontalPadding = "8px"; |
||||||
|
const buttonStyle = { |
||||||
|
fontSize: "15px", |
||||||
|
fontWeight: "700", |
||||||
|
paddingTop: verticalPadding, |
||||||
|
paddingBottom: verticalPadding, |
||||||
|
paddingLeft: horizontalPadding, |
||||||
|
paddingRight: horizontalPadding, |
||||||
|
borderRadius: 28, |
||||||
|
color: "white", |
||||||
|
width: "96px", |
||||||
|
height: "45px", |
||||||
|
...props.sx, |
||||||
|
}; |
||||||
|
|
||||||
|
const getFollowSize = () => { |
||||||
|
qortalRequest({ |
||||||
|
action: "LIST_QDN_RESOURCES", |
||||||
|
name: followerName, |
||||||
|
limit: 0, |
||||||
|
includeMetadata: false, |
||||||
|
}).then(publishesList => { |
||||||
|
let totalSize = 0; |
||||||
|
let itemsCount = 0; |
||||||
|
publishesList.map(publish => { |
||||||
|
totalSize += +publish.size; |
||||||
|
itemsCount++; |
||||||
|
}); |
||||||
|
setFollowingSize(formatBytes(totalSize)); |
||||||
|
setFollowingItemCount(itemsCount.toString()); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
function formatBytes(bytes: number, decimals = 2) { |
||||||
|
if (!+bytes) return "0 Bytes"; |
||||||
|
|
||||||
|
const k = 1024; |
||||||
|
const dm = decimals < 0 ? 0 : decimals; |
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; |
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; |
||||||
|
} |
||||||
|
|
||||||
|
const TooltipLine = styled("div")(({ theme }) => ({ |
||||||
|
fontSize: "18px", |
||||||
|
})); |
||||||
|
|
||||||
|
const tooltipTitle = followingSize && ( |
||||||
|
<> |
||||||
|
<TooltipLine> |
||||||
|
Following a name automatically downloads all of its content to your |
||||||
|
node. The more followers a name has, the faster its content will |
||||||
|
download for everyone. |
||||||
|
</TooltipLine> |
||||||
|
<br /> |
||||||
|
<TooltipLine>{`${followerName}'s Current Download Size: ${followingSize}`}</TooltipLine> |
||||||
|
<TooltipLine>{`Number of Files: ${followingItemCount}`}</TooltipLine> |
||||||
|
</> |
||||||
|
); |
||||||
|
|
||||||
|
const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => ( |
||||||
|
<Tooltip {...props} classes={{ popper: className }} /> |
||||||
|
))({ |
||||||
|
[`& .${tooltipClasses.tooltip}`]: { |
||||||
|
maxWidth: 600, |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<CustomWidthTooltip title={tooltipTitle} placement={"top"} arrow> |
||||||
|
<Button |
||||||
|
{...props} |
||||||
|
variant={"contained"} |
||||||
|
color="success" |
||||||
|
sx={buttonStyle} |
||||||
|
onClick={e => manageFollow(e)} |
||||||
|
> |
||||||
|
{isFollowingName() ? "Unfollow" : "Follow"} |
||||||
|
</Button> |
||||||
|
</CustomWidthTooltip> |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,66 @@ |
|||||||
|
import { fetchResourcesByIdentifier } from "../../../utils/qortalRequestFunctions.ts"; |
||||||
|
import { DISLIKE, LIKE, LikeType, NEUTRAL } from "./LikeAndDislike.tsx"; |
||||||
|
|
||||||
|
export const getCurrentLikeType = async ( |
||||||
|
username: string, |
||||||
|
likeIdentifier: string |
||||||
|
): Promise<LikeType> => { |
||||||
|
try { |
||||||
|
const response = await qortalRequest({ |
||||||
|
action: "FETCH_QDN_RESOURCE", |
||||||
|
name: username, |
||||||
|
service: "CHAIN_COMMENT", |
||||||
|
identifier: likeIdentifier, |
||||||
|
}); |
||||||
|
return response?.likeType; |
||||||
|
} catch (e) { |
||||||
|
console.log("liketype error: ", e); |
||||||
|
return NEUTRAL; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
type ResourceType = { likeType: LikeType }; |
||||||
|
export type LikesAndDislikes = { likes: number; dislikes: number }; |
||||||
|
const countLikesAndDislikes = (likesAndDislikes: ResourceType[]) => { |
||||||
|
let totalLikeCount = 0; |
||||||
|
let totalDislikeCount = 0; |
||||||
|
likesAndDislikes.map(likeOrDislike => { |
||||||
|
const likeType = likeOrDislike.likeType; |
||||||
|
if (likeType === LIKE) totalLikeCount += 1; |
||||||
|
if (likeType === DISLIKE) totalDislikeCount += 1; |
||||||
|
}); |
||||||
|
return { |
||||||
|
likes: totalLikeCount, |
||||||
|
dislikes: totalDislikeCount, |
||||||
|
} as LikesAndDislikes; |
||||||
|
}; |
||||||
|
export const getCurrentLikesAndDislikesCount = async ( |
||||||
|
likeIdentifier: string |
||||||
|
) => { |
||||||
|
try { |
||||||
|
const likesAndDislikes = await fetchResourcesByIdentifier<ResourceType>( |
||||||
|
"CHAIN_COMMENT", |
||||||
|
likeIdentifier |
||||||
|
); |
||||||
|
return countLikesAndDislikes(likesAndDislikes); |
||||||
|
} catch (e) { |
||||||
|
console.log(e); |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
export function formatLikeCount(likeCount: number, decimals = 2) { |
||||||
|
if (!+likeCount) return ""; |
||||||
|
|
||||||
|
const sigDigits = Math.floor(Math.log10(likeCount) / 3); |
||||||
|
if (sigDigits < 1) return likeCount.toString(); |
||||||
|
|
||||||
|
const sigDigitSize = 1000; |
||||||
|
const dm = decimals < 0 ? 0 : decimals; |
||||||
|
const sizes = ["K", "M", "B"]; |
||||||
|
|
||||||
|
const sigDigitsToTheThousands = Math.pow(sigDigitSize, sigDigits); |
||||||
|
const sigDigitLikeCount = (likeCount / sigDigitsToTheThousands).toFixed(dm); |
||||||
|
|
||||||
|
return `${sigDigitLikeCount}${sizes[sigDigits - 1] || ""}`; |
||||||
|
} |
@ -0,0 +1,230 @@ |
|||||||
|
import React, { useEffect, useState } from "react"; |
||||||
|
import ThumbUpIcon from "@mui/icons-material/ThumbUp"; |
||||||
|
import ThumbDownIcon from "@mui/icons-material/ThumbDown"; |
||||||
|
import ThumbUpOffAltOutlinedIcon from "@mui/icons-material/ThumbUpOffAltOutlined"; |
||||||
|
import ThumbDownOffAltOutlinedIcon from "@mui/icons-material/ThumbDownOffAltOutlined"; |
||||||
|
import { Box, Tooltip } from "@mui/material"; |
||||||
|
import { useDispatch, useSelector } from "react-redux"; |
||||||
|
import { setNotification } from "../../../state/features/notificationsSlice.ts"; |
||||||
|
import ShortUniqueId from "short-unique-id"; |
||||||
|
import { objectToBase64 } from "../../../utils/toBase64.ts"; |
||||||
|
import { RootState } from "../../../state/store.ts"; |
||||||
|
import { FOR, FOR_LIKE, LIKE_BASE } from "../../../constants/Identifiers.ts"; |
||||||
|
import { |
||||||
|
formatLikeCount, |
||||||
|
getCurrentLikesAndDislikesCount, |
||||||
|
getCurrentLikeType, |
||||||
|
LikesAndDislikes, |
||||||
|
} from "./LikeAndDislike-functions.ts"; |
||||||
|
|
||||||
|
interface LikeAndDislikeProps { |
||||||
|
name: string; |
||||||
|
identifier: string; |
||||||
|
} |
||||||
|
export enum LikeType { |
||||||
|
Like = 1, |
||||||
|
Neutral = 0, |
||||||
|
Dislike = -1, |
||||||
|
} |
||||||
|
export const LIKE = LikeType.Like; |
||||||
|
export const DISLIKE = LikeType.Dislike; |
||||||
|
export const NEUTRAL = LikeType.Neutral; |
||||||
|
export const LikeAndDislike = ({ name, identifier }: LikeAndDislikeProps) => { |
||||||
|
const username = useSelector((state: RootState) => state.auth?.user?.name); |
||||||
|
const dispatch = useDispatch(); |
||||||
|
const [likeCount, setLikeCount] = useState<number>(0); |
||||||
|
const [dislikeCount, setDislikeCount] = useState<number>(0); |
||||||
|
const [currentLikeType, setCurrentLikeType] = useState<LikeType>(NEUTRAL); |
||||||
|
const likeIdentifier = `${LIKE_BASE}${identifier.slice(0, 39)}`; |
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
type PromiseReturn = [LikeType, LikesAndDislikes]; |
||||||
|
|
||||||
|
Promise.all([ |
||||||
|
getCurrentLikeType(username, likeIdentifier), |
||||||
|
getCurrentLikesAndDislikesCount(likeIdentifier), |
||||||
|
]).then(([likeType, likesAndDislikes]: PromiseReturn) => { |
||||||
|
setCurrentLikeType(likeType); |
||||||
|
|
||||||
|
setLikeCount(likesAndDislikes?.likes || 0); |
||||||
|
setDislikeCount(likesAndDislikes?.dislikes || 0); |
||||||
|
setIsLoading(false); |
||||||
|
}); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const updateLikeDataState = (newLikeType: LikeType) => { |
||||||
|
const setSuccessNotification = (msg: string) => |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg, |
||||||
|
alertType: "success", |
||||||
|
}) |
||||||
|
); |
||||||
|
setCurrentLikeType(newLikeType); |
||||||
|
switch (newLikeType) { |
||||||
|
case NEUTRAL: |
||||||
|
if (currentLikeType === LIKE) { |
||||||
|
setLikeCount(count => count - 1); |
||||||
|
setSuccessNotification("Like Removed"); |
||||||
|
} else { |
||||||
|
setDislikeCount(count => count - 1); |
||||||
|
setSuccessNotification("Dislike Removed"); |
||||||
|
} |
||||||
|
|
||||||
|
break; |
||||||
|
case LIKE: |
||||||
|
if (currentLikeType === DISLIKE) setDislikeCount(count => count - 1); |
||||||
|
setLikeCount(count => count + 1); |
||||||
|
setSuccessNotification("Like Successful"); |
||||||
|
break; |
||||||
|
case DISLIKE: |
||||||
|
if (currentLikeType === LIKE) setLikeCount(count => count - 1); |
||||||
|
setDislikeCount(count => count + 1); |
||||||
|
setSuccessNotification("Dislike Successful"); |
||||||
|
break; |
||||||
|
} |
||||||
|
}; |
||||||
|
function publishLike(chosenLikeType: LikeType) { |
||||||
|
if (isLoading) { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: "Wait for Like Data to load first", |
||||||
|
alertType: "error", |
||||||
|
}) |
||||||
|
); |
||||||
|
return; |
||||||
|
} |
||||||
|
try { |
||||||
|
if (!username) throw new Error("You need a name to publish"); |
||||||
|
if (!name) throw new Error("Could not retrieve content creator's name"); |
||||||
|
if (!identifier) throw new Error("Could not retrieve id of video post"); |
||||||
|
|
||||||
|
if (username === name) { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: "You cannot send yourself a like", |
||||||
|
alertType: "error", |
||||||
|
}) |
||||||
|
); |
||||||
|
return; |
||||||
|
} |
||||||
|
qortalRequest({ |
||||||
|
action: "GET_NAME_DATA", |
||||||
|
name: name, |
||||||
|
}).then(resName => { |
||||||
|
const address = resName.owner; |
||||||
|
if (!address) |
||||||
|
throw new Error("Could not retrieve content creator's address"); |
||||||
|
}); |
||||||
|
|
||||||
|
objectToBase64({ |
||||||
|
likeType: chosenLikeType, |
||||||
|
}).then(likeToBase64 => { |
||||||
|
qortalRequest({ |
||||||
|
action: "PUBLISH_QDN_RESOURCE", |
||||||
|
name: username, |
||||||
|
service: "CHAIN_COMMENT", |
||||||
|
data64: likeToBase64, |
||||||
|
title: "", |
||||||
|
identifier: likeIdentifier, |
||||||
|
filename: `like_metadata.json`, |
||||||
|
}).then(() => { |
||||||
|
updateLikeDataState(chosenLikeType); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} catch (error: any) { |
||||||
|
dispatch( |
||||||
|
setNotification({ |
||||||
|
msg: |
||||||
|
error || |
||||||
|
error?.error || |
||||||
|
error?.message || |
||||||
|
"Failed to publish Like or Dislike", |
||||||
|
alertType: "error", |
||||||
|
}) |
||||||
|
); |
||||||
|
throw new Error("Failed to publish Super Like"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
display: "flex", |
||||||
|
alignItems: "center", |
||||||
|
gap: "15px", |
||||||
|
cursor: "pointer", |
||||||
|
flexShrink: 0, |
||||||
|
}} |
||||||
|
> |
||||||
|
<Tooltip title="Like or Dislike Video" placement="top"> |
||||||
|
<Box |
||||||
|
sx={{ |
||||||
|
padding: "5px", |
||||||
|
borderRadius: "7px", |
||||||
|
gap: "5px", |
||||||
|
display: "flex", |
||||||
|
alignItems: "center", |
||||||
|
marginRight: "10px", |
||||||
|
height: "53px", |
||||||
|
}} |
||||||
|
> |
||||||
|
{currentLikeType === LIKE ? ( |
||||||
|
<ThumbUpIcon onClick={() => publishLike(NEUTRAL)} /> |
||||||
|
) : ( |
||||||
|
<ThumbUpOffAltOutlinedIcon onClick={() => publishLike(LIKE)} /> |
||||||
|
)} |
||||||
|
{likeCount > 0 && ( |
||||||
|
<div |
||||||
|
style={{ |
||||||
|
display: "flex", |
||||||
|
alignItems: "center", |
||||||
|
justifyContent: "center", |
||||||
|
userSelect: "none", |
||||||
|
}} |
||||||
|
> |
||||||
|
<span style={{ marginRight: "10px", paddingBottom: "4px" }}> |
||||||
|
{formatLikeCount(likeCount)} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{currentLikeType === DISLIKE ? ( |
||||||
|
<ThumbDownIcon |
||||||
|
onClick={() => publishLike(NEUTRAL)} |
||||||
|
color={"error"} |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<ThumbDownOffAltOutlinedIcon |
||||||
|
onClick={() => publishLike(DISLIKE)} |
||||||
|
color={"error"} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{dislikeCount > 0 && ( |
||||||
|
<div |
||||||
|
style={{ |
||||||
|
display: "flex", |
||||||
|
alignItems: "center", |
||||||
|
justifyContent: "center", |
||||||
|
userSelect: "none", |
||||||
|
}} |
||||||
|
> |
||||||
|
<span |
||||||
|
style={{ |
||||||
|
marginRight: "10px", |
||||||
|
paddingBottom: "4px", |
||||||
|
color: "red", |
||||||
|
}} |
||||||
|
> |
||||||
|
{formatLikeCount(dislikeCount)} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</Box> |
||||||
|
</Tooltip> |
||||||
|
</Box> |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue