3
0
mirror of https://github.com/Qortal/q-tube.git synced 2025-02-11 17:55:51 +00:00

added super likes

This commit is contained in:
PhilReact 2023-12-14 16:15:45 +02:00
parent 3ab514e1cf
commit d8e2ecb382
11 changed files with 1955 additions and 7 deletions

BIN
src/assets/img/qort.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -179,7 +179,7 @@ export const Comment = ({
);
};
const CommentCard = ({
export const CommentCard = ({
message,
created,
name,

View File

@ -0,0 +1,324 @@
import { Badge, Box, Button, List, ListItem, ListItemText, Popover, Typography } from '@mui/material'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../../state/store'
import { FOR, FOR_SUPER_LIKE, SUPER_LIKE_BASE, minPriceSuperlike } from '../../../constants'
import NotificationsIcon from '@mui/icons-material/Notifications'
import { formatDate } from '../../../utils/time'
import ThumbUpIcon from "@mui/icons-material/ThumbUp";
import { extractSigValue, getPaymentInfo, isTimestampWithinRange } from '../../../pages/VideoContent/VideoContent'
import { useNavigate } from 'react-router-dom'
import localForage from "localforage";
import moment from 'moment'
const generalLocal = localForage.createInstance({
name: "q-tube-general",
});
function extractIdValue(metadescription) {
// Function to extract the substring within double asterisks
function extractSubstring(str) {
const match = str.match(/\*\*(.*?)\*\*/);
return match ? match[1] : null;
}
// Function to extract the 'sig' value
function extractSig(str) {
const regex = /id:(.*?)(;|$)/;
const match = str.match(regex);
return match ? match[1] : null;
}
// Extracting the relevant substring
const relevantSubstring = extractSubstring(metadescription);
if (relevantSubstring) {
// Extracting the 'sig' value
return extractSig(relevantSubstring);
} else {
return null;
}
}
export const Notifications = () => {
const dispatch = useDispatch()
const [anchorElNotification, setAnchorElNotification] = useState<HTMLButtonElement | null>(null)
const [notifications, setNotifications] = useState<any[]>([])
const [notificationTimestamp, setNotificationTimestamp] = useState<null | number>(null)
const username = useSelector((state: RootState) => state.auth?.user?.name);
const usernameAddress = useSelector((state: RootState) => state.auth?.user?.address);
const navigate = useNavigate();
const interval = useRef<any>(null)
const getInitialTimestamp = async ()=> {
const timestamp: undefined | number = await generalLocal.getItem("notification-timestamp");
if(timestamp){
setNotificationTimestamp(timestamp)
}
}
useEffect(()=> {
getInitialTimestamp()
}, [])
const openNotificationPopover = (event: any) => {
const target = event.currentTarget as unknown as HTMLButtonElement | null
setAnchorElNotification(target)
}
const closeNotificationPopover = () => {
setAnchorElNotification(null)
}
const fullNotifications = useMemo(() => {
return [...notifications].sort(
(a, b) => b.created - a.created
)
}, [notifications])
const notificationBadgeLength = useMemo(()=> {
if(!notificationTimestamp) return fullNotifications.length
return fullNotifications?.filter((item)=> item.created > notificationTimestamp).length
}, [fullNotifications, notificationTimestamp])
const checkNotifications = useCallback(async (username: string) => {
try {
// let notificationComments: Item[] =
// (await notification.getItem('comments')) || []
// notificationComments = notificationComments
// .filter((nc) => nc.postId && nc.postName && nc.lastSeen)
// .sort((a, b) => b.lastSeen - a.lastSeen)
const timestamp = await generalLocal.getItem("notification-timestamp");
const after = timestamp || moment().subtract(5, 'days').valueOf();
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&identifier=${SUPER_LIKE_BASE}&limit=20&includemetadata=true&reverse=true&excludeblocked=true&offset=0&description=${FOR}:${username}_${FOR_SUPER_LIKE}&after=${after}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseDataSearch = await response.json();
let notifys = []
for (const comment of responseDataSearch) {
if (comment.identifier && comment.name && comment?.metadata?.description) {
try {
const result = extractSigValue(comment?.metadata?.description)
if(!result) continue
const res = await getPaymentInfo(result);
if(+res?.amount >= minPriceSuperlike && res.recipient === usernameAddress && isTimestampWithinRange(res?.timestamp, comment.created)){
let urlReference = null
try {
let idForUrl = extractIdValue(comment?.metadata?.description)
const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${idForUrl}&limit=1&includemetadata=false&reverse=true&excludeblocked=true&offset=0&name=${username}`;
const response2 = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseSearch = await response2.json();
if(responseSearch.length > 0){
urlReference = responseSearch[0]
}
} catch (error) {
}
// const url = `/arbitrary/BLOG_COMMENT/${comment.name}/${comment.identifier}`;
// const response = await fetch(url, {
// method: "GET",
// headers: {
// "Content-Type": "application/json",
// },
// });
// if(!response.ok) continue
// const responseData2 = await response.text();
notifys = [...notifys, {
...comment,
amount: res.amount,
urlReference: urlReference || null
}];
}
} catch (error) {
}
}
}
setNotifications((prev) => {
const allNotifications = [...notifys, ...prev];
const uniqueNotifications = Array.from(new Map(allNotifications.map(notif => [notif.identifier, notif])).values());
return uniqueNotifications.slice(0, 20);
});
} catch (error) {
console.log({ error })
}
}, [])
const checkNotificationsFunc = useCallback(
(username: string) => {
let isCalling = false
interval.current = setInterval(async () => {
if (isCalling) return
isCalling = true
const res = await checkNotifications(username)
isCalling = false
}, 60000)
checkNotifications(username)
},
[checkNotifications])
useEffect(() => {
if (!username) return
checkNotificationsFunc(username)
return () => {
if (interval?.current) {
clearInterval(interval.current)
}
}
}, [checkNotificationsFunc, username])
const openPopover = Boolean(anchorElNotification)
return (
<Box
sx={{
display: 'flex',
alignItems: 'center'
}}
>
<Badge
badgeContent={notificationBadgeLength}
color="primary"
sx={{
margin: '0px 12px'
}}
>
<Button
onClick={(e) => {
openNotificationPopover(e)
generalLocal.setItem("notification-timestamp", Date.now());
setNotificationTimestamp(Date.now)
}}
sx={{
margin: '0px',
padding: '0px',
height: 'auto',
width: 'auto',
minWidth: 'unset'
}}
>
<NotificationsIcon color="action" />
</Button>
</Badge>
<Popover
id={'simple-popover-notification'}
open={openPopover}
anchorEl={anchorElNotification}
onClose={closeNotificationPopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
>
<Box>
<List
sx={{
maxHeight: '300px',
overflow: 'auto'
}}
>
{fullNotifications.length === 0 && (
<ListItem
>
<ListItemText
primary="No new notifications">
</ListItemText>
</ListItem>
)}
{fullNotifications.map((notification: any, index: number) => (
<ListItem
key={index}
divider
sx={{
cursor: notification?.urlReference ? 'pointer' : 'default'
}}
onClick={async () => {
if(notification?.urlReference){
navigate(`/video/${notification?.urlReference?.name}/${notification?.urlReference?.identifier}`);
}
}}
>
<ListItemText
primary={
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: '5px'
}}>
<Typography
component="span"
variant="body1"
color="textPrimary"
>
Super Like
</Typography>
<ThumbUpIcon
style={{
color: "gold",
}}
/>
</Box>
}
secondary={
<React.Fragment>
<Typography
component="span"
sx={{
fontSize: '16px'
}}
color="textSecondary"
>
{formatDate(notification.created)}
</Typography>
<Typography
component="span"
sx={{
fontSize: '16px'
}}
color="textSecondary"
>
{` from ${notification.name}`}
</Typography>
</React.Fragment>
}
/>
</ListItem>
))}
</List>
</Box>
</Popover>
</Box>
)
}

View File

@ -0,0 +1,310 @@
import React, { useState } from "react";
import ThumbUpIcon from "@mui/icons-material/ThumbUp";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Input,
InputAdornment,
InputLabel,
Modal,
Tooltip,
} from "@mui/material";
import qortImg from "../../../assets/img/qort.png";
import { MultiplePublish } from "../MultiplePublish/MultiplePublish";
import { useDispatch, useSelector } from "react-redux";
import { setNotification } from "../../../state/features/notificationsSlice";
import ShortUniqueId from "short-unique-id";
import { objectToBase64 } from "../../../utils/toBase64";
import { FOR, FOR_SUPER_LIKE, QTUBE_VIDEO_BASE, SUPER_LIKE_BASE, minPriceSuperlike } from "../../../constants";
import { CommentInput } from "../Comments/Comments-styles";
import {
CrowdfundActionButton,
CrowdfundActionButtonRow,
ModalBody,
NewCrowdfundTitle,
Spacer,
} from "../../UploadVideo/Upload-styles";
import { utf8ToBase64 } from "../SuperLikesList/CommentEditor";
import { RootState } from "../../../state/store";
const uid = new ShortUniqueId({ length: 4 });
export const SuperLike = ({ onSuccess, name, service, identifier, totalAmount, numberOfSuperlikes }) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [amount, setAmount] = useState<number>(10);
const [comment, setComment] = useState<string>("");
const username = useSelector((state: RootState) => state.auth?.user?.name);
const [isOpenMultiplePublish, setIsOpenMultiplePublish] = useState(false);
const [publishes, setPublishes] = useState<any[]>([]);
const dispatch = useDispatch();
const resetValues = () => {
setAmount(0);
setComment("");
setPublishes([]);
};
const onClose = () => {
resetValues();
setIsOpen(false);
};
async function publishSuperLike() {
try {
if(!username) throw new Error("You need a name to publish")
if(!name) throw new Error("Could not retrieve content creator's name");
let resName = await qortalRequest({
action: "GET_NAME_DATA",
name: name,
});
const address = resName.owner;
if(!identifier) throw new Error("Could not retrieve id of video post");
if (comment.length > 200) throw new Error("Comment needs to be under 200 characters")
if (!address)
throw new Error("Could not retrieve content creator's address");
if (!amount || amount < minPriceSuperlike)
throw new Error(`The amount needs to be at least ${minPriceSuperlike} QORT`);
let listOfPublishes = [];
const res = await qortalRequest({
action: "SEND_COIN",
coin: "QORT",
destinationAddress: address,
amount: amount,
});
let metadescription = `**sig:${res.signature};${FOR}:${name}_${FOR_SUPER_LIKE};nm:${name.slice(0,20)};id:${identifier.slice(-30)}**`;
const id = uid();
const identifierSuperLike = `${SUPER_LIKE_BASE}${identifier.slice(
0,
39
)}_${id}`;
// Description is obtained from raw data
const base64 = utf8ToBase64(comment);
const requestBodyJson: any = {
action: "PUBLISH_QDN_RESOURCE",
name: username,
service: "BLOG_COMMENT",
data64: base64,
title: "",
description: metadescription,
identifier: identifierSuperLike,
tag1: SUPER_LIKE_BASE,
filename: `superlike_metadata.json`,
};
listOfPublishes.push(requestBodyJson);
setPublishes(listOfPublishes);
setIsOpenMultiplePublish(true);
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to publish Super Like",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to publish Super Like",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to publish Super Like",
alertType: "error",
};
}
if (!notificationObj) return;
dispatch(setNotification(notificationObj));
throw new Error("Failed to publish Super Like");
}
}
return (
<>
<Box
onClick={() => {
setIsOpen(true);
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: '15px',
cursor: 'pointer',
flexShrink: 0
}}
>
{numberOfSuperlikes === 0 ? null : (
<p style={{
fontSize: '16px',
userSelect: 'none',
margin: '0px',
padding: '0px'
}}>{totalAmount} QORT from {numberOfSuperlikes} Super Likes</p>
)}
<Tooltip title="Super Like" placement="top">
<Box sx={{
padding: '5px',
borderRadius: '7px',
gap: '10px',
display: 'flex',
alignItems: 'center',
outline: '1px gold solid'
}}>
<ThumbUpIcon
style={{
color: "gold",
}}
/>
<p style={{
fontSize: '16px',
margin: '0px'
}}>Super Like</p>
</Box>
</Tooltip>
</Box>
<Modal
open={isOpen}
onClose={() => setIsOpen(false)}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<ModalBody>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: '100%'
}}
>
<NewCrowdfundTitle>Super Like</NewCrowdfundTitle>
</Box>
<DialogContent>
<Box
sx={{
width: "300px",
display: "flex",
justifyContent: "center",
}}
>
<Box>
<InputLabel htmlFor="standard-adornment-amount">
Amount in QORT
</InputLabel>
<Input
id="standard-adornment-amount"
type="number"
value={amount}
onChange={(e) => setAmount(+e.target.value)}
startAdornment={
<InputAdornment position="start">
<img
style={{
height: "15px",
width: "15px",
}}
src={qortImg}
/>
</InputAdornment>
}
/>
</Box>
</Box>
<Spacer height="25px" />
<Box>
<CommentInput
id="standard-multiline-flexible"
label="Your comment"
multiline
maxRows={8}
variant="filled"
value={comment}
inputProps={{
maxLength: 200,
}}
InputLabelProps={{ style: { fontSize: "18px" } }}
onChange={(e) => setComment(e.target.value)}
/>
</Box>
</DialogContent>
<CrowdfundActionButtonRow>
<CrowdfundActionButton
onClick={() => {
setIsOpen(false);
resetValues();
onClose();
}}
variant="contained"
color="error"
>
Cancel
</CrowdfundActionButton>
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
}}
>
<CrowdfundActionButton
variant="contained"
onClick={() => {
publishSuperLike();
}}
>
Publish Super Like
</CrowdfundActionButton>
</Box>
</CrowdfundActionButtonRow>
</ModalBody>
</Modal>
{isOpenMultiplePublish && (
<MultiplePublish
isOpen={isOpenMultiplePublish}
onSubmit={() => {
onSuccess({
name: username,
message: comment,
service,
identifier,
amount: +amount,
created: Date.now()
});
setIsOpenMultiplePublish(false);
onClose();
dispatch(
setNotification({
msg: "Super like published",
alertType: "success",
})
);
}}
publishes={publishes}
/>
)}
</>
);
};

View File

@ -0,0 +1,327 @@
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
useTheme,
} from "@mui/material";
import React, { useCallback, useState, useEffect } from "react";
import ThumbUpIcon from "@mui/icons-material/ThumbUp";
import { CommentEditor } from "./CommentEditor";
import {
CardContentContainerComment,
CommentActionButtonRow,
CommentDateText,
EditReplyButton,
StyledCardComment,
} from "./Comments-styles";
import { StyledCardHeaderComment } from "./Comments-styles";
import { StyledCardColComment } from "./Comments-styles";
import { AuthorTextComment } from "./Comments-styles";
import {
StyledCardContentComment,
LoadMoreCommentsButton as CommentActionButton,
} from "./Comments-styles";
import { useSelector } from "react-redux";
import { RootState } from "../../../state/store";
import Portal from "../Portal";
import { formatDate } from "../../../utils/time";
interface CommentProps {
comment: any;
postId: string;
postName: string;
onSubmit: (obj?: any, isEdit?: boolean) => void;
amount?: null | number
}
export const Comment = ({
comment,
postId,
postName,
onSubmit,
amount
}: CommentProps) => {
const [isReplying, setIsReplying] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
const { user } = useSelector((state: RootState) => state.auth);
const [currentEdit, setCurrentEdit] = useState<any>(null);
const theme = useTheme();
const handleSubmit = useCallback((comment: any, isEdit?: boolean) => {
onSubmit(comment, isEdit);
setCurrentEdit(null);
setIsReplying(false);
}, []);
return (
<Box
id={comment?.identifier}
sx={{
display: "flex",
width: "100%",
flexDirection: "column",
}}
>
{currentEdit && (
<Portal>
<Dialog
open={!!currentEdit}
onClose={() => setCurrentEdit(null)}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title"></DialogTitle>
<DialogContent>
<Box
sx={{
width: "300px",
display: "flex",
justifyContent: "center",
}}
>
<CommentEditor
onSubmit={obj => handleSubmit(obj, true)}
postId={postId}
postName={postName}
isEdit
commentId={currentEdit?.identifier}
commentMessage={currentEdit?.message}
/>
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setCurrentEdit(null)}>
Close
</Button>
</DialogActions>
</Dialog>
</Portal>
)}
<CommentCard
name={comment?.name}
message={comment?.message}
replies={comment?.replies || []}
setCurrentEdit={setCurrentEdit}
amount={amount}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "5px",
marginTop: "20px",
justifyContent: "space-between",
}}
>
{comment?.created && (
<Typography
variant="h6"
sx={{
fontSize: "12px",
marginLeft: "5px",
}}
color={theme.palette.text.primary}
>
{formatDate(+comment?.created)}
</Typography>
)}
<CommentActionButtonRow>
<CommentActionButton
size="small"
variant="contained"
onClick={() => setIsReplying(true)}
>
reply
</CommentActionButton>
{user?.name === comment?.name && (
<CommentActionButton
size="small"
variant="contained"
onClick={() => setCurrentEdit(comment)}
>
edit
</CommentActionButton>
)}
{isReplying && (
<CommentActionButton
size="small"
variant="contained"
onClick={() => {
setIsReplying(false);
setIsEditing(false);
}}
>
close
</CommentActionButton>
)}
</CommentActionButtonRow>
</Box>
</CommentCard>
<Box
sx={{
display: "flex",
width: "100%",
flexDirection: "column",
alignItems: "center",
}}
>
{isReplying && (
<CommentEditor
onSubmit={handleSubmit}
postId={postId}
postName={postName}
isReply
commentId={comment.identifier}
/>
)}
</Box>
</Box>
);
};
export const CommentCard = ({
message,
created,
name,
replies,
children,
setCurrentEdit,
isReply,
amount
}: any) => {
const [avatarUrl, setAvatarUrl] = React.useState<string>("");
const { user } = useSelector((state: RootState) => state.auth);
const theme = useTheme();
const getAvatar = React.useCallback(async (author: string) => {
try {
const url = await qortalRequest({
action: "GET_QDN_RESOURCE_URL",
name: author,
service: "THUMBNAIL",
identifier: "qortal_avatar",
});
setAvatarUrl(url);
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
getAvatar(name);
}, [name]);
return (
<CardContentContainerComment>
<StyledCardHeaderComment
sx={{
"& .MuiCardHeader-content": {
overflow: "hidden",
},
}}
>
<Box>
<Avatar
src={avatarUrl}
alt={`${name}'s avatar`}
sx={{ width: "35px", height: "35px" }}
/>
</Box>
<StyledCardColComment>
<Box sx={{
display: 'flex',
gap: '10px',
alignItems: 'center'
}}>
<AuthorTextComment>{name}</AuthorTextComment>
{!isReply && (
<ThumbUpIcon
style={{
color: "gold",
cursor: "pointer",
}}
/>
)}
{amount && (
<Typography sx={{
fontSize: '20px',
color: 'gold'
}}>
{parseFloat(amount)?.toFixed(2)} QORT
</Typography>
)}
</Box>
</StyledCardColComment>
</StyledCardHeaderComment>
<StyledCardContentComment>
<StyledCardComment>{message}</StyledCardComment>
</StyledCardContentComment>
<Box
sx={{
paddingLeft: "15px",
display: "flex",
flexDirection: "column",
}}
>
{replies?.map((reply: any) => {
return (
<Box
key={reply?.identifier}
id={reply?.identifier}
sx={{
display: "flex",
border: "1px solid grey",
borderRadius: "10px",
marginTop: "8px",
}}
>
<CommentCard
name={reply?.name}
message={reply?.message}
setCurrentEdit={setCurrentEdit}
isReply
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "5px",
justifyContent: "space-between",
}}
>
{reply?.created && (
<CommentDateText>
{formatDate(+reply?.created)}
</CommentDateText>
)}
{user?.name === reply?.name ? (
<EditReplyButton
size="small"
variant="contained"
onClick={() => setCurrentEdit(reply)}
sx={{}}
>
edit
</EditReplyButton>
) : (
<Box />
)}
</Box>
</CommentCard>
</Box>
);
})}
</Box>
{children}
</CardContentContainerComment>
);
};

View File

@ -0,0 +1,254 @@
import { Box, Button, TextField } from "@mui/material";
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../../state/store";
import ShortUniqueId from "short-unique-id";
import { setNotification } from "../../../state/features/notificationsSlice";
import { toBase64 } from "../../../utils/toBase64";
import localforage from "localforage";
import {
CommentInput,
CommentInputContainer,
SubmitCommentButton,
} from "./Comments-styles";
import { COMMENT_BASE } from "../../../constants";
const uid = new ShortUniqueId();
const notification = localforage.createInstance({
name: "notification",
});
const MAX_ITEMS = 10;
export interface Item {
id: string;
lastSeen: number;
postId: string;
postName: string;
}
export async function addItem(item: Item): Promise<void> {
// Get all items
let notificationComments: Item[] =
(await notification.getItem("comments")) || [];
// Find the item with the same id, if it exists
let existingItemIndex = notificationComments.findIndex(i => i.id === item.id);
if (existingItemIndex !== -1) {
// If the item exists, update its date
notificationComments[existingItemIndex].lastSeen = item.lastSeen;
} else {
// If the item doesn't exist, add it
notificationComments.push(item);
// If adding the item has caused us to exceed the max number of items, remove the oldest one
if (notificationComments.length > MAX_ITEMS) {
notificationComments.sort((a, b) => b.lastSeen - a.lastSeen); // sort items by date, newest first
notificationComments.pop(); // remove the oldest item
}
}
// Store the items back into localForage
await notification.setItem("comments", notificationComments);
}
export async function updateItemDate(item: any): Promise<void> {
// Get all items
let notificationComments: Item[] =
(await notification.getItem("comments")) || [];
let notificationCreatorComment: any =
(await notification.getItem("post-comments")) || {};
const findPostId = notificationCreatorComment[item.postId];
if (findPostId) {
notificationCreatorComment[item.postId].lastSeen = item.lastSeen;
}
// Find the item with the same id, if it exists
notificationComments.forEach((nc, index) => {
if (nc.postId === item.postId) {
notificationComments[index].lastSeen = item.lastSeen;
}
});
// Store the items back into localForage
await notification.setItem("comments", notificationComments);
await notification.setItem("post-comments", notificationCreatorComment);
}
interface CommentEditorProps {
postId: string;
postName: string;
onSubmit: (obj: any) => void;
isReply?: boolean;
commentId?: string;
isEdit?: boolean;
commentMessage?: string;
}
export function utf8ToBase64(inputString: string): string {
// Encode the string as UTF-8
const utf8String = encodeURIComponent(inputString).replace(
/%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(Number("0x" + p1))
);
// Convert the UTF-8 encoded string to base64
const base64String = btoa(utf8String);
return base64String;
}
export const CommentEditor = ({
onSubmit,
postId,
postName,
isReply,
commentId,
isEdit,
commentMessage
}: CommentEditorProps) => {
const [value, setValue] = useState<string>("");
const dispatch = useDispatch();
const { user } = useSelector((state: RootState) => state.auth);
useEffect(() => {
if (isEdit && commentMessage) {
setValue(commentMessage);
}
}, [isEdit, commentMessage]);
const publishComment = async (
identifier: string,
idForNotification?: string
) => {
let address;
let name;
let errorMsg = "";
address = user?.address;
name = user?.name || "";
if (!address) {
errorMsg = "Cannot post: your address isn't available";
}
if (!name) {
errorMsg = "Cannot post without a name";
}
if (value.length > 200) {
errorMsg = "Comment needs to be under 200 characters";
}
if (errorMsg) {
dispatch(
setNotification({
msg: errorMsg,
alertType: "error",
})
);
throw new Error(errorMsg);
}
try {
const base64 = utf8ToBase64(value);
const resourceResponse = await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
name: name,
service: "BLOG_COMMENT",
data64: base64,
identifier: identifier,
});
dispatch(
setNotification({
msg: "Comment successfully published",
alertType: "success",
})
);
if (idForNotification) {
addItem({
id: idForNotification,
lastSeen: Date.now(),
postId,
postName: postName,
});
}
return resourceResponse;
} catch (error: any) {
let notificationObj: any = null;
if (typeof error === "string") {
notificationObj = {
msg: error || "Failed to publish comment",
alertType: "error",
};
} else if (typeof error?.error === "string") {
notificationObj = {
msg: error?.error || "Failed to publish comment",
alertType: "error",
};
} else {
notificationObj = {
msg: error?.message || "Failed to publish comment",
alertType: "error",
};
}
if (!notificationObj) throw new Error("Failed to publish comment");
dispatch(setNotification(notificationObj));
throw new Error("Failed to publish comment");
}
};
const handleSubmit = async () => {
try {
const id = uid();
let identifier = `${COMMENT_BASE}${postId.slice(-12)}_base_${id}`;
let idForNotification = identifier;
let service = 'BLOG_COMMENT'
if (isReply && commentId) {
const removeBaseCommentId = commentId;
removeBaseCommentId.replace("_base_", "");
identifier = `${COMMENT_BASE}${postId.slice(
-12
)}_reply_${removeBaseCommentId.slice(-6)}_${id}`;
idForNotification = commentId;
}
if (isEdit && commentId) {
identifier = commentId;
}
await publishComment(identifier, idForNotification);
onSubmit({
created: Date.now(),
identifier,
message: value,
service,
name: user?.name,
});
setValue("");
} catch (error) {
console.error(error);
}
};
return (
<CommentInputContainer>
<CommentInput
id="standard-multiline-flexible"
label="Your comment"
multiline
maxRows={4}
variant="filled"
value={value}
inputProps={{
maxLength: 200,
}}
InputLabelProps={{ style: { fontSize: "18px" } }}
onChange={e => setValue(e.target.value)}
/>
<SubmitCommentButton variant="contained" onClick={handleSubmit}>
{isReply ? "Submit reply" : isEdit ? "Edit" : "Submit comment"}
</SubmitCommentButton>
</CommentInputContainer>
);
};

View File

@ -0,0 +1,281 @@
import { styled } from "@mui/system";
import { Card, Box, Typography, Button, TextField } from "@mui/material";
export const StyledCard = styled(Card)(({ theme }) => ({
backgroundColor:
theme.palette.mode === "light"
? theme.palette.primary.main
: theme.palette.primary.dark,
maxWidth: "600px",
width: "100%",
margin: "10px 0px",
cursor: "pointer",
"@media (max-width: 450px)": {
width: "100%;",
},
}));
export const CardContentContainer = styled(Box)(({ theme }) => ({
backgroundColor:
theme.palette.mode === "light"
? theme.palette.primary.dark
: theme.palette.primary.light,
margin: "5px 10px",
borderRadius: "15px",
}));
export const CardContentContainerComment = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.mode === "light" ? "#a9d9d038" : "#c3abe414",
border: `1px solid gold`,
margin: "0px",
padding: "8px 15px",
borderRadius: "8px",
width: "100%",
display: "flex",
flexDirection: "column",
}));
export const StyledCardHeader = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: "5px",
padding: "7px",
});
export const StyledCardHeaderComment = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: "7px",
padding: "9px 7px",
});
export const StyledCardCol = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const StyledCardColComment = styled(Box)({
display: "flex",
overflow: "hidden",
flexDirection: "column",
gap: "2px",
alignItems: "flex-start",
width: "100%",
});
export const StyledCardContent = styled(Box)({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
padding: "5px 10px",
gap: "10px",
});
export const StyledCardContentComment = styled(Box)({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "flex-start",
padding: "5px 10px",
gap: "10px",
});
export const StyledCardComment = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
color: theme.palette.text.primary,
fontSize: "19px",
wordBreak: "break-word"
}));
export const TitleText = styled(Typography)({
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
fontFamily: "Cairo, sans-serif",
fontSize: "22px",
lineHeight: "1.2",
});
export const AuthorText = styled(Typography)({
fontFamily: "Raleway, sans-serif",
fontSize: "16px",
lineHeight: "1.2",
});
export const AuthorTextComment = styled(Typography)(({ theme }) => ({
fontFamily: "Montserrat, sans-serif",
fontSize: "17px",
letterSpacing: "0.3px",
fontWeight: 400,
color: theme.palette.text.primary,
userSelect: "none",
}));
export const IconsBox = styled(Box)({
display: "flex",
gap: "3px",
position: "absolute",
top: "12px",
right: "5px",
transition: "all 0.3s ease-in-out",
});
export const BookmarkIconContainer = styled(Box)({
display: "flex",
boxShadow: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;",
backgroundColor: "#fbfbfb",
color: "#50e3c2",
padding: "5px",
borderRadius: "3px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
transform: "scale(1.1)",
},
});
export const BlockIconContainer = styled(Box)({
display: "flex",
boxShadow: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;",
backgroundColor: "#fbfbfb",
color: "#c25252",
padding: "5px",
borderRadius: "3px",
transition: "all 0.3s ease-in-out",
"&:hover": {
cursor: "pointer",
transform: "scale(1.1)",
},
});
export const CommentsContainer = styled(Box)({
width: "90%",
maxWidth: "1000px",
display: "flex",
flexDirection: "column",
flex: "1",
overflow: "auto",
});
export const CommentContainer = styled(Box)({
display: "flex",
flexDirection: "column",
margin: "25px 0px 50px 0px",
maxWidth: "100%",
width: "100%",
gap: "10px",
padding: "0px 5px",
});
export const NoCommentsRow = styled(Box)({
display: "flex",
alignItems: "center",
justifyContent: "center",
flex: "1",
padding: "10px 0px",
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
fontSize: "18px",
});
export const LoadMoreCommentsButtonRow = styled(Box)({
display: "flex",
});
export const EditReplyButton = styled(Button)(({ theme }) => ({
width: "30px",
alignSelf: "flex-end",
background: theme.palette.primary.light,
color: "#ffffff",
}));
export const LoadMoreCommentsButton = styled(Button)(({ theme }) => ({
fontFamily: "Montserrat",
fontWeight: 400,
letterSpacing: "0.2px",
fontSize: "15px",
backgroundColor: theme.palette.primary.main,
color: "#ffffff",
}));
export const CommentActionButtonRow = styled(Box)({
display: "flex",
alignItems: "center",
gap: "5px",
});
export const CommentEditorContainer = styled(Box)({
width: "100%",
display: "flex",
justifyContent: "center",
});
export const CommentDateText = styled(Typography)(({ theme }) => ({
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
fontSize: "13px",
marginLeft: "5px",
color: theme.palette.text.primary,
}));
export const CommentInputContainer = styled(Box)({
display: "flex",
flexDirection: "column",
marginTop: "15px",
width: "90%",
maxWidth: "1000px",
borderRadius: "8px",
gap: "10px",
alignItems: "center",
marginBottom: "25px",
});
export const CommentInput = styled(TextField)(({ theme }) => ({
backgroundColor: theme.palette.mode === "light" ? "#a9d9d01d" : "#c3abe4a",
border: `1px solid ${theme.palette.primary.main}`,
width: "100%",
borderRadius: "8px",
'& [class$="-MuiFilledInput-root"]': {
fontFamily: "Mulish",
letterSpacing: 0,
fontWeight: 400,
color: theme.palette.text.primary,
fontSize: "19px",
minHeight: "100px",
backgroundColor: "transparent",
"&:before": {
borderBottom: "none",
"&:hover": {
borderBottom: "none",
},
},
"&:hover": {
backgroundColor: "transparent",
"&:before": {
borderBottom: "none",
},
},
},
}));
export const SubmitCommentButton = styled(Button)(({ theme }) => ({
fontFamily: "Montserrat",
fontWeight: 400,
letterSpacing: "0.2px",
fontSize: "15px",
backgroundColor: theme.palette.primary.main,
color: "#ffffff",
width: "75%",
}));

View File

@ -0,0 +1,245 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CommentEditor } from "./CommentEditor";
import { Comment } from "./Comment";
import { Box, Button, CircularProgress, useTheme } from "@mui/material";
import { styled } from "@mui/system";
import { useSelector } from "react-redux";
import { RootState } from "../../../state/store";
import { useNavigate, useLocation } from "react-router-dom";
import {
CommentContainer,
CommentEditorContainer,
CommentsContainer,
LoadMoreCommentsButton,
LoadMoreCommentsButtonRow,
NoCommentsRow,
} from "./Comments-styles";
import { COMMENT_BASE } from "../../../constants";
import { CrowdfundSubTitle, CrowdfundSubTitleRow } from "../../UploadVideo/Upload-styles";
interface CommentSectionProps {
postId: string;
postName: string;
superlikes: any[];
getMore: ()=> void;
loadingSuperLikes: boolean;
}
const Panel = styled("div")`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
padding-bottom: 10px;
height: 100%;
overflow: hidden;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: #555;
}
`;
export const SuperLikesSection = ({ loadingSuperLikes, superlikes, postId, postName, getMore }: CommentSectionProps) => {
const navigate = useNavigate();
const location = useLocation();
const [listComments, setListComments] = useState<any[]>([]);
const [isOpen, setIsOpen] = useState<boolean>(false);
const { user } = useSelector((state: RootState) => state.auth);
const [newMessages, setNewMessages] = useState(0);
const [loadingComments, setLoadingComments] = useState<boolean>(null);
const onSubmit = (obj?: any, isEdit?: boolean) => {
if (isEdit) {
setListComments((prev: any[]) => {
const findCommentIndex = prev.findIndex(
item => item?.identifier === obj?.identifier
);
if (findCommentIndex === -1) return prev;
const newArray = [...prev];
newArray[findCommentIndex] = obj;
return newArray;
});
return;
}
setListComments(prev => [
...prev,
{
...obj,
},
]);
};
useEffect(() => {
const query = new URLSearchParams(location.search);
let commentVar = query?.get("comment");
if (commentVar) {
if (commentVar && commentVar.endsWith("/")) {
commentVar = commentVar.slice(0, -1);
}
setIsOpen(true);
if (listComments.length > 0) {
const el = document.getElementById(commentVar);
if (el) {
el.scrollIntoView();
el.classList.add("glow");
setTimeout(() => {
el.classList.remove("glow");
}, 2000);
}
navigate(location.pathname, { replace: true });
}
}
}, [navigate, location, listComments]);
const getReplies = useCallback(
async (commentId, postId) => {
const offset = 0;
const removeBaseCommentId = commentId.replace("_base_", "");
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=${COMMENT_BASE}${postId.slice(
-12
)}_reply_${removeBaseCommentId.slice(
-6
)}&limit=0&includemetadata=false&offset=${offset}&reverse=false&excludeblocked=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
const comments: any[] = [];
for (const comment of responseData) {
if (comment.identifier && comment.name) {
const url = `/arbitrary/BLOG_COMMENT/${comment.name}/${comment.identifier}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData2 = await response.text();
if (responseData) {
comments.push({
message: responseData2,
...comment,
});
}
}
}
return comments;
},
[postId]
);
const getComments = useCallback(
async (superlikes, postId) => {
try {
setLoadingComments(true);
let comments: any[] = [];
for (const comment of superlikes) {
comments.push(comment);
const res = await getReplies(comment.identifier, postId);
comments = [...comments, ...res];
}
setListComments(comments);
} catch (error) {
console.error(error);
} finally {
setLoadingComments(false);
}
},
[]
);
useEffect(() => {
if(superlikes.length > 0 && postId){
getComments(superlikes, postId)
}
}, [getComments, superlikes, postId]);
const structuredCommentList = useMemo(() => {
return listComments.reduce((acc, curr, index, array) => {
if (curr?.identifier?.includes("_reply_")) {
return acc;
}
acc.push({
...curr,
replies: array.filter(comment =>
comment.identifier.includes(`_reply_${curr.identifier.slice(-6)}`)
),
});
return acc;
}, []);
}, [listComments]);
console.log({loadingComments, listComments, superlikes})
return (
<>
<Panel>
<CrowdfundSubTitleRow >
<CrowdfundSubTitle sx={{
fontSize: '18px',
color: 'gold'
}}>Super Likes</CrowdfundSubTitle>
</CrowdfundSubTitleRow>
<CommentsContainer>
{(loadingComments || loadingSuperLikes) ? (
<NoCommentsRow>
<CircularProgress />
</NoCommentsRow>
) : listComments.length === 0 ? (
<NoCommentsRow>
There are no super likes yet. Be the first!
</NoCommentsRow>
) : (
<CommentContainer>
{structuredCommentList.map((comment: any) => {
return (
<Comment
key={comment?.identifier}
comment={comment}
onSubmit={onSubmit}
postId={postId}
postName={postName}
amount={comment?.amount || null}
/>
);
})}
</CommentContainer>
)}
{listComments.length > 20 && (
<LoadMoreCommentsButtonRow>
<LoadMoreCommentsButton
onClick={() => {
getMore()
}}
variant="contained"
size="small"
>
Load More Comments
</LoadMoreCommentsButton>
</LoadMoreCommentsButtonRow>
)}
</CommentsContainer>
</Panel>
</>
);
};

View File

@ -37,6 +37,7 @@ import { RootState } from "../../../state/store";
import { useWindowSize } from "../../../hooks/useWindowSize";
import { UploadVideo } from "../../UploadVideo/UploadVideo";
import { StyledButton } from "../../UploadVideo/Upload-styles";
import { Notifications } from "../../common/Notifications/Notifications";
interface Props {
isAuthenticated: boolean;
userName: string | null;
@ -354,6 +355,9 @@ const NavBar: React.FC<Props> = ({
/>
</Box>
</Popover>
{isAuthenticated && userName && (
<Notifications />
)}
<DownloadTaskManager />
{isAuthenticated && userName && (

View File

@ -1,4 +1,4 @@
const useTestIdentifiers = false;
const useTestIdentifiers = true;
export const QTUBE_VIDEO_BASE = useTestIdentifiers
? "MYTEST_vid_"
@ -8,10 +8,24 @@ export const QTUBE_VIDEO_BASE = useTestIdentifiers
? "MYTEST_playlist_"
: "qtube_playlist_";
export const SUPER_LIKE_BASE = useTestIdentifiers
? "MYTEST_superlike_"
: "qtube_superlike_";
export const COMMENT_BASE = useTestIdentifiers
? "qcomment_v1_MYTEST_"
: "qcomment_v1_qtube_";
export const FOR = useTestIdentifiers
? "FORTEST5"
: "FOR0962";
export const FOR_SUPER_LIKE = useTestIdentifiers
? "MYTEST_sl"
: `qtube_sl`;
export const minPriceSuperlike = 10
interface SubCategory {
id: number;
name: string;

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useRef, useEffect } from "react";
import React, { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { setIsLoadingGlobal } from "../../state/features/globalSlice";
@ -33,15 +33,94 @@ import {
CrowdfundSubTitle,
CrowdfundSubTitleRow,
} from "../../components/UploadVideo/Upload-styles";
import { QTUBE_VIDEO_BASE } from "../../constants";
import { FOR_SUPER_LIKE, QTUBE_VIDEO_BASE, SUPER_LIKE_BASE, minPriceSuperlike } from "../../constants";
import { Playlists } from "../../components/Playlists/Playlists";
import { DisplayHtml } from "../../components/common/TextEditor/DisplayHtml";
import FileElement from "../../components/common/FileElement";
import { SuperLike } from "../../components/common/SuperLike/SuperLike";
import { CommentContainer } from "../../components/common/Comments/Comments-styles";
import { Comment } from "../../components/common/Comments/Comment";
import { SuperLikesSection } from "../../components/common/SuperLikesList/SuperLikesSection";
export function isTimestampWithinRange(resTimestamp, resCreated) {
// Calculate the absolute difference in milliseconds
var difference = Math.abs(resTimestamp - resCreated);
// 2 minutes in milliseconds
var twoMinutesInMilliseconds = 2 * 60 * 1000;
// Check if the difference is within 2 minutes
return difference <= twoMinutesInMilliseconds;
}
export function extractSigValue(metadescription) {
// Function to extract the substring within double asterisks
function extractSubstring(str) {
const match = str.match(/\*\*(.*?)\*\*/);
return match ? match[1] : null;
}
// Function to extract the 'sig' value
function extractSig(str) {
const regex = /sig:(.*?)(;|$)/;
const match = str.match(regex);
return match ? match[1] : null;
}
// Extracting the relevant substring
const relevantSubstring = extractSubstring(metadescription);
if (relevantSubstring) {
// Extracting the 'sig' value
return extractSig(relevantSubstring);
} else {
return null;
}
}
export const getPaymentInfo = async (signature: string) => {
try {
const url = `/transactions/signature/${signature}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
// Coin payment info must be added to responseData so we can display it to the user
const responseData = await response.json();
if (responseData && !responseData.error) {
return responseData;
} else {
throw new Error('unable to get payment')
}
} catch (error) {
throw new Error('unable to get payment')
}
};
export const VideoContent = () => {
const { name, id } = useParams();
const [isExpandedDescription, setIsExpandedDescription] =
useState<boolean>(false);
const [superlikeList, setSuperlikelist] = useState<any[]>([])
const [loadingSuperLikes, setLoadingSuperLikes] = useState<boolean>(false)
const calculateAmountSuperlike = useMemo(()=> {
const totalQort = superlikeList?.reduce((acc, curr)=> {
if(curr?.amount && !isNaN(parseFloat(curr.amount))) return acc + parseFloat(curr.amount)
else return acc
}, 0)
return totalQort?.toFixed(2)
}, [superlikeList])
const numberOfSuperlikes = useMemo(()=> {
return superlikeList?.length ?? 0
}, [superlikeList])
const [nameAddress, setNameAddress] = useState<string>('')
const [descriptionHeight, setDescriptionHeight] =
useState<null | number>(null);
@ -50,8 +129,24 @@ export const VideoContent = () => {
);
const contentRef = useRef(null);
const getAddressName = async (name)=> {
const response = await qortalRequest({
action: "GET_NAME_DATA",
name: name
});
if(response?.owner){
setNameAddress(response.owner)
}
}
useEffect(()=> {
if(name){
getAddressName(name)
}
}, [name])
const avatarUrl = useMemo(() => {
let url = "";
if (name && userAvatarHash[name]) {
@ -194,6 +289,79 @@ export const VideoContent = () => {
}
}, [videoData]);
const getComments = useCallback(
async (id, nameAddressParam) => {
if(!id) return
try {
setLoadingSuperLikes(true);
const url = `/arbitrary/resources/search?mode=ALL&service=BLOG_COMMENT&query=${SUPER_LIKE_BASE}${id.slice(
0,39
)}&limit=100&includemetadata=true&reverse=true&excludeblocked=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const responseData = await response.json();
let comments: any[] = [];
for (const comment of responseData) {
if (comment.identifier && comment.name && comment?.metadata?.description) {
try {
const result = extractSigValue(comment?.metadata?.description)
if(!result) continue
const res = await getPaymentInfo(result);
if(+res?.amount >= minPriceSuperlike && res.recipient === nameAddressParam && isTimestampWithinRange(res?.timestamp, comment.created)){
const url = `/arbitrary/BLOG_COMMENT/${comment.name}/${comment.identifier}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if(!response.ok) continue
const responseData2 = await response.text();
comments = [...comments, {
...comment,
message: responseData2,
amount: res.amount
}];
}
} catch (error) {
}
}
}
setSuperlikelist(comments);
} catch (error) {
console.error(error);
} finally {
setLoadingSuperLikes(false);
}
},
[]
);
useEffect(() => {
if(!nameAddress || !id) return
getComments(id, nameAddress);
}, [getComments, id, nameAddress]);
return (
<Box
@ -248,15 +416,32 @@ export const VideoContent = () => {
</FileElement>
</FileAttachmentContainer>
</Box>
<VideoTitle
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
marginTop: '20px',
gap: '10px'
}}>
<VideoTitle
variant="h1"
color="textPrimary"
sx={{
textAlign: "center",
textAlign: "start",
}}
>
{videoData?.title}
</VideoTitle>
{videoData && (
<SuperLike numberOfSuperlikes={numberOfSuperlikes} totalAmount={calculateAmountSuperlike} name={videoData?.user} service={videoData?.service} identifier={videoData?.id} onSuccess={(val)=> {
setSuperlikelist((prev)=> [val, ...prev])
}} />
)}
</Box>
{videoData?.created && (
<Typography
variant="h6"
@ -368,6 +553,10 @@ export const VideoContent = () => {
</Box>
</VideoPlayerContainer>
<SuperLikesSection getMore={()=> {
}} loadingSuperLikes={loadingSuperLikes} superlikes={superlikeList} postId={id || ""} postName={name || ""} />
<Box
sx={{
display: "flex",