Merge pull request #34 from Qortal/feature/add-group-avatar

Feature/add group avatar
This commit is contained in:
Phillip 2025-04-29 17:45:45 +03:00 committed by GitHub
commit 2d8bf8fb97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1302 additions and 742 deletions

View File

@ -1,15 +1,15 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'org.Qortal.Qortal-Hub', appId: "org.Qortal.Qortal-Hub",
appName: 'Qortal-Hub', appName: "Qortal-Hub",
webDir: 'dist', webDir: "dist",
"plugins": { plugins: {
"LocalNotifications": { LocalNotifications: {
"smallIcon": "qort", smallIcon: "qort",
"iconColor": "#09b6e8" iconColor: "#09b6e8",
} },
} },
}; };
export default config; export default config;

View File

@ -100,17 +100,22 @@ import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil';
import { import {
canSaveSettingToQdnAtom, canSaveSettingToQdnAtom,
enabledDevModeAtom, enabledDevModeAtom,
groupAnnouncementsAtom,
groupChatTimestampsAtom,
groupsOwnerNamesAtom,
groupsPropertiesAtom, groupsPropertiesAtom,
hasSettingsChangedAtom, hasSettingsChangedAtom,
isDisabledEditorEnterAtom, isDisabledEditorEnterAtom,
isUsingImportExportSettingsAtom, isUsingImportExportSettingsAtom,
lastPaymentSeenTimestampAtom, lastPaymentSeenTimestampAtom,
mailsAtom, mailsAtom,
mutedGroupsAtom,
oldPinnedAppsAtom, oldPinnedAppsAtom,
qMailLastEnteredTimestampAtom, qMailLastEnteredTimestampAtom,
settingsLocalLastUpdatedAtom, settingsLocalLastUpdatedAtom,
settingsQDNLastUpdatedAtom, settingsQDNLastUpdatedAtom,
sortablePinnedAppsAtom, sortablePinnedAppsAtom,
timestampEnterDataAtom,
} from './atoms/global'; } from './atoms/global';
import { NotAuthenticated } from './ExtStates/NotAuthenticated'; import { NotAuthenticated } from './ExtStates/NotAuthenticated';
import { handleGetFileFromIndexedDB } from './utils/indexedDB'; import { handleGetFileFromIndexedDB } from './utils/indexedDB';
@ -477,6 +482,16 @@ function App() {
const resetLastPaymentSeenTimestampAtom = useResetRecoilState( const resetLastPaymentSeenTimestampAtom = useResetRecoilState(
lastPaymentSeenTimestampAtom lastPaymentSeenTimestampAtom
); );
const resetGroupsOwnerNamesAtom = useResetRecoilState(groupsOwnerNamesAtom);
const resetGroupAnnouncementsAtom = useResetRecoilState(
groupAnnouncementsAtom
);
const resetMutedGroupsAtom = useResetRecoilState(mutedGroupsAtom);
const resetGroupChatTimestampsAtom = useResetRecoilState(
groupChatTimestampsAtom
);
const resetTimestampEnterAtom = useResetRecoilState(timestampEnterDataAtom);
const resetAllRecoil = () => { const resetAllRecoil = () => {
resetAtomSortablePinnedAppsAtom(); resetAtomSortablePinnedAppsAtom();
@ -489,6 +504,11 @@ function App() {
resetAtomMailsAtom(); resetAtomMailsAtom();
resetGroupPropertiesAtom(); resetGroupPropertiesAtom();
resetLastPaymentSeenTimestampAtom(); resetLastPaymentSeenTimestampAtom();
resetGroupsOwnerNamesAtom();
resetGroupAnnouncementsAtom();
resetMutedGroupsAtom();
resetGroupChatTimestampsAtom();
resetTimestampEnterAtom();
}; };
const handleSetGlobalApikey = (key) => { const handleSetGlobalApikey = (key) => {

View File

@ -192,8 +192,82 @@ export const groupsPropertiesAtom = atom({
key: 'groupsPropertiesAtom', key: 'groupsPropertiesAtom',
default: {}, default: {},
}); });
export const groupsOwnerNamesAtom = atom({
key: 'groupsOwnerNamesAtom',
default: {},
});
export const isOpenBlockedModalAtom = atom({ export const isOpenBlockedModalAtom = atom({
key: 'isOpenBlockedModalAtom', key: 'isOpenBlockedModalAtom',
default: false, default: false,
}); });
export const groupsOwnerNamesSelector = selectorFamily({
key: 'groupsOwnerNamesSelector',
get:
(key) =>
({ get }) => {
const data = get(groupsOwnerNamesAtom);
return data[key] || null; // Return the value for the key or null if not found
},
});
export const groupAnnouncementsAtom = atom({
key: 'groupAnnouncementsAtom',
default: {},
});
export const groupAnnouncementSelector = selectorFamily({
key: 'groupAnnouncementSelector',
get:
(key) =>
({ get }) => {
const data = get(groupAnnouncementsAtom);
return data[key] || null; // Return the value for the key or null if not found
},
});
export const groupPropertySelector = selectorFamily({
key: 'groupPropertySelector',
get:
(key) =>
({ get }) => {
const data = get(groupsPropertiesAtom);
return data[key] || null; // Return the value for the key or null if not found
},
});
export const mutedGroupsAtom = atom({
key: 'mutedGroupsAtom',
default: [],
});
export const groupChatTimestampsAtom = atom({
key: 'groupChatTimestampsAtom',
default: {},
});
export const groupChatTimestampSelector = selectorFamily({
key: 'groupChatTimestampSelector',
get:
(key) =>
({ get }) => {
const data = get(groupChatTimestampsAtom);
return data[key] || null; // Return the value for the key or null if not found
},
});
export const timestampEnterDataAtom = atom({
key: 'timestampEnterDataAtom',
default: {},
});
export const timestampEnterDataSelector = selectorFamily({
key: 'timestampEnterDataSelector',
get:
(key) =>
({ get }) => {
const data = get(timestampEnterDataAtom);
return data[key] || null; // Return the value for the key or null if not found
},
});

View File

@ -15,6 +15,8 @@ export const AdminSpace = ({
defaultThread, defaultThread,
setDefaultThread, setDefaultThread,
setIsForceShowCreationKeyPopup, setIsForceShowCreationKeyPopup,
balance,
isOwner,
}) => { }) => {
const { rootHeight } = useContext(MyContext); const { rootHeight } = useContext(MyContext);
const [isMoved, setIsMoved] = useState(false); const [isMoved, setIsMoved] = useState(false);
@ -37,6 +39,7 @@ export const AdminSpace = ({
position: hide ? 'fixed' : 'relative', position: hide ? 'fixed' : 'relative',
visibility: hide && 'hidden', visibility: hide && 'hidden',
width: '100%', width: '100%',
overflow: 'auto',
}} }}
> >
{!isAdmin && ( {!isAdmin && (
@ -56,6 +59,9 @@ export const AdminSpace = ({
setIsForceShowCreationKeyPopup={setIsForceShowCreationKeyPopup} setIsForceShowCreationKeyPopup={setIsForceShowCreationKeyPopup}
adminsWithNames={adminsWithNames} adminsWithNames={adminsWithNames}
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
balance={balance}
userInfo={userInfo}
isOwner={isOwner}
/> />
)} )}
</div> </div>

View File

@ -15,6 +15,7 @@ import { base64ToUint8Array } from '../../qdn/encryption/group-encryption';
import { uint8ArrayToObject } from '../../backgroundFunctions/encryption'; import { uint8ArrayToObject } from '../../backgroundFunctions/encryption';
import { formatTimestampForum } from '../../utils/time'; import { formatTimestampForum } from '../../utils/time';
import { Spacer } from '../../common/Spacer'; import { Spacer } from '../../common/Spacer';
import { GroupAvatar } from '../GroupAvatar';
export const getPublishesFromAdminsAdminSpace = async ( export const getPublishesFromAdminsAdminSpace = async (
admins: string[], admins: string[],
@ -53,6 +54,9 @@ export const AdminSpaceInner = ({
selectedGroup, selectedGroup,
adminsWithNames, adminsWithNames,
setIsForceShowCreationKeyPopup, setIsForceShowCreationKeyPopup,
balance,
userInfo,
isOwner,
}) => { }) => {
const [adminGroupSecretKey, setAdminGroupSecretKey] = useState(null); const [adminGroupSecretKey, setAdminGroupSecretKey] = useState(null);
const [isFetchingAdminGroupSecretKey, setIsFetchingAdminGroupSecretKey] = const [isFetchingAdminGroupSecretKey, setIsFetchingAdminGroupSecretKey] =
@ -282,6 +286,32 @@ export const AdminSpaceInner = ({
content encrypted with it. content encrypted with it.
</Typography> </Typography>
</Box> </Box>
<Spacer height="25px" />
{isOwner && (
<Box
sx={{
border: '1px solid gray',
borderRadius: '6px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
maxWidth: '90%',
padding: '10px',
width: '300px',
alignItems: 'center',
}}
>
<Typography>Group Avatar</Typography>
<GroupAvatar
setOpenSnack={setOpenSnackGlobal}
setInfoSnack={setInfoSnackCustom}
myName={userInfo?.name}
balance={balance}
groupId={selectedGroup}
/>
</Box>
)}
</Box> </Box>
); );
}; };

View File

@ -75,6 +75,21 @@ const getBadgeImg = (level) => {
} }
}; };
const UserBadge = React.memo(({ userInfo }) => {
return (
<Tooltip disableFocusListener title={`level ${userInfo ?? 0}`}>
<img
style={{
visibility: userInfo !== undefined ? 'visible' : 'hidden',
width: '30px',
height: 'auto',
}}
src={getBadgeImg(userInfo)}
/>
</Tooltip>
);
});
export const MessageItem = React.memo( export const MessageItem = React.memo(
({ ({
message, message,
@ -210,16 +225,7 @@ export const MessageItem = React.memo(
{message?.senderName?.charAt(0)} {message?.senderName?.charAt(0)}
</Avatar> </Avatar>
</WrapperUserAction> </WrapperUserAction>
<Tooltip disableFocusListener title={`level ${userInfo ?? 0}`}> <UserBadge userInfo={userInfo} />
<img
style={{
visibility: userInfo !== undefined ? 'visible' : 'hidden',
width: '30px',
height: 'auto',
}}
src={getBadgeImg(userInfo)}
/>
</Tooltip>
</Box> </Box>
)} )}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { EditorProvider, useCurrentEditor } from '@tiptap/react'; import { EditorProvider, useCurrentEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
@ -41,295 +41,297 @@ function textMatcher(doc, from) {
return { start, query }; return { start, query };
} }
const MenuBar = ({ const MenuBar = React.memo(
setEditorRef, ({
isChat, setEditorRef,
isDisabledEditorEnter, isChat,
setIsDisabledEditorEnter, isDisabledEditorEnter,
}) => { setIsDisabledEditorEnter,
const { editor } = useCurrentEditor(); }) => {
const fileInputRef = useRef(null); const { editor } = useCurrentEditor();
const theme = useTheme(); const fileInputRef = useRef(null);
const theme = useTheme();
if (!editor) { useEffect(() => {
return null; if (editor && setEditorRef) {
} setEditorRef(editor);
}
}, [editor, setEditorRef]);
useEffect(() => { if (!editor) {
if (editor && setEditorRef) { return null;
setEditorRef(editor);
} }
}, [editor, setEditorRef]);
const handleImageUpload = async (file) => { const handleImageUpload = async (file) => {
let compressedFile; let compressedFile;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
new Compressor(file, { new Compressor(file, {
quality: 0.6, quality: 0.6,
maxWidth: 1200, maxWidth: 1200,
mimeType: 'image/webp', mimeType: 'image/webp',
success(result) { success(result) {
compressedFile = new File([result], 'image.webp', { compressedFile = new File([result], 'image.webp', {
type: 'image/webp', type: 'image/webp',
}); });
resolve(); resolve();
}, },
error(err) { error(err) {
console.error('Image compression error:', err); console.error('Image compression error:', err);
}, },
});
}); });
});
if (compressedFile) { if (compressedFile) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const url = reader.result; const url = reader.result;
editor editor
.chain() .chain()
.focus() .focus()
.setImage({ src: url, style: 'width: auto' }) .setImage({ src: url, style: 'width: auto' })
.run(); .run();
fileInputRef.current.value = ''; fileInputRef.current.value = '';
}; };
reader.readAsDataURL(compressedFile); reader.readAsDataURL(compressedFile);
} }
}; };
const triggerImageUpload = () => { const triggerImageUpload = () => {
fileInputRef.current.click(); // Trigger the file input click fileInputRef.current.click(); // Trigger the file input click
}; };
const handlePaste = (event) => { const handlePaste = (event) => {
const items = event.clipboardData.items; const items = event.clipboardData.items;
for (const item of items) { for (const item of items) {
if (item.type.startsWith('image/')) { if (item.type.startsWith('image/')) {
const file = item.getAsFile(); const file = item.getAsFile();
if (file) { if (file) {
event.preventDefault(); // Prevent the default paste behavior event.preventDefault(); // Prevent the default paste behavior
handleImageUpload(file); // Call the image upload function handleImageUpload(file); // Call the image upload function
}
} }
} }
} };
};
useEffect(() => { useEffect(() => {
if (editor) { if (editor) {
editor.view.dom.addEventListener('paste', handlePaste); editor.view.dom.addEventListener('paste', handlePaste);
return () => { return () => {
editor.view.dom.removeEventListener('paste', handlePaste); editor.view.dom.removeEventListener('paste', handlePaste);
}; };
} }
}, [editor]); }, [editor]);
return ( return (
<div className="control-group"> <div className="control-group">
<div <div
className="button-group" className="button-group"
style={{ style={{
display: 'flex', display: 'flex',
}}
>
<IconButton
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
sx={{
color: editor.isActive('bold')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}} }}
> >
<FormatBoldIcon /> <IconButton
</IconButton> onClick={() => editor.chain().focus().toggleBold().run()}
<IconButton disabled={!editor.can().chain().focus().toggleBold().run()}
onClick={() => editor.chain().focus().toggleItalic().run()} sx={{
disabled={!editor.can().chain().focus().toggleItalic().run()} color: editor.isActive('bold')
sx={{
color: editor.isActive('italic')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatItalicIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
sx={{
color: editor.isActive('strike')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<StrikethroughSIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
sx={{
color: editor.isActive('code')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<CodeIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().unsetAllMarks().run()}
sx={{
color:
editor.isActive('bold') ||
editor.isActive('italic') ||
editor.isActive('strike') ||
editor.isActive('code')
? theme.palette.text.primary ? theme.palette.text.primary
: theme.palette.text.secondary, : theme.palette.text.secondary,
padding: 'revert', padding: 'revert',
}}
>
<FormatClearIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
sx={{
color: editor.isActive('bulletList')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatListBulletedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
sx={{
color: editor.isActive('orderedList')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatListNumberedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
sx={{
color: editor.isActive('codeBlock')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<DeveloperModeIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
sx={{
color: editor.isActive('blockquote')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatQuoteIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
disabled={!editor.can().chain().focus().setHorizontalRule().run()}
sx={{ color: 'gray', padding: 'revert' }}
>
<HorizontalRuleIcon />
</IconButton>
<IconButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
sx={{
color: editor.isActive('heading', { level: 1 })
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatHeadingIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
sx={{ color: 'gray', padding: 'revert' }}
>
<UndoIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
sx={{ color: 'gray' }}
>
<RedoIcon />
</IconButton>
{isChat && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
marginLeft: '5px',
cursor: 'pointer',
}}
onClick={() => {
setIsDisabledEditorEnter(!isDisabledEditorEnter);
}} }}
> >
<Checkbox <FormatBoldIcon />
edge="start" </IconButton>
tabIndex={-1} <IconButton
disableRipple onClick={() => editor.chain().focus().toggleItalic().run()}
checked={isDisabledEditorEnter} disabled={!editor.can().chain().focus().toggleItalic().run()}
sx={{
color: editor.isActive('italic')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatItalicIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
sx={{
color: editor.isActive('strike')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<StrikethroughSIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
sx={{
color: editor.isActive('code')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<CodeIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().unsetAllMarks().run()}
sx={{
color:
editor.isActive('bold') ||
editor.isActive('italic') ||
editor.isActive('strike') ||
editor.isActive('code')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatClearIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
sx={{
color: editor.isActive('bulletList')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatListBulletedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
sx={{
color: editor.isActive('orderedList')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatListNumberedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
sx={{
color: editor.isActive('codeBlock')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<DeveloperModeIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
sx={{
color: editor.isActive('blockquote')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatQuoteIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
disabled={!editor.can().chain().focus().setHorizontalRule().run()}
sx={{ color: 'gray', padding: 'revert' }}
>
<HorizontalRuleIcon />
</IconButton>
<IconButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
sx={{
color: editor.isActive('heading', { level: 1 })
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatHeadingIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
sx={{ color: 'gray', padding: 'revert' }}
>
<UndoIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
sx={{ color: 'gray' }}
>
<RedoIcon />
</IconButton>
{isChat && (
<Box
sx={{ sx={{
'&.Mui-checked': { display: 'flex',
color: theme.palette.text.secondary, alignItems: 'center',
}, marginLeft: '5px',
'& .MuiSvgIcon-root': { cursor: 'pointer',
color: theme.palette.text.secondary,
},
}} }}
/> onClick={() => {
<Typography setIsDisabledEditorEnter(!isDisabledEditorEnter);
sx={{
fontSize: '14px',
color: theme.palette.text.primary,
}} }}
> >
disable enter <Checkbox
</Typography> edge="start"
</Box> tabIndex={-1}
)} disableRipple
{!isChat && ( checked={isDisabledEditorEnter}
<> sx={{
<IconButton '&.Mui-checked': {
onClick={triggerImageUpload} color: theme.palette.text.secondary,
sx={{ },
color: theme.palette.text.secondary, '& .MuiSvgIcon-root': {
padding: 'revert', color: theme.palette.text.secondary,
}} },
> }}
<ImageIcon /> />
</IconButton> <Typography
<input sx={{
type="file" fontSize: '14px',
ref={fileInputRef} color: theme.palette.text.primary,
style={{ display: 'none' }} }}
onChange={(event) => handleImageUpload(event.target.files[0])} >
accept="image/*" disable enter
/> </Typography>
</> </Box>
)} )}
{!isChat && (
<>
<IconButton
onClick={triggerImageUpload}
sx={{
color: theme.palette.text.secondary,
padding: 'revert',
}}
>
<ImageIcon />
</IconButton>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={(event) => handleImageUpload(event.target.files[0])}
accept="image/*"
/>
</>
)}
</div>
</div> </div>
</div> );
); }
}; );
const extensions = [ const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }), Color.configure({ types: [TextStyle.name, ListItem.name] }),
@ -373,10 +375,10 @@ export default ({
? extensions.filter((item) => item?.name !== 'image') ? extensions.filter((item) => item?.name !== 'image')
: extensions; : extensions;
const editorRef = useRef(null); const editorRef = useRef(null);
const setEditorRefFunc = (editorInstance) => { const setEditorRefFunc = useCallback((editorInstance) => {
editorRef.current = editorInstance; editorRef.current = editorInstance;
setEditorRef(editorInstance); setEditorRef(editorInstance);
}; }, []);
// const users = [ // const users = [
// { id: 1, label: 'Alice' }, // { id: 1, label: 'Alice' },

View File

@ -10,6 +10,8 @@ import {
import MailOutlineIcon from '@mui/icons-material/MailOutline'; import MailOutlineIcon from '@mui/icons-material/MailOutline';
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
import { executeEvent } from '../utils/events'; import { executeEvent } from '../utils/events';
import { useRecoilState } from 'recoil';
import { mutedGroupsAtom } from '../atoms/global';
const CustomStyledMenu = styled(Menu)(({ theme }) => ({ const CustomStyledMenu = styled(Menu)(({ theme }) => ({
'& .MuiPaper-root': { '& .MuiPaper-root': {
@ -28,16 +30,12 @@ const CustomStyledMenu = styled(Menu)(({ theme }) => ({
}, },
})); }));
export const ContextMenu = ({ export const ContextMenu = ({ children, groupId, getUserSettings }) => {
children,
groupId,
getUserSettings,
mutedGroups,
}) => {
const [menuPosition, setMenuPosition] = useState(null); const [menuPosition, setMenuPosition] = useState(null);
const longPressTimeout = useRef(null); const longPressTimeout = useRef(null);
const preventClick = useRef(false); // Flag to prevent click after long-press or right-click const preventClick = useRef(false); // Flag to prevent click after long-press or right-click
const theme = useTheme(); const theme = useTheme();
const [mutedGroups] = useRecoilState(mutedGroupsAtom);
const isMuted = useMemo(() => { const isMuted = useMemo(() => {
return mutedGroups.includes(groupId); return mutedGroups.includes(groupId);
}, [mutedGroups, groupId]); }, [mutedGroups, groupId]);

View File

@ -199,6 +199,8 @@ export const AddGroup = ({ address, open, setOpen }) => {
}; };
}, []); }, []);
if (!open) return null;
return ( return (
<Fragment> <Fragment>
<Dialog <Dialog

View File

@ -68,9 +68,14 @@ import { AdminSpace } from '../Chat/AdminSpace';
import { useRecoilState, useSetRecoilState } from 'recoil'; import { useRecoilState, useSetRecoilState } from 'recoil';
import { import {
addressInfoControllerAtom, addressInfoControllerAtom,
groupAnnouncementsAtom,
groupChatTimestampsAtom,
groupsOwnerNamesAtom,
groupsPropertiesAtom, groupsPropertiesAtom,
isOpenBlockedModalAtom, isOpenBlockedModalAtom,
mutedGroupsAtom,
selectedGroupIdAtom, selectedGroupIdAtom,
timestampEnterDataAtom,
} from '../../atoms/global'; } from '../../atoms/global';
import { sortArrayByTimestampAndGroupName } from '../../utils/time'; import { sortArrayByTimestampAndGroupName } from '../../utils/time';
import PersonOffIcon from '@mui/icons-material/PersonOff'; import PersonOffIcon from '@mui/icons-material/PersonOff';
@ -79,6 +84,7 @@ import NoEncryptionGmailerrorredIcon from '@mui/icons-material/NoEncryptionGmail
import { BlockedUsersModal } from './BlockedUsersModal'; import { BlockedUsersModal } from './BlockedUsersModal';
import { WalletsAppWrapper } from './WalletsAppWrapper'; import { WalletsAppWrapper } from './WalletsAppWrapper';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GroupList } from './GroupList';
export const getPublishesFromAdmins = async (admins: string[], groupId) => { export const getPublishesFromAdmins = async (admins: string[], groupId) => {
const queryString = admins.map((name) => `name=${name}`).join('&'); const queryString = admins.map((name) => `name=${name}`).join('&');
@ -116,7 +122,7 @@ interface GroupProps {
balance: number; balance: number;
} }
const timeDifferenceForNotificationChats = 900000; export const timeDifferenceForNotificationChats = 900000;
export const requestQueueMemberNames = new RequestQueueWithPromise(5); export const requestQueueMemberNames = new RequestQueueWithPromise(5);
export const requestQueueAdminMemberNames = new RequestQueueWithPromise(5); export const requestQueueAdminMemberNames = new RequestQueueWithPromise(5);
@ -409,7 +415,9 @@ export const Group = ({
const { setMemberGroups, rootHeight, isRunningPublicNode } = const { setMemberGroups, rootHeight, isRunningPublicNode } =
useContext(MyContext); useContext(MyContext);
const lastGroupNotification = useRef<null | number>(null); const lastGroupNotification = useRef<null | number>(null);
const [timestampEnterData, setTimestampEnterData] = useState({}); const [timestampEnterData, setTimestampEnterData] = useRecoilState(
timestampEnterDataAtom
);
const [chatMode, setChatMode] = useState('groups'); const [chatMode, setChatMode] = useState('groups');
const [newChat, setNewChat] = useState(false); const [newChat, setNewChat] = useState(false);
const [openSnack, setOpenSnack] = React.useState(false); const [openSnack, setOpenSnack] = React.useState(false);
@ -420,7 +428,10 @@ export const Group = ({
const [firstSecretKeyInCreation, setFirstSecretKeyInCreation] = const [firstSecretKeyInCreation, setFirstSecretKeyInCreation] =
React.useState(false); React.useState(false);
const [groupSection, setGroupSection] = React.useState('home'); const [groupSection, setGroupSection] = React.useState('home');
const [groupAnnouncements, setGroupAnnouncements] = React.useState({}); const [groupAnnouncements, setGroupAnnouncements] = useRecoilState(
groupAnnouncementsAtom
);
const [defaultThread, setDefaultThread] = React.useState(null); const [defaultThread, setDefaultThread] = React.useState(null);
const [isOpenDrawer, setIsOpenDrawer] = React.useState(false); const [isOpenDrawer, setIsOpenDrawer] = React.useState(false);
const setIsOpenBlockedUserModal = useSetRecoilState(isOpenBlockedModalAtom); const setIsOpenBlockedUserModal = useSetRecoilState(isOpenBlockedModalAtom);
@ -428,7 +439,7 @@ export const Group = ({
const [hideCommonKeyPopup, setHideCommonKeyPopup] = React.useState(false); const [hideCommonKeyPopup, setHideCommonKeyPopup] = React.useState(false);
const [isLoadingGroupMessage, setIsLoadingGroupMessage] = React.useState(''); const [isLoadingGroupMessage, setIsLoadingGroupMessage] = React.useState('');
const [drawerMode, setDrawerMode] = React.useState('groups'); const [drawerMode, setDrawerMode] = React.useState('groups');
const [mutedGroups, setMutedGroups] = useState([]); const setMutedGroups = useSetRecoilState(mutedGroupsAtom);
const [mobileViewMode, setMobileViewMode] = useState('home'); const [mobileViewMode, setMobileViewMode] = useState('home');
const [mobileViewModeKeepOpen, setMobileViewModeKeepOpen] = useState(''); const [mobileViewModeKeepOpen, setMobileViewModeKeepOpen] = useState('');
const isFocusedRef = useRef(true); const isFocusedRef = useRef(true);
@ -442,17 +453,23 @@ export const Group = ({
const settimeoutForRefetchSecretKey = useRef(null); const settimeoutForRefetchSecretKey = useRef(null);
const { clearStatesMessageQueueProvider } = useMessageQueue(); const { clearStatesMessageQueueProvider } = useMessageQueue();
const initiatedGetMembers = useRef(false); const initiatedGetMembers = useRef(false);
const [groupChatTimestamps, setGroupChatTimestamps] = React.useState({}); const [groupChatTimestamps, setGroupChatTimestamps] = useRecoilState(
groupChatTimestampsAtom
);
const [appsMode, setAppsMode] = useState('home'); const [appsMode, setAppsMode] = useState('home');
const [appsModeDev, setAppsModeDev] = useState('home'); const [appsModeDev, setAppsModeDev] = useState('home');
const [isOpenSideViewDirects, setIsOpenSideViewDirects] = useState(false); const [isOpenSideViewDirects, setIsOpenSideViewDirects] = useState(false);
const [isOpenSideViewGroups, setIsOpenSideViewGroups] = useState(false); const [isOpenSideViewGroups, setIsOpenSideViewGroups] = useState(false);
const [isForceShowCreationKeyPopup, setIsForceShowCreationKeyPopup] = const [isForceShowCreationKeyPopup, setIsForceShowCreationKeyPopup] =
useState(false); useState(false);
const groupsOwnerNamesRef = useRef({});
const { t } = useTranslation(['core', 'group']); const { t } = useTranslation(['core', 'group']);
const [groupsProperties, setGroupsProperties] = const [groupsProperties, setGroupsProperties] =
useRecoilState(groupsPropertiesAtom); useRecoilState(groupsPropertiesAtom);
const [groupsOwnerNames, setGroupsOwnerNames] =
useRecoilState(groupsOwnerNamesAtom);
const setUserInfoForLevels = useSetRecoilState(addressInfoControllerAtom); const setUserInfoForLevels = useSetRecoilState(addressInfoControllerAtom);
const isPrivate = useMemo(() => { const isPrivate = useMemo(() => {
@ -495,7 +512,7 @@ export const Group = ({
selectedDirectRef.current = selectedDirect; selectedDirectRef.current = selectedDirect;
}, [selectedDirect]); }, [selectedDirect]);
const getUserSettings = async () => { const getUserSettings = useCallback(async () => {
try { try {
return new Promise((res, rej) => { return new Promise((res, rej) => {
window window
@ -517,13 +534,13 @@ export const Group = ({
} catch (error) { } catch (error) {
console.log('error', error); console.log('error', error);
} }
}; }, [setMutedGroups]);
useEffect(() => { useEffect(() => {
getUserSettings(); getUserSettings();
}, []); }, [getUserSettings]);
const getTimestampEnterChat = async () => { const getTimestampEnterChat = useCallback(async () => {
try { try {
return new Promise((res, rej) => { return new Promise((res, rej) => {
window window
@ -543,7 +560,7 @@ export const Group = ({
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
}; }, []);
const refreshHomeDataFunc = () => { const refreshHomeDataFunc = () => {
setGroupSection('default'); setGroupSection('default');
@ -645,115 +662,139 @@ export const Group = ({
return hasUnread; return hasUnread;
}, [groupAnnouncements, groups]); }, [groupAnnouncements, groups]);
const getSecretKey = async ( const getSecretKey = useCallback(
loadingGroupParam?: boolean, async (loadingGroupParam?: boolean, secretKeyToPublish?: boolean) => {
secretKeyToPublish?: boolean try {
) => { setIsLoadingGroupMessage('Locating encryption keys');
try { pauseAllQueues();
setIsLoadingGroupMessage('Locating encryption keys');
pauseAllQueues();
let dataFromStorage;
let publishFromStorage;
let adminsFromStorage;
if (
secretKeyToPublish &&
secretKey &&
lastFetchedSecretKey.current &&
Date.now() - lastFetchedSecretKey.current < 600000
)
return secretKey;
if (loadingGroupParam) {
setIsLoadingGroup(true);
}
if (selectedGroup?.groupId !== selectedGroupRef.current.groupId) {
if (settimeoutForRefetchSecretKey.current) {
clearTimeout(settimeoutForRefetchSecretKey.current);
}
return;
}
const prevGroupId = selectedGroupRef.current.groupId;
// const validApi = await findUsableApi();
const { names, addresses, both } =
adminsFromStorage || (await getGroupAdmins(selectedGroup?.groupId));
setAdmins(addresses);
setAdminsWithNames(both);
if (!names.length) {
throw new Error('Network error');
}
const publish =
publishFromStorage ||
(await getPublishesFromAdmins(names, selectedGroup?.groupId));
if (prevGroupId !== selectedGroupRef.current.groupId) { let dataFromStorage;
if (settimeoutForRefetchSecretKey.current) { let publishFromStorage;
clearTimeout(settimeoutForRefetchSecretKey.current); let adminsFromStorage;
if (
secretKeyToPublish &&
secretKey &&
lastFetchedSecretKey.current &&
Date.now() - lastFetchedSecretKey.current < 600000
) {
return secretKey;
} }
return;
} if (loadingGroupParam) {
if (publish === false) { setIsLoadingGroup(true);
setTriedToFetchSecretKey(true); }
settimeoutForRefetchSecretKey.current = setTimeout(() => {
getSecretKey(); if (selectedGroup?.groupId !== selectedGroupRef.current.groupId) {
}, 120000); if (settimeoutForRefetchSecretKey.current) {
return false; clearTimeout(settimeoutForRefetchSecretKey.current);
} }
setSecretKeyPublishDate(publish?.updated || publish?.created); return;
let data; }
if (dataFromStorage) {
data = dataFromStorage; const prevGroupId = selectedGroupRef.current.groupId;
} else {
// const shouldRebuild = !secretKeyPublishDate || (publish?.update && publish?.updated > secretKeyPublishDate) const { names, addresses, both } =
setIsLoadingGroupMessage('Downloading encryption keys'); adminsFromStorage || (await getGroupAdmins(selectedGroup?.groupId));
const res = await fetch( setAdmins(addresses);
`${getBaseApiReact()}/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${ setAdminsWithNames(both);
publish.identifier
}?encoding=base64&rebuild=true` if (!names.length) throw new Error('Network error');
);
data = await res.text(); const publish =
} publishFromStorage ||
const decryptedKey: any = await decryptResource(data); (await getPublishesFromAdmins(names, selectedGroup?.groupId));
const dataint8Array = base64ToUint8Array(decryptedKey.data);
const decryptedKeyToObject = uint8ArrayToObject(dataint8Array); if (prevGroupId !== selectedGroupRef.current.groupId) {
if (!validateSecretKey(decryptedKeyToObject)) if (settimeoutForRefetchSecretKey.current) {
throw new Error('SecretKey is not valid'); clearTimeout(settimeoutForRefetchSecretKey.current);
setSecretKeyDetails(publish); }
setSecretKey(decryptedKeyToObject); return;
lastFetchedSecretKey.current = Date.now(); }
setMemberCountFromSecretKeyData(decryptedKey.count);
window if (publish === false) {
.sendMessage('setGroupData', { setTriedToFetchSecretKey(true);
groupId: selectedGroup?.groupId, settimeoutForRefetchSecretKey.current = setTimeout(() => {
secretKeyData: data, getSecretKey();
secretKeyResource: publish, }, 120000);
admins: { names, addresses, both }, return false;
}) }
.catch((error) => {
console.error( setSecretKeyPublishDate(publish?.updated || publish?.created);
'Failed to set group data:',
error.message || 'An error occurred' let data;
if (dataFromStorage) {
data = dataFromStorage;
} else {
setIsLoadingGroupMessage('Downloading encryption keys');
const res = await fetch(
`${getBaseApiReact()}/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${publish.identifier}?encoding=base64&rebuild=true`
); );
}); data = await res.text();
}
if (decryptedKeyToObject) { const decryptedKey: any = await decryptResource(data);
setTriedToFetchSecretKey(true); const dataint8Array = base64ToUint8Array(decryptedKey.data);
setFirstSecretKeyInCreation(false); const decryptedKeyToObject = uint8ArrayToObject(dataint8Array);
return decryptedKeyToObject;
} else { if (!validateSecretKey(decryptedKeyToObject)) {
setTriedToFetchSecretKey(true); throw new Error('SecretKey is not valid');
}
setSecretKeyDetails(publish);
setSecretKey(decryptedKeyToObject);
lastFetchedSecretKey.current = Date.now();
setMemberCountFromSecretKeyData(decryptedKey.count);
window
.sendMessage('setGroupData', {
groupId: selectedGroup?.groupId,
secretKeyData: data,
secretKeyResource: publish,
admins: { names, addresses, both },
})
.catch((error) => {
console.error(
'Failed to set group data:',
error.message || 'An error occurred'
);
});
if (decryptedKeyToObject) {
setTriedToFetchSecretKey(true);
setFirstSecretKeyInCreation(false);
return decryptedKeyToObject;
} else {
setTriedToFetchSecretKey(true);
}
} catch (error) {
if (error === 'Unable to decrypt data') {
setTriedToFetchSecretKey(true);
settimeoutForRefetchSecretKey.current = setTimeout(() => {
getSecretKey();
}, 120000);
}
} finally {
setIsLoadingGroup(false);
setIsLoadingGroupMessage('');
resumeAllQueues();
} }
} catch (error) { },
if (error === 'Unable to decrypt data') { [
setTriedToFetchSecretKey(true); secretKey,
settimeoutForRefetchSecretKey.current = setTimeout(() => { selectedGroup?.groupId,
getSecretKey(); setIsLoadingGroup,
}, 120000); setIsLoadingGroupMessage,
} setSecretKey,
} finally { setSecretKeyDetails,
setIsLoadingGroup(false); setTriedToFetchSecretKey,
setIsLoadingGroupMessage(''); setFirstSecretKeyInCreation,
resumeAllQueues(); setMemberCountFromSecretKeyData,
} setAdmins,
}; setAdminsWithNames,
setSecretKeyPublishDate,
]
);
const getAdminsForPublic = async (selectedGroup) => { const getAdminsForPublic = async (selectedGroup) => {
try { try {
@ -826,6 +867,24 @@ export const Group = ({
} }
}; };
const getOwnerNameForGroup = async (owner: string, groupId: string) => {
try {
if (!owner) return;
if (groupsOwnerNamesRef.current[groupId]) return;
const name = await requestQueueMemberNames.enqueue(() => {
return getNameInfo(owner);
});
if (name) {
groupsOwnerNamesRef.current[groupId] = name;
setGroupsOwnerNames((prev) => {
return { ...prev, [groupId]: name };
});
}
} catch (error) {
console.error(error);
}
};
const getGroupsProperties = useCallback(async (address) => { const getGroupsProperties = useCallback(async (address) => {
try { try {
const url = `${getBaseApiReact()}/groups/member/${address}`; const url = `${getBaseApiReact()}/groups/member/${address}`;
@ -837,6 +896,9 @@ export const Group = ({
return result; return result;
}, {}); }, {});
setGroupsProperties(transformToObject); setGroupsProperties(transformToObject);
Object.keys(transformToObject).forEach((key) => {
getOwnerNameForGroup(transformToObject[key]?.owner || '', key);
});
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@ -1468,9 +1530,9 @@ export const Group = ({
}; };
}, [groups, selectedGroup]); }, [groups, selectedGroup]);
const handleSecretKeyCreationInProgress = () => { const handleSecretKeyCreationInProgress = useCallback(() => {
setFirstSecretKeyInCreation(true); setFirstSecretKeyInCreation(true);
}; }, []);
const goToHome = async () => { const goToHome = async () => {
setDesktopViewMode('home'); setDesktopViewMode('home');
@ -1781,332 +1843,34 @@ export const Group = ({
); );
}; };
const renderGroups = () => { const selectGroupFunc = useCallback((group) => {
return ( setMobileViewMode('group');
<div setDesktopSideView('groups');
style={{ initiatedGetMembers.current = false;
display: 'flex', clearAllQueues();
width: '380px', setSelectedDirect(null);
flexDirection: 'column', setTriedToFetchSecretKey(false);
alignItems: 'flex-start', setNewChat(false);
height: '100%', setSelectedGroup(null);
background: theme.palette.background.surface, setUserInfoForLevels({});
borderRadius: '0px 15px 15px 0px', setSecretKey(null);
padding: '0px 2px', lastFetchedSecretKey.current = null;
}} setSecretKeyPublishDate(null);
> setAdmins([]);
<Box setSecretKeyDetails(null);
sx={{ setAdminsWithNames([]);
width: '100%', setGroupOwner(null);
alignItems: 'center', setMembers([]);
justifyContent: 'center', setMemberCountFromSecretKeyData(null);
display: 'flex', setHideCommonKeyPopup(false);
gap: '10px', setFirstSecretKeyInCreation(false);
}} setGroupSection('chat');
> setIsOpenDrawer(false);
<ButtonBase setIsForceShowCreationKeyPopup(false);
onClick={() => { setTimeout(() => {
setDesktopSideView('groups'); setSelectedGroup(group);
}} }, 200);
> }, []);
<IconWrapper
color={
groupChatHasUnread || groupsAnnHasUnread
? theme.palette.other.unread
: desktopSideView === 'groups'
? theme.palette.text.primary
: theme.palette.text.secondary
}
label="Groups"
selected={desktopSideView === 'groups'}
customWidth="75px"
>
<HubsIcon
height={24}
color={
groupChatHasUnread || groupsAnnHasUnread
? theme.palette.other.unread
: desktopSideView === 'groups'
? theme.palette.text.primary
: theme.palette.text.secondary
}
/>
</IconWrapper>
</ButtonBase>
<ButtonBase
onClick={() => {
setDesktopSideView('directs');
}}
>
<IconWrapper
customWidth="75px"
color={
directChatHasUnread
? theme.palette.other.unread
: desktopSideView === 'directs'
? theme.palette.text.primary
: theme.palette.text.secondary
}
label="Messaging"
selected={desktopSideView === 'directs'}
>
<MessagingIcon
height={24}
color={
directChatHasUnread
? theme.palette.other.unread
: desktopSideView === 'directs'
? theme.palette.text.primary
: theme.palette.text.secondary
}
/>
</IconWrapper>
</ButtonBase>
</Box>
<div
style={{
alignItems: 'flex-start',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
left: chatMode === 'directs' && '-1000px',
overflowY: 'auto',
position: chatMode === 'directs' && 'fixed',
visibility: chatMode === 'directs' && 'hidden',
width: '100%',
}}
>
{groups.map((group: any) => (
<List
sx={{
width: '100%',
}}
className="group-list"
dense={true}
>
<ListItem
onClick={() => {
setMobileViewMode('group');
setDesktopSideView('groups');
initiatedGetMembers.current = false;
clearAllQueues();
setSelectedDirect(null);
setTriedToFetchSecretKey(false);
setNewChat(false);
setSelectedGroup(null);
setUserInfoForLevels({});
setSecretKey(null);
lastFetchedSecretKey.current = null;
setSecretKeyPublishDate(null);
setAdmins([]);
setSecretKeyDetails(null);
setAdminsWithNames([]);
setGroupOwner(null);
setMembers([]);
setMemberCountFromSecretKeyData(null);
setHideCommonKeyPopup(false);
setFirstSecretKeyInCreation(false);
setGroupSection('chat');
setIsOpenDrawer(false);
setIsForceShowCreationKeyPopup(false);
setTimeout(() => {
setSelectedGroup(group);
}, 200);
}}
sx={{
display: 'flex',
background:
group?.groupId === selectedGroup?.groupId &&
theme.palette.action.selected,
borderRadius: '2px',
cursor: 'pointer',
flexDirection: 'column',
padding: '2px',
width: '100%',
'&:hover': {
backgroundColor: 'action.hover', // background on hover
},
}}
>
<ContextMenu
mutedGroups={mutedGroups}
getUserSettings={getUserSettings}
groupId={group.groupId}
>
<Box
sx={{
alignItems: 'center',
display: 'flex',
width: '100%',
}}
>
<ListItemAvatar>
{groupsProperties[group?.groupId]?.isOpen === false ? (
<Box
sx={{
alignItems: 'center',
background: theme.palette.background.default,
borderRadius: '50%',
display: 'flex',
height: '40px',
justifyContent: 'center',
width: '40px',
}}
>
{/* <Avatar src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${
app?.name
}/qortal_avatar?async=true`} /> */}
<LockIcon
sx={{
color: theme.palette.other.positive,
}}
/>
</Box>
) : (
<Box
sx={{
alignItems: 'center',
background: theme.palette.background.default,
borderRadius: '50%',
display: 'flex',
height: '40px',
justifyContent: 'center',
width: '40px',
}}
>
<NoEncryptionGmailerrorredIcon
sx={{
color: theme.palette.other.danger,
}}
/>
</Box>
)}
</ListItemAvatar>
<ListItemText
primary={
group.groupId === '0' ? 'General' : group.groupName
}
secondary={
!group?.timestamp
? 'no messages'
: `last message: ${formatEmailDate(group?.timestamp)}`
}
primaryTypographyProps={{
style: {
color:
group?.groupId === selectedGroup?.groupId &&
theme.palette.text.primary,
fontSize: '16px',
},
}} // Change the color of the primary text
secondaryTypographyProps={{
style: {
color:
group?.groupId === selectedGroup?.groupId &&
theme.palette.text.primary,
fontSize: '12px',
},
}}
sx={{
width: '150px',
fontFamily: 'Inter',
fontSize: '16px',
}}
/>
{groupAnnouncements[group?.groupId] &&
!groupAnnouncements[group?.groupId]?.seentimestamp && (
<CampaignIcon
sx={{
color: theme.palette.other.unread,
marginRight: '5px',
}}
/>
)}
{group?.data &&
groupChatTimestamps[group?.groupId] &&
group?.sender !== myAddress &&
group?.timestamp &&
((!timestampEnterData[group?.groupId] &&
Date.now() - group?.timestamp <
timeDifferenceForNotificationChats) ||
timestampEnterData[group?.groupId] <
group?.timestamp) && (
<MarkChatUnreadIcon
sx={{
color: theme.palette.other.unread,
}}
/>
)}
</Box>
</ContextMenu>
</ListItem>
</List>
))}
</div>
<div
style={{
display: 'flex',
gap: '10px',
justifyContent: 'center',
padding: '10px',
width: '100%',
}}
>
{chatMode === 'groups' && (
<>
<CustomButton
onClick={() => {
setOpenAddGroup(true);
}}
>
<AddCircleOutlineIcon
sx={{
color: theme.palette.text.primary,
}}
/>
Group
</CustomButton>
{!isRunningPublicNode && (
<CustomButton
onClick={() => {
setIsOpenBlockedUserModal(true);
}}
sx={{
minWidth: 'unset',
padding: '10px',
}}
>
<PersonOffIcon
sx={{
color: theme.palette.text.primary,
}}
/>
</CustomButton>
)}
</>
)}
{chatMode === 'directs' && (
<CustomButton
onClick={() => {
setNewChat(true);
setSelectedDirect(null);
setIsOpenDrawer(false);
}}
>
<CreateIcon
sx={{
color: theme.palette.text.primary,
}}
/>
New Chat
</CustomButton>
)}
</div>
</div>
);
};
return ( return (
<> <>
@ -2151,9 +1915,24 @@ export const Group = ({
/> />
)} )}
{desktopViewMode === 'chat' && {desktopViewMode === 'chat' && desktopSideView !== 'directs' && (
desktopSideView !== 'directs' && <GroupList
renderGroups()} selectGroupFunc={selectGroupFunc}
setDesktopSideView={setDesktopSideView}
groupChatHasUnread={groupChatHasUnread}
groupsAnnHasUnread={groupsAnnHasUnread}
desktopSideView={desktopSideView}
directChatHasUnread={directChatHasUnread}
chatMode={chatMode}
groups={groups}
selectedGroup={selectedGroup}
getUserSettings={getUserSettings}
setOpenAddGroup={setOpenAddGroup}
isRunningPublicNode={isRunningPublicNode}
setIsOpenBlockedUserModal={setIsOpenBlockedUserModal}
myAddress={myAddress}
/>
)}
{desktopViewMode === 'chat' && {desktopViewMode === 'chat' &&
desktopSideView === 'directs' && desktopSideView === 'directs' &&
@ -2293,7 +2072,7 @@ export const Group = ({
isPrivate={isPrivate} isPrivate={isPrivate}
setSecretKey={setSecretKey} setSecretKey={setSecretKey}
handleNewEncryptionNotification={setNewEncryptionNotification} handleNewEncryptionNotification={setNewEncryptionNotification}
hide={groupSection !== 'chat' || selectedDirect || newChat} hide={groupSection !== 'chat' || !!selectedDirect || newChat}
hideView={!(desktopViewMode === 'chat' && selectedGroup)} hideView={!(desktopViewMode === 'chat' && selectedGroup)}
handleSecretKeyCreationInProgress={ handleSecretKeyCreationInProgress={
handleSecretKeyCreationInProgress handleSecretKeyCreationInProgress
@ -2431,10 +2210,12 @@ export const Group = ({
} }
adminsWithNames={adminsWithNames} adminsWithNames={adminsWithNames}
selectedGroup={selectedGroup?.groupId} selectedGroup={selectedGroup?.groupId}
isOwner={groupOwner?.owner === myAddress}
myAddress={myAddress} myAddress={myAddress}
userInfo={userInfo} userInfo={userInfo}
hide={groupSection !== 'adminSpace'} hide={groupSection !== 'adminSpace'}
isAdmin={admins.includes(myAddress)} isAdmin={admins.includes(myAddress)}
balance={balance}
/> />
)} )}
</> </>

View File

@ -0,0 +1,348 @@
import {
Avatar,
Box,
ButtonBase,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme,
} from '@mui/material';
import React, { useCallback } from 'react';
import { IconWrapper } from '../Desktop/DesktopFooter';
import { HubsIcon } from '../../assets/Icons/HubsIcon';
import { MessagingIcon } from '../../assets/Icons/MessagingIcon';
import { ContextMenu } from '../ContextMenu';
import { getBaseApiReact } from '../../App';
import { formatEmailDate } from './QMailMessages';
import CampaignIcon from '@mui/icons-material/Campaign';
import MarkChatUnreadIcon from '@mui/icons-material/MarkChatUnread';
import LockIcon from '@mui/icons-material/Lock';
import { CustomButton } from '../../styles/App-styles';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import PersonOffIcon from '@mui/icons-material/PersonOff';
import {
groupAnnouncementSelector,
groupChatTimestampSelector,
groupPropertySelector,
groupsOwnerNamesSelector,
timestampEnterDataSelector,
} from '../../atoms/global';
import { useRecoilValue } from 'recoil';
import { timeDifferenceForNotificationChats } from './Group';
export const GroupList = ({
selectGroupFunc,
setDesktopSideView,
groupChatHasUnread,
groupsAnnHasUnread,
desktopSideView,
directChatHasUnread,
chatMode,
groups,
selectedGroup,
getUserSettings,
setOpenAddGroup,
isRunningPublicNode,
setIsOpenBlockedUserModal,
myAddress,
}) => {
const theme = useTheme();
return (
<div
style={{
display: 'flex',
width: '380px',
flexDirection: 'column',
alignItems: 'flex-start',
height: '100%',
background: theme.palette.background.surface,
borderRadius: '0px 15px 15px 0px',
padding: '0px 2px',
}}
>
<Box
sx={{
width: '100%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '10px',
}}
>
<ButtonBase
onClick={() => {
setDesktopSideView('groups');
}}
>
<IconWrapper
color={
groupChatHasUnread || groupsAnnHasUnread
? theme.palette.other.unread
: desktopSideView === 'groups'
? theme.palette.text.primary
: theme.palette.text.secondary
}
label="Groups"
selected={desktopSideView === 'groups'}
customWidth="75px"
>
<HubsIcon
height={24}
color={
groupChatHasUnread || groupsAnnHasUnread
? theme.palette.other.unread
: desktopSideView === 'groups'
? theme.palette.text.primary
: theme.palette.text.secondary
}
/>
</IconWrapper>
</ButtonBase>
<ButtonBase
onClick={() => {
setDesktopSideView('directs');
}}
>
<IconWrapper
customWidth="75px"
color={
directChatHasUnread
? theme.palette.other.unread
: desktopSideView === 'directs'
? theme.palette.text.primary
: theme.palette.text.secondary
}
label="Messaging"
selected={desktopSideView === 'directs'}
>
<MessagingIcon
height={24}
color={
directChatHasUnread
? theme.palette.other.unread
: desktopSideView === 'directs'
? theme.palette.text.primary
: theme.palette.text.secondary
}
/>
</IconWrapper>
</ButtonBase>
</Box>
<div
style={{
alignItems: 'flex-start',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
left: chatMode === 'directs' && '-1000px',
overflowY: 'auto',
position: chatMode === 'directs' && 'fixed',
visibility: chatMode === 'directs' && 'hidden',
width: '100%',
}}
>
<List
sx={{
width: '100%',
}}
className="group-list"
dense={false}
>
{groups.map((group: any) => (
<GroupItem
selectGroupFunc={selectGroupFunc}
key={group.groupId}
group={group}
selectedGroup={selectedGroup}
getUserSettings={getUserSettings}
myAddress={myAddress}
/>
))}
</List>
</div>
<div
style={{
display: 'flex',
gap: '10px',
justifyContent: 'center',
padding: '10px',
width: '100%',
}}
>
<>
<CustomButton
onClick={() => {
setOpenAddGroup(true);
}}
>
<AddCircleOutlineIcon
sx={{
color: theme.palette.text.primary,
}}
/>
Group
</CustomButton>
{!isRunningPublicNode && (
<CustomButton
onClick={() => {
setIsOpenBlockedUserModal(true);
}}
sx={{
minWidth: 'unset',
padding: '10px',
}}
>
<PersonOffIcon
sx={{
color: theme.palette.text.primary,
}}
/>
</CustomButton>
)}
</>
</div>
</div>
);
};
const GroupItem = React.memo(
({ selectGroupFunc, group, selectedGroup, getUserSettings, myAddress }) => {
const theme = useTheme();
const ownerName = useRecoilValue(groupsOwnerNamesSelector(group?.groupId));
const announcement = useRecoilValue(
groupAnnouncementSelector(group?.groupId)
);
const groupProperty = useRecoilValue(groupPropertySelector(group?.groupId));
const groupChatTimestamp = useRecoilValue(
groupChatTimestampSelector(group?.groupId)
);
const timestampEnterData = useRecoilValue(
timestampEnterDataSelector(group?.groupId)
);
const selectGroupHandler = useCallback(() => {
selectGroupFunc(group);
}, [group, selectGroupFunc]);
return (
<ListItem
onClick={selectGroupHandler}
sx={{
display: 'flex',
background:
group?.groupId === selectedGroup?.groupId &&
theme.palette.action.selected,
borderRadius: '2px',
cursor: 'pointer',
flexDirection: 'column',
padding: '10px',
width: '100%',
'&:hover': {
backgroundColor: 'action.hover', // background on hover
},
}}
>
<ContextMenu getUserSettings={getUserSettings} groupId={group.groupId}>
<Box
sx={{
alignItems: 'center',
display: 'flex',
width: '100%',
}}
>
<ListItemAvatar>
{ownerName ? (
<Avatar
alt={group?.groupName?.charAt(0)}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${
ownerName
}/qortal_group_avatar_${group?.groupId}?async=true`}
>
{group?.groupName?.charAt(0).toUpperCase()}
</Avatar>
) : (
<Avatar alt={group?.groupName?.charAt(0)}>
{' '}
{group?.groupName?.charAt(0).toUpperCase() || 'G'}
</Avatar>
)}
</ListItemAvatar>
<ListItemText
primary={group.groupId === '0' ? 'General' : group.groupName}
secondary={
!group?.timestamp
? 'no messages'
: `last message: ${formatEmailDate(group?.timestamp)}`
}
primaryTypographyProps={{
style: {
color:
group?.groupId === selectedGroup?.groupId &&
theme.palette.text.primary,
fontSize: '16px',
},
}} // Change the color of the primary text
secondaryTypographyProps={{
style: {
color:
group?.groupId === selectedGroup?.groupId &&
theme.palette.text.primary,
fontSize: '12px',
},
}}
sx={{
width: '150px',
fontFamily: 'Inter',
fontSize: '16px',
}}
/>
{announcement && !announcement?.seentimestamp && (
<CampaignIcon
sx={{
color: theme.palette.other.unread,
marginRight: '5px',
marginBottom: 'auto',
}}
/>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '5px',
justifyContent: 'flex-start',
height: '100%',
marginBottom: 'auto',
}}
>
{group?.data &&
groupChatTimestamp &&
group?.sender !== myAddress &&
group?.timestamp &&
((!timestampEnterData &&
Date.now() - group?.timestamp <
timeDifferenceForNotificationChats) ||
timestampEnterData < group?.timestamp) && (
<MarkChatUnreadIcon
sx={{
color: theme.palette.other.unread,
}}
/>
)}
{groupProperty?.isOpen === false && (
<LockIcon
sx={{
color: theme.palette.other.positive,
marginBottom: 'auto',
}}
/>
)}
</Box>
</Box>
</ContextMenu>
</ListItem>
);
}
);

View File

@ -49,7 +49,7 @@ import ErrorBoundary from '../../common/ErrorBoundary';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { getFee } from '../../background'; import { getFee } from '../../background';
export const requestQueuePromos = new RequestQueueWithPromise(20); export const requestQueuePromos = new RequestQueueWithPromise(3);
export function utf8ToBase64(inputString: string): string { export function utf8ToBase64(inputString: string): string {
// Encode the string as UTF-8 // Encode the string as UTF-8

View File

@ -0,0 +1,293 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import Logo2 from '../assets/svgs/Logo2.svg';
import { MyContext, getArbitraryEndpointReact, getBaseApiReact } from '../App';
import {
Avatar,
Box,
Button,
ButtonBase,
Popover,
Typography,
useTheme,
} from '@mui/material';
import { Spacer } from '../common/Spacer';
import ImageUploader from '../common/ImageUploader';
import { getFee } from '../background';
import { fileToBase64 } from '../utils/fileReading';
import { LoadingButton } from '@mui/lab';
import ErrorIcon from '@mui/icons-material/Error';
export const GroupAvatar = ({
myName,
balance,
setOpenSnack,
setInfoSnack,
groupId,
}) => {
const [hasAvatar, setHasAvatar] = useState(false);
const [avatarFile, setAvatarFile] = useState(null);
const [tempAvatar, setTempAvatar] = useState(null);
const { show } = useContext(MyContext);
const [anchorEl, setAnchorEl] = useState(null);
const [isLoading, setIsLoading] = useState(false);
// Handle child element click to open Popover
const handleChildClick = (event) => {
event.stopPropagation(); // Prevent parent onClick from firing
setAnchorEl(event.currentTarget);
};
// Handle closing the Popover
const handleClose = () => {
setAnchorEl(null);
};
// Determine if the popover is open
const open = Boolean(anchorEl);
const id = open ? 'avatar-img' : undefined;
const checkIfAvatarExists = useCallback(async (name, groupId) => {
try {
const identifier = `qortal_group_avatar_${groupId}`;
const url = `${getBaseApiReact()}${getArbitraryEndpointReact()}?mode=ALL&service=THUMBNAIL&identifier=${identifier}&limit=1&name=${name}&includemetadata=false&prefix=true`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const responseData = await response.json();
if (responseData?.length > 0) {
setHasAvatar(true);
}
} catch (error) {
console.log(error);
}
}, []);
useEffect(() => {
if (!myName || !groupId) return;
checkIfAvatarExists(myName, groupId);
}, [myName, groupId, checkIfAvatarExists]);
const publishAvatar = async () => {
try {
if (!groupId) return;
const fee = await getFee('ARBITRARY');
if (+balance < +fee.fee)
throw new Error(`Publishing an Avatar requires ${fee.fee}`);
await show({
message: 'Would you like to publish an avatar?',
publishFee: fee.fee + ' QORT',
});
setIsLoading(true);
const avatarBase64 = await fileToBase64(avatarFile);
await new Promise((res, rej) => {
window
.sendMessage('publishOnQDN', {
data: avatarBase64,
identifier: `qortal_group_avatar_${groupId}`,
service: 'THUMBNAIL',
})
.then((response) => {
if (!response?.error) {
res(response);
return;
}
rej(response.error);
})
.catch((error) => {
rej(error.message || 'An error occurred');
});
});
setAvatarFile(null);
setTempAvatar(`data:image/webp;base64,${avatarBase64}`);
handleClose();
} catch (error) {
if (error?.message) {
setOpenSnack(true);
setInfoSnack({
type: 'error',
message: error?.message,
});
}
} finally {
setIsLoading(false);
}
};
if (tempAvatar) {
return (
<>
<Avatar
sx={{
height: '138px',
width: '138px',
}}
src={tempAvatar}
alt={myName}
>
{myName?.charAt(0)}
</Avatar>
<ButtonBase onClick={handleChildClick}>
<Typography
sx={{
fontSize: '16px',
opacity: 0.5,
}}
>
change avatar
</Typography>
</ButtonBase>
<PopoverComp
myName={myName}
avatarFile={avatarFile}
setAvatarFile={setAvatarFile}
id={id}
open={open}
anchorEl={anchorEl}
handleClose={handleClose}
publishAvatar={publishAvatar}
isLoading={isLoading}
/>
</>
);
}
if (hasAvatar) {
return (
<>
<Avatar
sx={{
height: '138px',
width: '138px',
}}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${myName}/qortal_group_avatar_${groupId}?async=true`}
alt={myName}
>
{myName?.charAt(0)}
</Avatar>
<ButtonBase onClick={handleChildClick}>
<Typography
sx={{
fontSize: '16px',
opacity: 0.5,
}}
>
change avatar
</Typography>
</ButtonBase>
<PopoverComp
myName={myName}
avatarFile={avatarFile}
setAvatarFile={setAvatarFile}
id={id}
open={open}
anchorEl={anchorEl}
handleClose={handleClose}
publishAvatar={publishAvatar}
isLoading={isLoading}
/>
</>
);
}
return (
<>
<img src={Logo2} />
<ButtonBase onClick={handleChildClick}>
<Typography
sx={{
fontSize: '16px',
opacity: 0.5,
}}
>
set avatar
</Typography>
</ButtonBase>
<PopoverComp
myName={myName}
avatarFile={avatarFile}
setAvatarFile={setAvatarFile}
id={id}
open={open}
anchorEl={anchorEl}
handleClose={handleClose}
publishAvatar={publishAvatar}
isLoading={isLoading}
/>
</>
);
};
const PopoverComp = ({
avatarFile,
setAvatarFile,
id,
open,
anchorEl,
handleClose,
publishAvatar,
isLoading,
myName,
}) => {
const theme = useTheme();
return (
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose} // Close popover on click outside
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
>
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography
sx={{
fontSize: '12px',
}}
>
(500 KB max. for GIFS){' '}
</Typography>
<ImageUploader onPick={(file) => setAvatarFile(file)}>
<Button variant="contained">Choose Image</Button>
</ImageUploader>
{avatarFile?.name}
<Spacer height="25px" />
{!myName && (
<Box
sx={{
display: 'flex',
gap: '5px',
alignItems: 'center',
}}
>
<ErrorIcon
sx={{
color: theme.palette.text.primary,
}}
/>
<Typography>
A registered name is required to set an avatar
</Typography>
</Box>
)}
<Spacer height="25px" />
<LoadingButton
loading={isLoading}
disabled={!avatarFile || !myName}
onClick={publishAvatar}
variant="contained"
>
Publish avatar
</LoadingButton>
</Box>
</Popover>
);
};