added search, mentions, keep place , scroll dn

This commit is contained in:
2024-11-15 13:38:49 +02:00
parent 8562b0c5ce
commit 54bb22e24e
21 changed files with 1593 additions and 97 deletions

View File

@@ -575,6 +575,7 @@ function App() {
};
};
const getBalanceFunc = () => {
setQortBalanceLoading(true);
window
@@ -2173,6 +2174,7 @@ function App() {
onClick={() => {
setRawWallet(null);
setExtstate("not-authenticated");
logoutFunc()
}}
src={Return}
/>

View File

@@ -6,15 +6,15 @@ const MessageQueueContext = createContext(null);
export const useMessageQueue = () => useContext(MessageQueueContext);
const uid = new ShortUniqueId({ length: 8 });
let messageQueue = []; // Global message queue
export const MessageQueueProvider = ({ children }) => {
const messageQueueRef = useRef([]);
const [queueChats, setQueueChats] = useState({}); // Stores chats and status for display
const isProcessingRef = useRef(false); // To track if the queue is being processed
const maxRetries = 2;
const clearStatesMessageQueueProvider = useCallback(() => {
setQueueChats({});
messageQueue = [];
messageQueueRef.current = [];
isProcessingRef.current = false;
}, []);
@@ -36,9 +36,9 @@ export const MessageQueueProvider = ({ children }) => {
[groupDirectId]: [...(prev[groupDirectId] || []), chatData]
}));
// Add the message to the global messageQueue
messageQueue = [
...messageQueue,
// Add the message to the global messageQueueRef.current
messageQueueRef.current = [
...messageQueueRef.current,
{ func: sendMessageFunc, identifier: tempId, groupDirectId, specialId: messageObj?.message?.specialId }
];
@@ -51,10 +51,10 @@ export const MessageQueueProvider = ({ children }) => {
processQueue(newMessages, groupDirectId);
};
// Function to process the messageQueue and handle new messages
// Function to process the messageQueueRef.current and handle new messages
const processQueue = useCallback(async (newMessages = [], groupDirectId) => {
// Filter out any message in the queue that matches the specialId from newMessages
messageQueue = messageQueue.filter((msg) => {
messageQueueRef.current = messageQueueRef.current.filter((msg) => {
return !newMessages.some(newMsg => newMsg?.specialId === msg?.specialId);
});
@@ -81,12 +81,12 @@ export const MessageQueueProvider = ({ children }) => {
});
// If currently processing or the queue is empty, return
if (isProcessingRef.current || messageQueue.length === 0) return;
if (isProcessingRef.current || messageQueueRef.current.length === 0) return;
isProcessingRef.current = true; // Lock the queue for processing
while (messageQueue.length > 0) {
const currentMessage = messageQueue[0]; // Get the first message in the queue
while (messageQueueRef.current.length > 0) {
const currentMessage = messageQueueRef.current[0]; // Get the first message in the queue
const { groupDirectId, identifier } = currentMessage;
// Update the chat status to 'sending'
@@ -104,10 +104,10 @@ export const MessageQueueProvider = ({ children }) => {
});
try {
// Execute the function stored in the messageQueue
// Execute the function stored in the messageQueueRef.current
await currentMessage.func();
// Remove the message from the messageQueue after successful sending
messageQueue = messageQueue.slice(1); // Slice here remains for successful messages
// Remove the message from the messageQueueRef.current after successful sending
messageQueueRef.current.shift(); // Slice here remains for successful messages
// Remove the message from queueChats after success
// setQueueChats((prev) => {
@@ -136,8 +136,8 @@ export const MessageQueueProvider = ({ children }) => {
// Max retries reached, set status to 'failed-permanent'
updatedChats[groupDirectId][chatIndex].status = 'failed-permanent';
// Remove the message from the messageQueue after max retries
messageQueue = messageQueue.slice(1); // Slice for failed messages after max retries
// Remove the message from the messageQueueRef.current after max retries
messageQueueRef.current.shift();// Slice for failed messages after max retries
// // Remove the message from queueChats after failure
// updatedChats[groupDirectId] = updatedChats[groupDirectId].filter(
@@ -147,7 +147,7 @@ export const MessageQueueProvider = ({ children }) => {
}
return updatedChats;
});
}
}
// Delay between processing each message to avoid overlap
await new Promise((res) => setTimeout(res, 5000));

View File

@@ -3,6 +3,7 @@ import {
addEnteredQmailTimestamp,
addTimestampEnterChat,
addTimestampGroupAnnouncement,
addTimestampMention,
addUserSettings,
banFromGroup,
cancelBan,
@@ -29,6 +30,7 @@ import {
getTempPublish,
getTimestampEnterChat,
getTimestampGroupAnnouncement,
getTimestampMention,
getUserInfo,
getUserSettings,
handleActiveGroupDataFromSocket,
@@ -1217,6 +1219,59 @@ export async function getTimestampEnterChatCase(request, event) {
}
}
export async function getTimestampMentionCase(request, event) {
try {
const response = await getTimestampMention();
event.source.postMessage(
{
requestId: request.requestId,
action: "getTimestampMention",
payload: response,
type: "backgroundMessageResponse",
},
event.origin
);
} catch (error) {
event.source.postMessage(
{
requestId: request.requestId,
action: "getTimestampMention",
error: error?.message,
type: "backgroundMessageResponse",
},
event.origin
);
}
}
export async function addTimestampMentionCase(request, event) {
try {
const { groupId, timestamp } = request.payload;
const response = await addTimestampMention({ groupId, timestamp });
event.source.postMessage(
{
requestId: request.requestId,
action: "addTimestampMention",
payload: response,
type: "backgroundMessageResponse",
},
event.origin
);
} catch (error) {
event.source.postMessage(
{
requestId: request.requestId,
action: "addTimestampMention",
error: error?.message,
type: "backgroundMessageResponse",
},
event.origin
);
}
}
export async function getGroupNotificationTimestampCase(request, event) {
try {
const response = await getTimestampGroupAnnouncement();

View File

@@ -2827,7 +2827,6 @@ async function getChatHeadsDirect() {
chrome?.runtime?.onMessage.addListener((request, sender, sendResponse) => {
if (request) {
console.log('REQUEST MESSAGE', request)
switch (request.action) {
case "version":

View File

@@ -35,6 +35,7 @@ import {
addEnteredQmailTimestampCase,
addGroupNotificationTimestampCase,
addTimestampEnterChatCase,
addTimestampMentionCase,
addUserSettingsCase,
balanceCase,
banFromGroupCase,
@@ -59,6 +60,7 @@ import {
getTempPublishCase,
getThreadActivityCase,
getTimestampEnterChatCase,
getTimestampMentionCase,
getUserSettingsCase,
getWalletInfoCase,
handleActiveGroupDataFromSocketCase,
@@ -2543,6 +2545,18 @@ export async function getTimestampEnterChat() {
return {};
}
}
export async function getTimestampMention() {
const wallet = await getSaveWallet();
const address = wallet.address0;
const key = `enter-mention-timestamp-${address}`;
const res = await getData<any>(key).catch(() => null);
if (res) {
const parsedData = res;
return parsedData;
} else {
return {};
}
}
export async function getTimestampGroupAnnouncement() {
const wallet = await getSaveWallet();
const address = wallet.address0;
@@ -2666,6 +2680,21 @@ export async function addTimestampEnterChat({ groupId, timestamp }) {
});
}
export async function addTimestampMention({ groupId, timestamp }) {
const wallet = await getSaveWallet();
const address = wallet.address0;
const data = await getTimestampMention();
data[groupId] = timestamp;
return await new Promise((resolve, reject) => {
storeData(`enter-mention-timestamp-${address}`, data)
.then(() => resolve(true))
.catch((error) => {
reject(new Error(error.message || "Error saving data"));
});
});
}
export async function notifyAdminRegenerateSecretKey({
groupName,
adminAddress,
@@ -2841,6 +2870,12 @@ function setupMessageListener() {
case "getTimestampEnterChat":
getTimestampEnterChatCase(request, event);
break;
case "addTimestampMention":
addTimestampMentionCase(request, event);
break;
case "getTimestampMention":
getTimestampMentionCase(request, event);
break;
case "getGroupNotificationTimestamp":
getGroupNotificationTimestampCase(request, event);
break;

View File

@@ -24,7 +24,7 @@ import { isExtMsg } from '../../background'
const uid = new ShortUniqueId({ length: 5 });
export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance}) => {
export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, myAddress, handleNewEncryptionNotification, hide, handleSecretKeyCreationInProgress, triedToFetchSecretKey, myName, balance, getTimestampEnterChatParent}) => {
const [messages, setMessages] = useState([])
const [chatReferences, setChatReferences] = useState({})
const [isSending, setIsSending] = useState(false)
@@ -43,6 +43,61 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
const editorRef = useRef(null);
const { queueChats, addToQueue, processWithNewMessages } = useMessageQueue();
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const lastReadTimestamp = useRef(null)
const getTimestampEnterChat = async () => {
try {
return new Promise((res, rej) => {
window.sendMessage("getTimestampEnterChat")
.then((response) => {
if (!response?.error) {
if(response && selectedGroup && response[selectedGroup]){
lastReadTimestamp.current = response[selectedGroup]
window.sendMessage("addTimestampEnterChat", {
timestamp: Date.now(),
groupId: selectedGroup
}).catch((error) => {
console.error("Failed to add timestamp:", error.message || "An error occurred");
});
setTimeout(() => {
getTimestampEnterChatParent();
}, 200);
}
res(response);
return;
}
rej(response.error);
})
.catch((error) => {
rej(error.message || "An error occurred");
});
});
} catch (error) {}
};
useEffect(()=> {
getTimestampEnterChat()
}, [])
const members = useMemo(() => {
const uniqueMembers = new Set();
messages.forEach((message) => {
if (message?.senderName) {
uniqueMembers.add(message?.senderName);
}
});
return Array.from(uniqueMembers);
}, [messages]);
const triggerRerender = () => {
forceUpdate(); // Trigger re-render by updating the state
@@ -145,16 +200,20 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
res(combineUIAndExtensionMsgs);
if (isInitiated) {
const formatted = combineUIAndExtensionMsgs
.filter((rawItem) => !rawItem?.chatReference)
.map((item) => ({
...item,
id: item.signature,
text: item?.decryptedData?.message || "",
repliedTo: item?.repliedTo || item?.decryptedData?.repliedTo,
unread: item?.sender === myAddress ? false : !!item?.chatReference ? false : true,
isNotEncrypted: !!item?.messageText,
}));
.map((item) => {
return {
...item,
id: item.signature,
text: item?.decryptedData?.message || "",
repliedTo: item?.repliedTo || item?.decryptedData?.repliedTo,
unread: item?.sender === myAddress ? false : !!item?.chatReference ? false : true,
isNotEncrypted: !!item?.messageText,
}
});
setMessages((prev) => [...prev, ...formatted]);
setChatReferences((prev) => {
@@ -211,16 +270,25 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey,
return organizedChatReferences;
});
} else {
let firstUnreadFound = false;
const formatted = combineUIAndExtensionMsgs
.filter((rawItem) => !rawItem?.chatReference)
.map((item) => ({
...item,
id: item.signature,
text: item?.decryptedData?.message || "",
repliedTo: item?.repliedTo || item?.decryptedData?.repliedTo,
isNotEncrypted: !!item?.messageText,
unread: false,
}));
.map((item) => {
const divide = lastReadTimestamp.current && !firstUnreadFound && item.timestamp > lastReadTimestamp.current && myAddress !== item?.sender;
if(divide){
firstUnreadFound = true
}
return {
...item,
id: item.signature,
text: item?.decryptedData?.message || "",
repliedTo: item?.repliedTo || item?.decryptedData?.repliedTo,
isNotEncrypted: !!item?.messageText,
unread: false,
divide
}
});
setMessages(formatted);
setChatReferences((prev) => {
@@ -620,9 +688,9 @@ const clearEditorContent = () => {
position: hide ? 'absolute' : 'relative',
left: hide && '-100000px',
}}>
<ChatList onReply={onReply} chatId={selectedGroup} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages} handleReaction={handleReaction} chatReferences={chatReferences} tempChatReferences={tempChatReferences}/>
<ChatList enableMentions onReply={onReply} chatId={selectedGroup} initialMessages={messages} myAddress={myAddress} tempMessages={tempMessages} handleReaction={handleReaction} chatReferences={chatReferences} tempChatReferences={tempChatReferences} members={members} myName={myName} selectedGroup={selectedGroup} />
<div style={{
// position: 'fixed',
@@ -669,7 +737,7 @@ const clearEditorContent = () => {
)}
<Tiptap setEditorRef={setEditorRef} onEnter={sendMessage} isChat disableEnter={isMobile ? true : false} isFocusedParent={isFocusedParent} setIsFocusedParent={setIsFocusedParent} />
<Tiptap enableMentions setEditorRef={setEditorRef} onEnter={sendMessage} isChat disableEnter={isMobile ? true : false} isFocusedParent={isFocusedParent} setIsFocusedParent={setIsFocusedParent} membersWithNames={members} />
</div>
<Box sx={{
display: 'flex',

View File

@@ -3,11 +3,15 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { MessageItem } from './MessageItem';
import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events';
import { useInView } from 'react-intersection-observer'
import { Box } from '@mui/material';
import { ChatOptions } from './ChatOptions';
export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onReply, handleReaction, chatReferences, tempChatReferences }) => {
export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onReply, handleReaction, chatReferences, tempChatReferences, members, myName, selectedGroup, enableMentions }) => {
const parentRef = useRef();
const [messages, setMessages] = useState(initialMessages);
const [showScrollButton, setShowScrollButton] = useState(false);
const [showScrollDownButton, setShowScrollDownButton] = useState(false);
const hasLoadedInitialRef = useRef(false);
const isAtBottomRef = useRef(true);
@@ -32,7 +36,7 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
setMessages(totalMessages);
setTimeout(() => {
const hasUnreadMessages = totalMessages.some((msg) => msg.unread && !msg?.chatReference);
const hasUnreadMessages = totalMessages.some((msg) => msg.unread && !msg?.chatReference && !msg?.isTemp);
if (parentRef.current) {
const { scrollTop, scrollHeight, clientHeight } = parentRef.current;
const atBottom = scrollTop + clientHeight >= scrollHeight - 10; // Adjust threshold as needed
@@ -43,21 +47,30 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
}
}
if (!hasLoadedInitialRef.current) {
scrollToBottom(totalMessages);
const findDivideIndex = totalMessages.findIndex((item)=> !!item?.divide)
const divideIndex = findDivideIndex !== -1 ? findDivideIndex : undefined
scrollToBottom(totalMessages, divideIndex);
hasLoadedInitialRef.current = true;
}
}, 500);
}, [initialMessages, tempMessages]);
const scrollToBottom = (initialMsgs) => {
const scrollToBottom = (initialMsgs, divideIndex) => {
const index = initialMsgs ? initialMsgs.length - 1 : messages.length - 1;
if (rowVirtualizer) {
if(divideIndex){
rowVirtualizer.scrollToIndex(divideIndex, { align: 'start' })
} else {
rowVirtualizer.scrollToIndex(index, { align: 'end' })
}
}
handleMessageSeen()
};
const handleMessageSeen = useCallback(() => {
setMessages((prevMessages) =>
prevMessages.map((msg) => ({
@@ -98,19 +111,51 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
getScrollElement: () => parentRef.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
observeElementOffset: (instance, cb) => {
const offsetCheck = () => {
const { scrollHeight, scrollTop, clientHeight } = instance.scrollElement;
const atBottom = scrollHeight - scrollTop - clientHeight <= 300;
if(showScrollButton){
setShowScrollDownButton(false)
} else
if(atBottom){
setShowScrollDownButton(false)
} else {
setShowScrollDownButton(true)
}
cb(scrollTop); // Pass scroll offset to callback
// setShowScrollToBottom(!atBottom);
};
// Initial check and continuous monitoring
offsetCheck();
instance.scrollElement.addEventListener('scroll', offsetCheck);
return () => instance.scrollElement.removeEventListener('scroll', offsetCheck);
},
});
const goToMessage = useCallback((idx)=> {
rowVirtualizer.scrollToIndex(idx)
}, [])
return (
<Box sx={{
display: 'flex',
width: '100%',
height: '100%'
}}>
<div style={{
height: '100%',
position: 'relative',
display: 'flex',
flexDirection: 'column'
flexDirection: 'column',
width: '100%'
}}>
<div
@@ -181,8 +226,10 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
width: '100%', // Control width (90% of the parent)
padding: '10px 0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overscrollBehavior: 'none',
flexDirection: 'column',
gap: '5px'
}}
>
<MessageItem
@@ -195,7 +242,7 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
onReply={onReply}
reply={reply}
replyIndex={replyIndex}
scrollToItem={(idx) => rowVirtualizer.scrollToIndex(idx)}
scrollToItem={goToMessage}
handleReaction={handleReaction}
reactions={reactions}
isUpdating={isUpdating}
@@ -214,18 +261,44 @@ export const ChatList = ({ initialMessages, myAddress, tempMessages, chatId, onR
bottom: 20,
position: 'absolute',
right: 20,
backgroundColor: '#ff5a5f',
backgroundColor: 'var(--unread)',
color: 'white',
padding: '10px 20px',
borderRadius: '20px',
cursor: 'pointer',
zIndex: 10,
border: 'none',
outline: 'none'
}}
>
Scroll to Unread Messages
</button>
)}
{showScrollDownButton && (
<button
onClick={() => scrollToBottom()}
style={{
bottom: 20,
position: 'absolute',
right: 20,
backgroundColor: 'var(--Mail-Background)',
color: 'white',
padding: '10px 20px',
borderRadius: '20px',
cursor: 'pointer',
zIndex: 10,
border: 'none',
outline: 'none'
}}
>
Scroll to bottom
</button>
)}
</div>
{enableMentions && (
<ChatOptions messages={messages} goToMessage={goToMessage} members={members} myName={myName} selectedGroup={selectedGroup}/>
)}
</Box>
);
};

View File

@@ -0,0 +1,693 @@
import {
Avatar,
Box,
ButtonBase,
InputBase,
MenuItem,
Select,
Typography,
} from "@mui/material";
import React, { useEffect, useMemo, useRef, useState } from "react";
import SearchIcon from "@mui/icons-material/Search";
import { Spacer } from "../../common/Spacer";
import AlternateEmailIcon from "@mui/icons-material/AlternateEmail";
import CloseIcon from "@mui/icons-material/Close";
import {
AppsSearchContainer,
AppsSearchLeft,
AppsSearchRight,
} from "../Apps/Apps-styles";
import IconSearch from "../../assets/svgs/Search.svg";
import IconClearInput from "../../assets/svgs/ClearInput.svg";
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from "react-virtualized";
import { getBaseApiReact } from "../../App";
import { MessageDisplay } from "./MessageDisplay";
import { useVirtualizer } from "@tanstack/react-virtual";
import { formatTimestamp } from "../../utils/time";
import { ContextMenuMentions } from "../ContextMenuMentions";
import { convert } from 'html-to-text';
const extractTextFromHTML = (htmlString = '') => {
return convert(htmlString, {
wordwrap: false, // Disable word wrapping
})?.toLowerCase();
};
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 50,
});
export const ChatOptions = ({ messages, goToMessage, members, myName, selectedGroup }) => {
const [mode, setMode] = useState("default");
const [searchValue, setSearchValue] = useState("");
const [selectedMember, setSelectedMember] = useState(0);
const parentRef = useRef();
const parentRefMentions = useRef();
const [lastMentionTimestamp, setLastMentionTimestamp] = useState(null)
const [debouncedValue, setDebouncedValue] = useState(""); // Debounced value
const getTimestampMention = async () => {
try {
return new Promise((res, rej) => {
window.sendMessage("getTimestampMention")
.then((response) => {
if (!response?.error) {
if(response && selectedGroup && response[selectedGroup]){
setLastMentionTimestamp(response[selectedGroup])
}
res(response);
return;
}
rej(response.error);
})
.catch((error) => {
rej(error.message || "An error occurred");
});
});
} catch (error) {}
};
useEffect(()=> {
if(mode === 'mentions' && selectedGroup){
window.sendMessage("addTimestampMention", {
timestamp: Date.now(),
groupId: selectedGroup
}).then((res)=> {
getTimestampMention()
}).catch((error) => {
console.error("Failed to add timestamp:", error.message || "An error occurred");
});
}
}, [mode, selectedGroup])
useEffect(()=> {
getTimestampMention()
}, [])
// Debounce logic
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(searchValue);
}, 350);
// Cleanup timeout if searchValue changes before the timeout completes
return () => {
clearTimeout(handler);
};
}, [searchValue]); // Runs effect when searchValue changes
const searchedList = useMemo(() => {
if (!debouncedValue?.trim()) {
if (selectedMember) {
return messages
.filter((message) => message?.senderName === selectedMember)
?.sort((a, b) => b?.timestamp - a?.timestamp);
}
return [];
}
if (selectedMember) {
return messages
.filter(
(message) =>
message?.senderName === selectedMember &&
extractTextFromHTML(message?.decryptedData?.message)?.includes(
debouncedValue.toLowerCase()
)
)
?.sort((a, b) => b?.timestamp - a?.timestamp);
}
return messages
.filter((message) =>
extractTextFromHTML(message?.decryptedData?.message)?.includes(debouncedValue.toLowerCase())
)
?.sort((a, b) => b?.timestamp - a?.timestamp);
}, [debouncedValue, messages, selectedMember]);
const mentionList = useMemo(() => {
if(!messages || messages.length === 0 || !myName) return []
return messages
.filter((message) =>
extractTextFromHTML(message?.decryptedData?.message)?.includes(`@${myName}`)
)
?.sort((a, b) => b?.timestamp - a?.timestamp);
}, [messages, myName]);
const rowVirtualizer = useVirtualizer({
count: searchedList.length,
getItemKey: React.useCallback(
(index) => searchedList[index].signature,
[searchedList]
),
getScrollElement: () => parentRef.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
});
const rowVirtualizerMentions = useVirtualizer({
count: mentionList.length,
getItemKey: React.useCallback(
(index) => mentionList[index].signature,
[mentionList]
),
getScrollElement: () => parentRefMentions.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
});
if (mode === "mentions") {
return (
<Box
sx={{
width: "300px",
height: "100%",
display: "flex",
flexDirection: "column",
// alignItems: 'center',
backgroundColor: "#1F2023",
borderBottomLeftRadius: "20px",
borderTopLeftRadius: "20px",
overflow: "auto",
flexShrink: 0,
flexGrow: 0,
}}
>
<Box
sx={{
padding: "10px",
display: "flex",
justifyContent: "flex-end",
}}
>
<CloseIcon
onClick={() => {
setMode("default");
}}
sx={{
cursor: "pointer",
color: "white",
}}
/>
</Box>
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{mentionList?.length === 0 && (
<Typography
sx={{
fontSize: "11px",
fontWeight: 400,
color: "rgba(255, 255, 255, 0.2)",
}}
>
No results
</Typography>
)}
<Box
sx={{
display: "flex",
width: "100%",
height: "100%",
}}
>
<div
style={{
height: "100%",
position: "relative",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div
ref={parentRefMentions}
className="List"
style={{
flexGrow: 1,
overflow: "auto",
position: "relative",
display: "flex",
height: "0px",
}}
>
<div
style={{
height: rowVirtualizerMentions.getTotalSize(),
width: "100%",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
}}
>
{rowVirtualizerMentions.getVirtualItems().map((virtualRow) => {
const index = virtualRow.index;
let message = mentionList[index];
return (
<div
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={rowVirtualizerMentions.measureElement} //measure dynamic row height
key={message.signature}
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",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "0px 20px",
}}
>
<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",
height: "25px",
width: "25px",
}}
alt={message?.senderName}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${
message?.senderName
}/qortal_avatar?async=true`}
>
{message?.senderName?.charAt(0)}
</Avatar>
<Typography
sx={{
fontWight: 600,
fontFamily: "Inter",
color: "cadetBlue",
}}
>
{message?.senderName}
</Typography>
</Box>
</Box>
<Spacer height="5px" />
<Typography sx={{
fontSize: '12px'
}}>{formatTimestamp(message.timestamp)}</Typography>
<Box
style={{
cursor: "pointer",
}}
onClick={() => {
const findMsgIndex = messages.findIndex(
(item) =>
item?.signature === message?.signature
);
if (findMsgIndex !== -1) {
goToMessage(findMsgIndex);
}
}}
>
<MessageDisplay
htmlContent={
message?.decryptedData?.message || "<p></p>"
}
/>
</Box>
</Box>
</div>
);
})}
</div>
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
}
if (mode === "search") {
return (
<Box
sx={{
width: "300px",
height: "100%",
display: "flex",
flexDirection: "column",
// alignItems: 'center',
backgroundColor: "#1F2023",
borderBottomLeftRadius: "20px",
borderTopLeftRadius: "20px",
overflow: "auto",
flexShrink: 0,
flexGrow: 0,
}}
>
<Box
sx={{
padding: "10px",
display: "flex",
justifyContent: "flex-end",
}}
>
<CloseIcon
onClick={() => {
setMode("default");
}}
sx={{
cursor: "pointer",
color: "white",
}}
/>
</Box>
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<AppsSearchContainer>
<AppsSearchLeft>
<img src={IconSearch} />
<InputBase
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
sx={{ ml: 1, flex: 1 }}
placeholder="Search chat text"
inputProps={{
"aria-label": "Search for apps",
fontSize: "16px",
fontWeight: 400,
}}
/>
</AppsSearchLeft>
<AppsSearchRight>
{searchValue && (
<ButtonBase
onClick={() => {
setSearchValue("");
}}
>
<img src={IconClearInput} />
</ButtonBase>
)}
</AppsSearchRight>
</AppsSearchContainer>
<Box
sx={{
padding: "10px",
display: "flex",
justifyContent: "space-between",
alignItems: 'center'
}}
>
<Select
size="small"
labelId="demo-simple-select-label"
id="demo-simple-select"
value={selectedMember}
label="By member"
onChange={(e) => setSelectedMember(e.target.value)}
>
<MenuItem value={0}>
<em>By member</em>
</MenuItem>
{members?.map((member) => {
return (
<MenuItem key={member} value={member}>
{member}
</MenuItem>
);
})}
</Select>
{!!selectedMember && (
<CloseIcon
onClick={() => {
setSelectedMember(0);
}}
sx={{
cursor: "pointer",
color: "white",
}}
/>
)}
</Box>
{debouncedValue && searchedList?.length === 0 && (
<Typography
sx={{
fontSize: "11px",
fontWeight: 400,
color: "rgba(255, 255, 255, 0.2)",
}}
>
No results
</Typography>
)}
<Box
sx={{
display: "flex",
width: "100%",
height: "100%",
}}
>
<div
style={{
height: "100%",
position: "relative",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div
ref={parentRef}
className="List"
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;
let message = searchedList[index];
return (
<div
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={rowVirtualizer.measureElement} //measure dynamic row height
key={message.signature}
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",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "0px 20px",
}}
>
<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",
height: "25px",
width: "25px",
}}
alt={message?.senderName}
src={`${getBaseApiReact()}/arbitrary/THUMBNAIL/${
message?.senderName
}/qortal_avatar?async=true`}
>
{message?.senderName?.charAt(0)}
</Avatar>
<Typography
sx={{
fontWight: 600,
fontFamily: "Inter",
color: "cadetBlue",
}}
>
{message?.senderName}
</Typography>
</Box>
</Box>
<Spacer height="5px" />
<Typography sx={{
fontSize: '12px'
}}>{formatTimestamp(message.timestamp)}</Typography>
<Box
style={{
cursor: "pointer",
}}
onClick={() => {
const findMsgIndex = messages.findIndex(
(item) =>
item?.signature === message?.signature
);
if (findMsgIndex !== -1) {
goToMessage(findMsgIndex);
}
}}
>
<MessageDisplay
htmlContent={
message?.decryptedData?.message || "<p></p>"
}
/>
</Box>
</Box>
</div>
);
})}
</div>
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
}
return (
<Box
sx={{
width: "50px",
height: "100%",
gap: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Box
sx={{
width: "100%",
padding: "10px",
gap: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
backgroundColor: "#1F2023",
borderBottomLeftRadius: "20px",
borderTopLeftRadius: "20px",
minHeight: "200px",
}}
>
<ButtonBase onClick={() => {
setMode("search")
}}>
<SearchIcon />
</ButtonBase>
<ContextMenuMentions getTimestampMention={getTimestampMention} groupId={selectedGroup}>
<ButtonBase onClick={() => {
setMode("mentions")
setSearchValue('')
setSelectedMember(0)
}}>
<AlternateEmailIcon sx={{
color: mentionList?.length > 0 && (!lastMentionTimestamp || lastMentionTimestamp < mentionList[0]?.timestamp) ? 'var(--unread)' : 'white'
}} />
</ButtonBase>
</ContextMenuMentions>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,69 @@
import React, {
forwardRef, useEffect, useImperativeHandle,
useState,
} from 'react'
export default forwardRef((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = index => {
const item = props.items[index]
if (item) {
props.command(item)
}
}
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
},
}))
return (
<div className="dropdown-menu">
{props.items.length
? props.items.map((item, index) => (
<button
className={index === selectedIndex ? 'is-selected' : ''}
key={item.id || index}
onClick={() => selectItem(index)}
>
{item.label}
</button>
))
: <div className="item">No result</div>
}
</div>
)
})

View File

@@ -47,6 +47,12 @@ export const MessageItem = ({
return (
<>
{message?.divide && (
<div className="unread-divider" id="unread-divider-id">
Unread messages below
</div>
)}
<div
ref={lastSignature === message?.signature ? ref : null}
style={{
@@ -239,7 +245,9 @@ export const MessageItem = ({
handleReaction(reaction, message, true)
}
}}>
<div>{reaction}</div> {numberOfReactions > 1 && (
<div style={{
fontSize: '16px'
}}>{reaction}</div> {numberOfReactions > 1 && (
<Typography sx={{
marginLeft: '4px'
}}>{' '} {numberOfReactions}</Typography>
@@ -307,6 +315,7 @@ export const MessageItem = ({
></Message> */}
{/* {!message.unread && <span style={{ color: 'green' }}> Seen</span>} */}
</div>
</>
);
};
@@ -355,5 +364,6 @@ export const ReplyPreview = ({message})=> {
)}
</Box>
</Box>
)
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { EditorProvider, useCurrentEditor } from "@tiptap/react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { EditorProvider, useCurrentEditor, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Color } from "@tiptap/extension-color";
import ListItem from "@tiptap/extension-list-item";
@@ -22,10 +22,28 @@ import RedoIcon from "@mui/icons-material/Redo";
import FormatHeadingIcon from "@mui/icons-material/FormatSize";
import DeveloperModeIcon from "@mui/icons-material/DeveloperMode";
import Compressor from "compressorjs";
import Mention from '@tiptap/extension-mention';
import ImageResize from "tiptap-extension-resize-image"; // Import the ResizeImage extension
import { isMobile } from "../../App";
import tippy from "tippy.js";
import "tippy.js/dist/tippy.css";
import Popover from '@mui/material/Popover';
import List from '@mui/material/List';
import ListItemMui from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import { ReactRenderer } from '@tiptap/react'
import MentionList from './MentionList.jsx'
function textMatcher(doc, from) {
const textBeforeCursor = doc.textBetween(0, from, ' ', ' ');
const match = textBeforeCursor.match(/@[\w]*$/); // Match '@' followed by valid characters
if (!match) return null;
const start = from - match[0].length;
const query = match[0];
return { start, query };
}
const MenuBar = ({ setEditorRef, isChat }) => {
const { editor } = useCurrentEditor();
const fileInputRef = useRef(null);
@@ -279,8 +297,10 @@ export default ({
isFocusedParent,
overrideMobile,
customEditorHeight,
membersWithNames,
enableMentions
}) => {
const [isFocused, setIsFocused] = useState(false);
const extensionsFiltered = isChat
? extensions.filter((item) => item?.name !== "image")
: extensions;
@@ -290,6 +310,32 @@ export default ({
setEditorRef(editorInstance);
};
// const users = [
// { id: 1, label: 'Alice' },
// { id: 2, label: 'Bob' },
// { id: 3, label: 'Charlie' },
// ];
const users = useMemo(()=> {
return (membersWithNames || [])?.map((item)=> {
return {
id: item,
label: item
}
})
}, [membersWithNames])
const usersRef = useRef([]);
useEffect(() => {
usersRef.current = users; // Keep users up-to-date
}, [users]);
const handleFocus = () => {
if (!isMobile) return;
setIsFocusedParent(true);
@@ -302,14 +348,89 @@ export default ({
}
};
const additionalExtensions = useMemo(()=> {
if(!enableMentions) return []
return [
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: {
items: ({ query }) => {
if (!query) return usersRef?.current;
return usersRef?.current?.filter((user) =>
user.label.toLowerCase().includes(query.toLowerCase())
);
},
render: () => {
let popup; // Reference to the Tippy.js instance
let component;
return {
onStart: props => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props) {
component.updateProps(props)
if (!props.clientRect) {
return
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
popup[0].destroy()
component.destroy()
},
}
},
},
})
]
}, [enableMentions])
return (
<div>
<EditorProvider
slotBefore={
(isFocusedParent || !isMobile || overrideMobile) && (
<MenuBar setEditorRef={setEditorRefFunc} isChat={isChat} />
)
}
extensions={extensionsFiltered}
extensions={[...extensionsFiltered, ...additionalExtensions
]}
content={content}
onCreate={({ editor }) => {
editor.on("focus", handleFocus); // Listen for focus event
@@ -348,5 +469,7 @@ export default ({
},
}}
/>
</div>
);
};

View File

@@ -123,3 +123,50 @@
.isReply p {
font-size: 12px !important;
}
.tiptap .mention {
box-decoration-break: clone;
color: lightblue;
padding: 0.1rem 0.3rem;
}
.unread-divider {
width: 90%;
color: white;
border-bottom: 1px solid white;
display: flex;
justify-content: center;
border-radius: 2px;
}
.mention-item {
cursor: pointer;
}
.dropdown-menu {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.4rem;
position: relative;
max-height: 200px;
overflow: auto;
button {
align-items: center;
background-color: transparent;
display: flex;
gap: 0.25rem;
text-align: left;
font-size: 16px;
width: 100%;
border: none;
color: white;
cursor: pointer;
&:hover,
&:hover.is-selected {
background-color: gray;
}
}
}

View File

@@ -0,0 +1,130 @@
import React, { useState, useRef, useMemo, useEffect } from "react";
import {
ListItemIcon,
Menu,
MenuItem,
Typography,
styled,
} from "@mui/material";
import { executeEvent } from "../utils/events";
const CustomStyledMenu = styled(Menu)(({ theme }) => ({
"& .MuiPaper-root": {
backgroundColor: "#f9f9f9",
borderRadius: "12px",
padding: theme.spacing(1),
boxShadow: "0 5px 15px rgba(0, 0, 0, 0.2)",
},
"& .MuiMenuItem-root": {
fontSize: "14px", // Smaller font size for the menu item text
color: "#444",
transition: "0.3s background-color",
"&:hover": {
backgroundColor: "#f0f0f0", // Explicit hover state
},
},
}));
export const ContextMenuMentions = ({
children,
groupId,
getTimestampMention
}) => {
const [menuPosition, setMenuPosition] = useState(null);
const longPressTimeout = useRef(null);
const preventClick = useRef(false); // Flag to prevent click after long-press or right-click
// Handle right-click (context menu) for desktop
const handleContextMenu = (event) => {
event.preventDefault();
event.stopPropagation(); // Prevent parent click
// Set flag to prevent any click event after right-click
preventClick.current = true;
setMenuPosition({
mouseX: event.clientX,
mouseY: event.clientY,
});
};
// Handle long-press for mobile
const handleTouchStart = (event) => {
longPressTimeout.current = setTimeout(() => {
preventClick.current = true; // Prevent the next click after long-press
event.stopPropagation(); // Prevent parent click
setMenuPosition({
mouseX: event.touches[0].clientX,
mouseY: event.touches[0].clientY,
});
}, 500); // Long press duration
};
const handleTouchEnd = (event) => {
clearTimeout(longPressTimeout.current);
if (preventClick.current) {
event.preventDefault();
event.stopPropagation(); // Prevent synthetic click after long-press
preventClick.current = false; // Reset the flag
}
};
const handleClose = (e) => {
e.preventDefault();
e.stopPropagation();
setMenuPosition(null);
};
const addTimestamp = ()=> {
window.sendMessage("addTimestampMention", {
timestamp: Date.now(),
groupId
}).then((res)=> {
getTimestampMention()
}).catch((error) => {
console.error("Failed to add timestamp:", error.message || "An error occurred");
});
}
return (
<div
onContextMenu={handleContextMenu} // For desktop right-click
onTouchStart={handleTouchStart} // For mobile long-press start
onTouchEnd={handleTouchEnd} // For mobile long-press end
style={{ width: "100%", height: "100%" }}
>
{children}
<CustomStyledMenu
disableAutoFocusItem
open={!!menuPosition}
onClose={handleClose}
anchorReference="anchorPosition"
anchorPosition={
menuPosition
? { top: menuPosition.mouseY, left: menuPosition.mouseX }
: undefined
}
onClick={(e) => {
e.stopPropagation();
}}
>
<MenuItem
onClick={(e) => {
handleClose(e);
addTimestamp()
}}
>
<Typography variant="inherit" sx={{ fontSize: "14px" }}>
Unmark
</Typography>
</MenuItem>
</CustomStyledMenu>
</div>
);
};

View File

@@ -445,6 +445,7 @@ export const Group = ({
const [appsModeDev, setAppsModeDev] = useState('home')
const [isOpenSideViewDirects, setIsOpenSideViewDirects] = useState(false)
const [isOpenSideViewGroups, setIsOpenSideViewGroups] = useState(false)
const toggleSideViewDirects = ()=> {
if(isOpenSideViewGroups){
setIsOpenSideViewGroups(false)
@@ -2013,17 +2014,17 @@ export const Group = ({
// getTimestampEnterChat();
}, 200);
window.sendMessage("addTimestampEnterChat", {
timestamp: Date.now(),
groupId: group.groupId,
}).catch((error) => {
console.error("Failed to add timestamp:", error.message || "An error occurred");
});
// window.sendMessage("addTimestampEnterChat", {
// timestamp: Date.now(),
// groupId: group.groupId,
// }).catch((error) => {
// console.error("Failed to add timestamp:", error.message || "An error occurred");
// });
setTimeout(() => {
getTimestampEnterChat();
}, 200);
// setTimeout(() => {
// getTimestampEnterChat();
// }, 200);
}}
@@ -2485,6 +2486,7 @@ export const Group = ({
triedToFetchSecretKey={triedToFetchSecretKey}
myName={userInfo?.name}
balance={balance}
getTimestampEnterChatParent={getTimestampEnterChat}
/>
)}
{firstSecretKeyInCreation &&

View File

@@ -162,7 +162,7 @@ export const ListOfGroupPromotions = () => {
});
await Promise.all(getPromos);
const groupWithInfo = await getGroupNames(data);
const groupWithInfo = await getGroupNames(data.sort((a, b) => b.created - a.created));
setPromotions(groupWithInfo);
} catch (error) {
console.error(error);
@@ -559,10 +559,11 @@ export const ListOfGroupPromotions = () => {
}}
>
<Button
variant="contained"
// variant="contained"
onClick={(event) => handlePopoverOpen(event, promotion?.groupId)}
sx={{
fontSize: "12px",
color: 'white'
}}
>
Join Group: {` ${promotion?.groupName}`}
@@ -698,7 +699,7 @@ export const ListOfGroupPromotions = () => {
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{"Publish Group Promotion"}
{"Promote your group to non-members"}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">

View File

@@ -48,10 +48,11 @@ export const ThingsToDoInitial = ({ myAddress, name, hasGroups, balance, userInf
if (name) setChecked2(true);
}, [name]);
const isLoaded = React.useMemo(()=> {
if(balance !== null && userInfo !== null) return true
if(userInfo !== null) return true
return false
}, [balance, userInfo])
}, [ userInfo])
const hasDoneNameAndBalanceAndIsLoaded = React.useMemo(()=> {
if(isLoaded && checked1 && checked2) return true

View File

@@ -40,6 +40,7 @@
body {
margin: 0px;
overflow: hidden;
}
.image-container {

View File

@@ -323,7 +323,6 @@ export const getUserAccount = async ({isFromExtension, appInfo}) => {
}
} catch (error) {
console.log('per error', error)
throw new Error("Unable to fetch user account");
}
};