import React, { useCallback, useContext, useEffect, useRef, useState, } from "react"; import { Avatar, Box, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, ListItem, ListItemAvatar, ListItemButton, ListItemText, MenuItem, Popover, Select, TextField, Typography, } from "@mui/material"; import { getNameInfo } from "./Group"; import { getBaseApi, getFee } from "../../background"; import { LoadingButton } from "@mui/lab"; import LockIcon from '@mui/icons-material/Lock'; import NoEncryptionGmailerrorredIcon from '@mui/icons-material/NoEncryptionGmailerrorred'; import { MyContext, getArbitraryEndpointReact, getBaseApiReact, isMobile, } from "../../App"; import { Spacer } from "../../common/Spacer"; import { CustomLoader } from "../../common/CustomLoader"; import { RequestQueueWithPromise } from "../../utils/queue/queue"; import { useRecoilState } from "recoil"; 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 { WrapperUserAction } from "../WrapperUserAction"; import { useVirtualizer } from "@tanstack/react-virtual"; import ErrorBoundary from "../../common/ErrorBoundary"; export const requestQueuePromos = new RequestQueueWithPromise(20); 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] = useRecoilState( myGroupsWhereIAmAdminAtom ); const [promotions, setPromotions] = useRecoilState( promotionsAtom ); const [promotionTimeInterval, setPromotionTimeInterval] = useRecoilState( promotionTimeIntervalAtom ); 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 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) {} }, []); 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"); }); }); 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", flexDirection: "column", alignItems: "center", marginTop: "25px", }} > <Box sx={{ width: isMobile ? "320px" : "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, }} > Group Promotions </Typography> <Button variant="contained" onClick={() => setIsShowModal(true)} sx={{ fontSize: "12px", }} > Add Promotion </Button> </Box> <Spacer height="10px" /> </Box> <Box sx={{ width: isMobile ? "320px" : "750px", maxWidth: "90%", maxHeight: "700px", display: "flex", flexDirection: "column", bgcolor: "background.paper", padding: "20px 0px", borderRadius: "19px", }} > {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: "white", }} alt={promotion?.name} src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${ promotion?.name }/qortal_avatar?async=true`} > {promotion?.name?.charAt(0)} </Avatar> <Typography sx={{ fontWight: 600, fontFamily: "Inter", color: "cadetBlue", }} > {promotion?.name} </Typography> </Box> </Box> <Spacer height="20px"/> <Typography sx={{ fontWight: 600, fontFamily: "Inter", color: "cadetBlue", }} > {promotion?.groupName} </Typography> <Spacer height="20px" /> <Box sx={{ display: 'flex', gap: '20px', alignItems: 'center' }}> {promotion?.isOpen === false && ( <LockIcon sx={{ color: 'var(--green)' }} /> )} {promotion?.isOpen === true && ( <NoEncryptionGmailerrorredIcon sx={{ color: 'var(--danger)' }} /> )} <Typography sx={{ fontSize: "15px", fontWeight: 600, }} > {promotion?.isOpen ? 'Public group' : 'Private group' } </Typography> </Box> <Spacer height="20px" /> <Typography sx={{ fontWight: 600, fontFamily: "Inter", color: "cadetBlue", }} > {promotion?.data} </Typography> <Spacer height="20px" /> <Box sx={{ display: "flex", justifyContent: "center", width: "100%", }} > <Button onClick={(event) => handlePopoverOpen(event, promotion?.groupId)} sx={{ fontSize: "12px", color: 'white' }} > Join Group: {` ${promotion?.groupName}`} </Button> </Box> </Box> <Spacer height="50px" /> </ErrorBoundary> </div> ); })} </div> </div> </div> </div> </Box> <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)} > {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: "white", }, "& .MuiFormLabel-root.Mui-focused": { color: "white", }, }} /> </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> ); };