Qortal-Hub/src/components/Group/ListOfGroupPromotions.tsx
2025-04-29 22:25:20 +03:00

888 lines
30 KiB
TypeScript

import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import {
Avatar,
Box,
Button,
ButtonBase,
Collapse,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
MenuItem,
Popover,
Select,
TextField,
Typography,
useTheme,
} from '@mui/material';
import { LoadingButton } from '@mui/lab';
import LockIcon from '@mui/icons-material/Lock';
import NoEncryptionGmailerrorredIcon from '@mui/icons-material/NoEncryptionGmailerrorred';
import {
MyContext,
getArbitraryEndpointReact,
getBaseApiReact,
} from '../../App';
import { Spacer } from '../../common/Spacer';
import { CustomLoader } from '../../common/CustomLoader';
import { RequestQueueWithPromise } from '../../utils/queue/queue';
import {
myGroupsWhereIAmAdminAtom,
promotionTimeIntervalAtom,
promotionsAtom,
} from '../../atoms/global';
import { Label } from './AddGroup';
import ShortUniqueId from 'short-unique-id';
import { CustomizedSnackbars } from '../Snackbar/Snackbar';
import { getGroupNames } from './UserListOfInvites';
import { useVirtualizer } from '@tanstack/react-virtual';
import ErrorBoundary from '../../common/ErrorBoundary';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { getFee } from '../../background';
import { useAtom } from 'jotai';
export const requestQueuePromos = new RequestQueueWithPromise(3);
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;
}
const uid = new ShortUniqueId({ length: 8 });
export function getGroupId(str) {
const match = str.match(/group-(\d+)-/);
return match ? match[1] : null;
}
const THIRTY_MINUTES = 30 * 60 * 1000; // 30 minutes in milliseconds
export const ListOfGroupPromotions = () => {
const [popoverAnchor, setPopoverAnchor] = useState(null);
const [openPopoverIndex, setOpenPopoverIndex] = useState(null);
const [selectedGroup, setSelectedGroup] = useState(null);
const [loading, setLoading] = useState(false);
const [isShowModal, setIsShowModal] = useState(false);
const [text, setText] = useState('');
const [myGroupsWhereIAmAdmin, setMyGroupsWhereIAmAdmin] = useAtom(
myGroupsWhereIAmAdminAtom
);
const [promotions, setPromotions] = useAtom(promotionsAtom);
const [promotionTimeInterval, setPromotionTimeInterval] = useAtom(
promotionTimeIntervalAtom
);
const [isExpanded, setIsExpanded] = React.useState(false);
const [openSnack, setOpenSnack] = useState(false);
const [infoSnack, setInfoSnack] = useState(null);
const [fee, setFee] = useState(null);
const [isLoadingJoinGroup, setIsLoadingJoinGroup] = useState(false);
const [isLoadingPublish, setIsLoadingPublish] = useState(false);
const { show, setTxList } = useContext(MyContext);
const theme = useTheme();
const listRef = useRef();
const rowVirtualizer = useVirtualizer({
count: promotions.length,
getItemKey: React.useCallback(
(index) => promotions[index]?.identifier,
[promotions]
),
getScrollElement: () => listRef.current,
estimateSize: () => 80, // Provide an estimated height of items, adjust this as needed
overscan: 10, // Number of items to render outside the visible area to improve smoothness
});
useEffect(() => {
try {
(async () => {
const feeRes = await getFee('ARBITRARY');
setFee(feeRes?.fee);
})();
} catch (error) {
console.log(error);
}
}, []);
const getPromotions = useCallback(async () => {
try {
setPromotionTimeInterval(Date.now());
const identifier = `group-promotions-ui24-`;
const url = `${getBaseApiReact()}${getArbitraryEndpointReact()}?mode=ALL&service=DOCUMENT&identifier=${identifier}&limit=100&includemetadata=false&reverse=true&prefix=true`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const responseData = await response.json();
let data: any[] = [];
const uniqueGroupIds = new Set();
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const getPromos = responseData?.map(async (promo: any) => {
if (promo?.size < 200 && promo.created > oneWeekAgo) {
const name = await requestQueuePromos.enqueue(async () => {
const url = `${getBaseApiReact()}/arbitrary/${promo.service}/${
promo.name
}/${promo.identifier}`;
const response = await fetch(url, {
method: 'GET',
});
try {
const responseData = await response.text();
if (responseData) {
const groupId = getGroupId(promo.identifier);
// Check if this groupId has already been processed
if (!uniqueGroupIds.has(groupId)) {
// Add the groupId to the set
uniqueGroupIds.add(groupId);
// Push the item to data
data.push({
data: responseData,
groupId,
...promo,
});
}
}
} catch (error) {
console.error('Error fetching promo:', error);
}
});
}
return true;
});
await Promise.all(getPromos);
const groupWithInfo = await getGroupNames(
data.sort((a, b) => b.created - a.created)
);
setPromotions(groupWithInfo);
} catch (error) {
console.error(error);
}
}, []);
useEffect(() => {
const now = Date.now();
const timeSinceLastFetch = now - promotionTimeInterval;
const initialDelay =
timeSinceLastFetch >= THIRTY_MINUTES
? 0
: THIRTY_MINUTES - timeSinceLastFetch;
const initialTimeout = setTimeout(() => {
getPromotions();
// Start a 30-minute interval
const interval = setInterval(() => {
getPromotions();
}, THIRTY_MINUTES);
return () => clearInterval(interval);
}, initialDelay);
return () => clearTimeout(initialTimeout);
}, [getPromotions, promotionTimeInterval]);
const handlePopoverOpen = (event, index) => {
setPopoverAnchor(event.currentTarget);
setOpenPopoverIndex(index);
};
const handlePopoverClose = () => {
setPopoverAnchor(null);
setOpenPopoverIndex(null);
};
const publishPromo = async () => {
try {
setIsLoadingPublish(true);
const data = utf8ToBase64(text);
const identifier = `group-promotions-ui24-group-${selectedGroup}-${uid.rnd()}`;
await new Promise((res, rej) => {
window
.sendMessage('publishOnQDN', {
data: data,
identifier: identifier,
service: 'DOCUMENT',
})
.then((response) => {
if (!response?.error) {
res(response);
return;
}
rej(response.error);
})
.catch((error) => {
rej(error.message || 'An error occurred');
});
}); // TODO translate
setInfoSnack({
type: 'success',
message:
'Successfully published promotion. It may take a couple of minutes for the promotion to appear',
});
setOpenSnack(true);
setText('');
setSelectedGroup(null);
setIsShowModal(false);
} catch (error) {
setInfoSnack({
type: 'error',
message:
error?.message || 'Error publishing the promotion. Please try again',
});
setOpenSnack(true);
} finally {
setIsLoadingPublish(false);
}
};
const handleJoinGroup = async (group, isOpen) => {
try {
const groupId = group.groupId;
const fee = await getFee('JOIN_GROUP');
await show({
message: 'Would you like to perform an JOIN_GROUP transaction?',
publishFee: fee.fee + ' QORT',
});
setIsLoadingJoinGroup(true);
await new Promise((res, rej) => {
window
.sendMessage('joinGroup', {
groupId,
})
.then((response) => {
if (!response?.error) {
setInfoSnack({
type: 'success',
message:
'Successfully requested to join group. It may take a couple of minutes for the changes to propagate',
});
if (isOpen) {
setTxList((prev) => [
{
...response,
type: 'joined-group',
label: `Joined Group ${group?.groupName}: awaiting confirmation`,
labelDone: `Joined Group ${group?.groupName}: success!`,
done: false,
groupId,
},
...prev,
]);
} else {
setTxList((prev) => [
{
...response,
type: 'joined-group-request',
label: `Requested to join Group ${group?.groupName}: awaiting confirmation`,
labelDone: `Requested to join Group ${group?.groupName}: success!`,
done: false,
groupId,
},
...prev,
]);
}
setOpenSnack(true);
handlePopoverClose();
res(response);
return;
} else {
setInfoSnack({
type: 'error',
message: response?.error,
});
setOpenSnack(true);
rej(response.error);
}
})
.catch((error) => {
setInfoSnack({
type: 'error',
message: error.message || 'An error occurred',
});
setOpenSnack(true);
rej(error);
});
});
setIsLoadingJoinGroup(false);
} catch (error) {
} finally {
setIsLoadingJoinGroup(false);
}
};
return (
<Box
sx={{
width: '100%',
display: 'flex',
marginTop: '20px',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
display: 'flex',
gap: '20px',
width: '100%',
justifyContent: 'space-between',
}}
>
<ButtonBase
sx={{
display: 'flex',
flexDirection: 'row',
padding: `0px ${isExpanded ? '24px' : '20px'}`,
gap: '10px',
justifyContent: 'flex-start',
alignSelf: isExpanded && 'flex-start',
}}
onClick={() => setIsExpanded((prev) => !prev)}
>
<Typography
sx={{
fontSize: '1rem',
}}
>
Group promotions{' '}
{promotions.length > 0 && ` (${promotions.length})`}
</Typography>
{isExpanded ? (
<ExpandLessIcon
sx={{
marginLeft: 'auto',
}}
/>
) : (
<ExpandMoreIcon
sx={{
marginLeft: 'auto',
}}
/>
)}
</ButtonBase>
<Box
style={{
width: '330px',
}}
/>
</Box>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<>
<Box
sx={{
width: '750px',
maxWidth: '90%',
display: 'flex',
flexDirection: 'column',
padding: '0px 20px',
}}
>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography
sx={{
fontSize: '13px',
fontWeight: 600,
}}
></Typography>
<Button
variant="contained"
onClick={() => setIsShowModal(true)}
sx={{
fontSize: '12px',
}}
>
Add Promotion
</Button>
</Box>
<Spacer height="10px" />
</Box>
<Box
sx={{
bgcolor: 'background.paper',
borderRadius: '19px',
display: 'flex',
flexDirection: 'column',
maxHeight: '700px',
maxWidth: '90%',
padding: '20px 0px',
width: '750px',
}}
>
{loading && promotions.length === 0 && (
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
}}
>
<CustomLoader />
</Box>
)}
{!loading && promotions.length === 0 && (
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
}}
>
<Typography
sx={{
fontSize: '11px',
fontWeight: 400,
color: 'rgba(255, 255, 255, 0.2)',
}}
>
Nothing to display
</Typography>
</Box>
)}
<div
style={{
height: '600px',
position: 'relative',
display: 'flex',
flexDirection: 'column',
width: '100%',
}}
>
<div
ref={listRef}
className="scrollable-container"
style={{
flexGrow: 1,
overflow: 'auto',
position: 'relative',
display: 'flex',
height: '0px',
}}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
width: '100%',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const index = virtualRow.index;
const promotion = promotions[index];
return (
<div
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={rowVirtualizer.measureElement} //measure dynamic row height
key={promotion?.identifier}
style={{
position: 'absolute',
top: 0,
left: '50%', // Move to the center horizontally
transform: `translateY(${virtualRow.start}px) translateX(-50%)`, // Adjust for centering
width: '100%', // Control width (90% of the parent)
padding: '10px 0',
display: 'flex',
alignItems: 'center',
overscrollBehavior: 'none',
flexDirection: 'column',
gap: '5px',
}}
>
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
padding: '0px 20px',
}}
>
<Popover
open={openPopoverIndex === promotion?.groupId}
anchorEl={popoverAnchor}
onClose={(event, reason) => {
if (reason === 'backdropClick') {
// Prevent closing on backdrop click
return;
}
handlePopoverClose(); // Close only on other events like Esc key press
}}
anchorOrigin={{
vertical: 'top',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
style={{ marginTop: '8px' }}
>
<Box
sx={{
width: '325px',
height: 'auto',
maxHeight: '400px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px',
padding: '10px',
}}
>
<Typography
sx={{
fontSize: '13px',
fontWeight: 600,
}}
>
Group name: {` ${promotion?.groupName}`}
</Typography>
<Typography
sx={{
fontSize: '13px',
fontWeight: 600,
}}
>
Number of members:{' '}
{` ${promotion?.memberCount}`}
</Typography>
{promotion?.description && (
<Typography
sx={{
fontSize: '13px',
fontWeight: 600,
}}
>
{promotion?.description}
</Typography>
)}
{promotion?.isOpen === false && (
<Typography
sx={{
fontSize: '13px',
fontWeight: 600,
}}
>
*This is a closed/private group, so you
will need to wait until an admin accepts
your request
</Typography>
)}
<Spacer height="5px" />
<Box
sx={{
display: 'flex',
gap: '20px',
alignItems: 'center',
width: '100%',
justifyContent: 'center',
}}
>
<LoadingButton
loading={isLoadingJoinGroup}
loadingPosition="start"
variant="contained"
onClick={handlePopoverClose}
>
Close
</LoadingButton>
<LoadingButton
loading={isLoadingJoinGroup}
loadingPosition="start"
variant="contained"
onClick={() =>
handleJoinGroup(
promotion,
promotion?.isOpen
)
}
>
Join
</LoadingButton>
</Box>
</Box>
</Popover>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '15px',
}}
>
<Avatar
sx={{
backgroundColor: '#27282c',
color: theme.palette.text.primary,
}}
alt={promotion?.name}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${
promotion?.name
}/qortal_avatar?async=true`}
>
{promotion?.name?.charAt(0)}
</Avatar>
<Typography
sx={{
fontWight: 600,
fontFamily: 'Inter',
}}
>
{promotion?.name}
</Typography>
</Box>
<Typography
sx={{
fontWight: 600,
fontFamily: 'Inter',
}}
>
{promotion?.groupName}
</Typography>
</Box>
<Spacer height="20px" />
<Box
sx={{
display: 'flex',
gap: '20px',
alignItems: 'center',
}}
>
{promotion?.isOpen === false && (
<LockIcon
sx={{
color: theme.palette.other.positive,
}}
/>
)}
{promotion?.isOpen === true && (
<NoEncryptionGmailerrorredIcon
sx={{
color: theme.palette.other.danger,
}}
/>
)}
<Typography
sx={{
fontSize: '15px',
fontWeight: 600,
}}
>
{promotion?.isOpen
? 'Public group'
: 'Private group'}
</Typography>
</Box>
<Spacer height="20px" />
<Typography
sx={{
fontWight: 600,
fontFamily: 'Inter',
}}
>
{promotion?.data}
</Typography>
<Spacer height="20px" />
<Box
sx={{
display: 'flex',
justifyContent: 'center',
width: '100%',
}}
>
<Button
// variant="contained"
onClick={(event) =>
handlePopoverOpen(event, promotion?.groupId)
}
sx={{
fontSize: '12px',
color: theme.palette.text.primary,
}}
>
Join Group: {` ${promotion?.groupName}`}
</Button>
</Box>
</Box>
<Spacer height="50px" />
</ErrorBoundary>
</div>
);
})}
</div>
</div>
</div>
</div>
</Box>
</>
</Collapse>
<Spacer height="20px" />
{isShowModal && (
<Dialog
open={isShowModal}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{'Promote your group to non-members'}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Only the latest promotion from the week will be shown for your
group.
</DialogContentText>
<DialogContentText id="alert-dialog-description2">
Max 200 characters. Publish Fee: {fee && fee} {' QORT'}
</DialogContentText>
<Spacer height="20px" />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '5px',
}}
>
<Label>Select a group</Label>
<Label>Only groups where you are an admin will be shown</Label>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={selectedGroup}
label="Groups where you are an admin"
onChange={(e) => setSelectedGroup(e.target.value)}
variant="outlined"
>
{myGroupsWhereIAmAdmin?.map((group) => {
return (
<MenuItem key={group?.groupId} value={group?.groupId}>
{group?.groupName}
</MenuItem>
);
})}
</Select>
</Box>
<Spacer height="20px" />
<TextField
label="Promotion text"
variant="filled"
fullWidth
value={text}
onChange={(e) => setText(e.target.value)}
inputProps={{
maxLength: 200,
}}
multiline={true}
sx={{
'& .MuiFormLabel-root': {
color: theme.palette.text.primary,
},
'& .MuiFormLabel-root.Mui-focused': {
color: theme.palette.text.primary,
},
}}
/>
</DialogContent>
<DialogActions>
<Button
disabled={isLoadingPublish}
variant="contained"
onClick={() => setIsShowModal(false)}
>
Close
</Button>
<Button
disabled={!text.trim() || !selectedGroup || isLoadingPublish}
variant="contained"
onClick={publishPromo}
autoFocus
>
Publish
</Button>
</DialogActions>
</Dialog>
)}
<CustomizedSnackbars
open={openSnack}
setOpen={setOpenSnack}
info={infoSnack}
setInfo={setInfoSnack}
/>
</Box>
);
};