'init'
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
package-lock.json
|
||||
LICENSE
|
||||
*.code-workspace
|
||||
BIN
app/.DS_Store
vendored
Normal file
BIN
app/.DS_Store
vendored
Normal file
Binary file not shown.
83
app/App.tsx
Normal file
83
app/App.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Introduction } from "./views/Introduction/Introduction";
|
||||
import { CreateSeed } from './views/CreateSeed/CreateSeed';
|
||||
import { Lockscreen } from './views/Lockscreen/Lockscreen';
|
||||
import { ConfirmSeed } from './views/ConfirmSeed/ConfirmSeed';
|
||||
import { SetPassword } from './views/SetPassword/SetPassword';
|
||||
import { Main } from './views/Main/Main';
|
||||
import { ExistsSeed } from './views/ExistsSeed/ExistsSeed';
|
||||
import { Box, Divider } from '@mantine/core';
|
||||
import './style.css'
|
||||
import { useRosettaColors } from './hooks/useRosettaColors';
|
||||
import { Buffer } from 'buffer';
|
||||
import { InformationProvider } from './providers/InformationProvider/InformationProvider';
|
||||
import { BlacklistProvider } from './providers/BlacklistProvider/BlacklistProvider';
|
||||
import { useAccountProvider } from './providers/AccountProvider/useAccountProvider';
|
||||
import { ImageViwerProvider } from './providers/ImageViewerProvider/ImageViewerProvider';
|
||||
import { AvatarProvider } from './providers/AvatarProvider/AvatarProvider';
|
||||
import { Topbar } from './components/Topbar/Topbar';
|
||||
import { ContextMenuProvider } from './providers/ContextMenuProvider/ContextMenuProvider';
|
||||
import { SettingsProvider } from './providers/SettingsProvider/SettingsProvider';
|
||||
import { DialogListProvider } from './providers/DialogListProvider/DialogListProvider';
|
||||
import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogStateProvider';
|
||||
import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm';
|
||||
window.Buffer = Buffer;
|
||||
|
||||
export default function App() {
|
||||
const { allAccounts, accountProviderLoaded } = useAccountProvider();
|
||||
const colors = useRosettaColors();
|
||||
|
||||
|
||||
const getViewByLoginState = () => {
|
||||
if (!accountProviderLoaded) {
|
||||
return <></>
|
||||
}
|
||||
if (allAccounts.length <= 0) {
|
||||
/**
|
||||
* Если аккаунтов нет
|
||||
*/
|
||||
return <Introduction />
|
||||
}
|
||||
if (allAccounts.length > 0) {
|
||||
/**
|
||||
* Если есть аккаунт, но только один
|
||||
*/
|
||||
return <Lockscreen />
|
||||
}
|
||||
return <Introduction></Introduction>
|
||||
}
|
||||
|
||||
return (
|
||||
<InformationProvider>
|
||||
<DialogStateProvider>
|
||||
<DialogListProvider>
|
||||
<BlacklistProvider>
|
||||
<SettingsProvider>
|
||||
<Box h={'100%'}>
|
||||
<Topbar></Topbar>
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
<ContextMenuProvider>
|
||||
<ImageViwerProvider>
|
||||
<AvatarProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
getViewByLoginState()
|
||||
} />
|
||||
<Route path="/create-seed" element={<CreateSeed />} />
|
||||
<Route path="/confirm-seed" element={<ConfirmSeed />} />
|
||||
<Route path="/set-password" element={<SetPassword />} />
|
||||
<Route path="/main/*" element={<Main />} />
|
||||
<Route path="/exists-seed" element={<ExistsSeed />} />
|
||||
<Route path="/deviceconfirm" element={<DeviceConfirm />} />
|
||||
</Routes>
|
||||
</AvatarProvider>
|
||||
</ImageViwerProvider>
|
||||
</ContextMenuProvider>
|
||||
</Box>
|
||||
</SettingsProvider>
|
||||
</BlacklistProvider>
|
||||
</DialogListProvider>
|
||||
</DialogStateProvider>
|
||||
</InformationProvider>
|
||||
);
|
||||
}
|
||||
BIN
app/components/.DS_Store
vendored
Normal file
BIN
app/components/.DS_Store
vendored
Normal file
Binary file not shown.
78
app/components/ActionAvatar/ActionAvatar.tsx
Normal file
78
app/components/ActionAvatar/ActionAvatar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { useAvatarChange } from "@/app/providers/AvatarProvider/useChangeAvatar";
|
||||
import { useImageViewer } from "@/app/providers/ImageViewerProvider/useImageViewer";
|
||||
import { imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
||||
import { Avatar, Box, Flex, Overlay } from "@mantine/core";
|
||||
import { useFileDialog } from "@mantine/hooks";
|
||||
import { IconCamera } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ActionAvatarProps {
|
||||
title: string;
|
||||
publicKey: string;
|
||||
forceChangeable?: boolean;
|
||||
}
|
||||
|
||||
export function ActionAvatar(props : ActionAvatarProps) {
|
||||
const [overlay, setOverlay] = useState(false);
|
||||
const publicKey = usePublicKey();
|
||||
const changeAvatar = useAvatarChange();
|
||||
const avatars = useAvatars(props.publicKey, true);
|
||||
const {open} = useImageViewer();
|
||||
|
||||
const fileDialog = useFileDialog({
|
||||
multiple: false,
|
||||
accept: 'image/*',
|
||||
onChange: async (files) => {
|
||||
if(!files){
|
||||
return;
|
||||
}
|
||||
if(files.length == 0){
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
const base64Image = await imagePrepareForNetworkTransfer(file);
|
||||
changeAvatar(base64Image, props.publicKey);
|
||||
}
|
||||
});
|
||||
|
||||
const onClickAvatar = () => {
|
||||
if(props.publicKey != publicKey && !props.forceChangeable){
|
||||
open(avatars.map(a => ({src: a.avatar, timestamp: a.timestamp})), 0);
|
||||
return;
|
||||
}
|
||||
fileDialog.open();
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align={'center'} justify={'center'}>
|
||||
<Box
|
||||
w={120} h={120}
|
||||
onMouseEnter={() => setOverlay(true)}
|
||||
onMouseLeave={() => setOverlay(false)}
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={onClickAvatar}
|
||||
pos={'relative'}>
|
||||
<Avatar
|
||||
size={120}
|
||||
radius={120}
|
||||
mx="auto"
|
||||
name={props.title.trim() || props.publicKey}
|
||||
color={'initials'}
|
||||
src={avatars.length > 0 ?
|
||||
avatars[0].avatar
|
||||
: undefined}
|
||||
>
|
||||
</Avatar>
|
||||
{(overlay && (props.publicKey == publicKey || props.forceChangeable)) && <Overlay zIndex={99} radius={120}>
|
||||
<Flex align={'center'} justify={'center'} h={'100%'} gap={5} opacity={0.8}>
|
||||
<IconCamera stroke={2} color="white" size={40}></IconCamera>
|
||||
</Flex>
|
||||
</Overlay>}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
54
app/components/AnimatedButton/AnimatedButton.tsx
Normal file
54
app/components/AnimatedButton/AnimatedButton.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Button, ButtonProps } from '@mantine/core';
|
||||
import { forwardRef, useMemo, useEffect } from 'react';
|
||||
|
||||
type AnimatedButtonProps = ButtonProps & {
|
||||
animated?: [string, string];
|
||||
animationDurationMs?: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const AnimatedButton = forwardRef<HTMLButtonElement, AnimatedButtonProps>(
|
||||
({ animated, animationDurationMs = 2000, style, onClick, disabled, ...rest }, ref) => {
|
||||
const animationName = useMemo(() => {
|
||||
if (!animated) return undefined;
|
||||
const safe = (s: string) => s.replace(/[^a-zA-Z0-9]/g, '');
|
||||
return `abg_${safe(animated[0])}_${safe(animated[1])}`;
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animated || !animationName) return;
|
||||
const id = `__${animationName}`;
|
||||
let styleEl = document.getElementById(id) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = id;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = `@keyframes ${animationName}{0%{background-position:-200% 0;}100%{background-position:200% 0;}}`;
|
||||
}, [animated, animationName]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={
|
||||
animated && animationName && !disabled
|
||||
? {
|
||||
background: animated[0],
|
||||
backgroundImage: `linear-gradient(90deg, transparent, ${animated[1]}, transparent)`,
|
||||
backgroundSize: '50% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${animationName} ${animationDurationMs}ms linear infinite`,
|
||||
willChange: 'background-position',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}
|
||||
: style
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animatedRoundedProgress {
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { RingProgress } from "@mantine/core";
|
||||
import classes from './AnimatedRoundedProgress.module.css'
|
||||
|
||||
interface AnimatedRoundedProgressProps {
|
||||
value: number;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AnimatedRoundedProgress(props : AnimatedRoundedProgressProps) {
|
||||
const value = Math.min(100, Math.max(0, props.value));
|
||||
const color = props.color || 'white';
|
||||
return (
|
||||
<RingProgress
|
||||
size={props.size || 20}
|
||||
sections={[{ value, color: color }, { value: 100 - value, color: 'transparent' }]}
|
||||
transitionDuration={250}
|
||||
thickness={2}
|
||||
roundCaps
|
||||
className={classes.animatedRoundedProgress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
app/components/AttachmentError/AttachmentError.tsx
Normal file
22
app/components/AttachmentError/AttachmentError.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Box, Flex, Text } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
|
||||
export function AttachmentError() {
|
||||
const colors = useRosettaColors();
|
||||
return (
|
||||
<Box style={{
|
||||
border: `1px solid ${colors.borderColor}`,
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: colors.mainColor,
|
||||
}}>
|
||||
<Flex direction={'row'} gap={'sm'} align={'center'}>
|
||||
<IconX size={30} color={colors.error}></IconX>
|
||||
<Text size={'xs'}>
|
||||
This attachment is no longer available because it was sent for a previous version of the app.
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
30
app/components/AuthFlowBreadcrumbs/AuthFlowBreadcrumbs.tsx
Normal file
30
app/components/AuthFlowBreadcrumbs/AuthFlowBreadcrumbs.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Button, Flex, Text } from "@mantine/core";
|
||||
import { IconChevronLeft } from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface AuthFlowBreadcrumbsProps {
|
||||
rightSection?: React.ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function AuthFlowBreadcrumbs(props: AuthFlowBreadcrumbsProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Flex w={'100%'} mih={50} justify={'space-between'} pl={'sm'} pr={'sm'} direction={'row'} align={'center'}>
|
||||
<Flex align={'center'} justify={'flex-start'} style={{ flex: 1 }}>
|
||||
<Button p={0} leftSection={
|
||||
<IconChevronLeft size={15}></IconChevronLeft>
|
||||
} color="red" onClick={() => navigate(-1)} variant="transparent">
|
||||
Back
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex style={{ flex: 1 }} justify={'center'}>
|
||||
<Text fw={500} ta={'center'} size={'sm'}>{props.title}</Text>
|
||||
</Flex>
|
||||
<Flex align={'center'} justify={'flex-end'} style={{ flex: 1 }}>
|
||||
{props.rightSection}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
51
app/components/BackToDialogs/BackToDialogs.tsx
Normal file
51
app/components/BackToDialogs/BackToDialogs.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { useDialogsList } from "@/app/providers/DialogListProvider/useDialogsList";
|
||||
import { Badge, Flex } from "@mantine/core";
|
||||
import { IconChevronLeft } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function BackToDialogs() {
|
||||
const colors = useRosettaColors();
|
||||
const [unreadedMessagessCount, setUnreadedMessagesCount] = useState(0);
|
||||
const {dialogs} = useDialogsList();
|
||||
const [_, setViewState] = useViewPanelsState();
|
||||
const {getQuery} = useDatabase();
|
||||
const publicKey = usePublicKey();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const result = await getQuery(`
|
||||
SELECT COUNT(*) AS unloaded_count FROM messages WHERE from_me = 0 AND read = 0 AND account = ?
|
||||
`, [publicKey]);
|
||||
setUnreadedMessagesCount(result.unloaded_count || 0);
|
||||
})();
|
||||
}, [dialogs, publicKey]);
|
||||
|
||||
const onClickDialogs = () => {
|
||||
setViewState(ViewPanelsState.DIALOGS_PANEL_ONLY);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex align={'center'} justify={'flex-start'} style={{cursor: 'pointer', position: 'relative'}} onClick={onClickDialogs}>
|
||||
<IconChevronLeft color={colors.brandColor}>
|
||||
</IconChevronLeft>
|
||||
{unreadedMessagessCount > 0 &&
|
||||
<Badge style={{
|
||||
cursor: 'pointer',
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
left: 15,
|
||||
minWidth: 10,
|
||||
zIndex: 10
|
||||
}} color="var(--mantine-color-red-5)" variant="filled" circle size={'sm'}>
|
||||
{unreadedMessagessCount > 9 ? '9+' : unreadedMessagessCount}
|
||||
</Badge>
|
||||
}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
app/components/Breadcrumbs/Breadcrumbs.module.css
Normal file
14
app/components/Breadcrumbs/Breadcrumbs.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.history_button {
|
||||
@mixin hover {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history_button_disabled {
|
||||
background-color: unset!important;
|
||||
}
|
||||
46
app/components/Breadcrumbs/Breadcrumbs.tsx
Normal file
46
app/components/Breadcrumbs/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Box, Button, darken, Flex, lighten, Text, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import classes from './Breadcrumbs.module.css'
|
||||
|
||||
export interface BreadcrumbsProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
rightSection?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Breadcrumbs(props : BreadcrumbsProps) {
|
||||
const {chevrons} = useRosettaColors();
|
||||
const colorScheme = useComputedColorScheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box w={'100%'} h={50}>
|
||||
<Flex p={'sm'} h={'100%'} align={'center'} justify={'space-between'}>
|
||||
<Flex align={'center'}>
|
||||
<Box>
|
||||
<Button className={classes.history_button} onClick={() => navigate(-1)} c={chevrons.active} variant={'subtle'} p={5}>
|
||||
<IconChevronLeft></IconChevronLeft>
|
||||
</Button>
|
||||
|
||||
<Button className={classes.history_button_disabled}
|
||||
c={chevrons.disabled} variant={'subtle'} p={5}>
|
||||
<IconChevronRight></IconChevronRight>
|
||||
</Button>
|
||||
|
||||
</Box>
|
||||
<Box ml={'sm'}>
|
||||
<Text fw={'bold'} c={colorScheme == 'light' ? darken(chevrons.active, 0.6) : lighten(chevrons.active, 0.6)} size={'sm'}>{props.text}</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box>
|
||||
{props.onClick && (<Button onClick={props.onClick} p={0} pr={6} variant={'transparent'}>Save</Button>)}
|
||||
{props.rightSection}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
184
app/components/ChatHeader/ChatHeader.tsx
Normal file
184
app/components/ChatHeader/ChatHeader.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||
import { Avatar, Box, Divider, Flex, Loader, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconBookmark, IconLockAccess, IconLockCancel, IconTrashX } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
|
||||
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { ReplyHeader } from "../ReplyHeader/ReplyHeader";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { BackToDialogs } from "../BackToDialogs/BackToDialogs";
|
||||
import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts";
|
||||
|
||||
|
||||
export function ChatHeader() {
|
||||
const colors = useRosettaColors();
|
||||
const computedTheme = useComputedColorScheme();
|
||||
const navigate = useNavigate();
|
||||
const publicKey = usePublicKey();
|
||||
const {deleteMessages, dialog} = useDialog();
|
||||
const theme = useMantineTheme();
|
||||
const [blocked, blockUser, unblockUser] = useBlacklist(dialog);
|
||||
const [opponent, ___, forceUpdateUserInformation] = useUserInformation(dialog);
|
||||
const protocolState = useProtocolState();
|
||||
const [userTypeing, setUserTypeing] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(undefined);
|
||||
const avatars = useAvatars(dialog);
|
||||
const {replyMessages} = useReplyMessages();
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const isSystemAccount = systemAccounts.find((acc) => acc.publicKey == dialog) != undefined;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
forceUpdateUserInformation();
|
||||
setUserTypeing(false);
|
||||
clearTimeout(timeoutRef.current);
|
||||
}, [dialog]);
|
||||
|
||||
usePacket(0x0B, (packet : PacketTyping) => {
|
||||
if(packet.getFromPublicKey() == dialog && packet.getToPublicKey() == publicKey){
|
||||
setUserTypeing(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setUserTypeing(false);
|
||||
}, 3000);
|
||||
}
|
||||
}, [dialog]);
|
||||
|
||||
const clearMessages = async () => {
|
||||
deleteMessages();
|
||||
modals.closeAll();
|
||||
}
|
||||
|
||||
const onClickClearMessages = () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Clear all messages?',
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to clear all messages? This action cannot be undone.
|
||||
</Text>
|
||||
),
|
||||
withCloseButton: false,
|
||||
labels: { confirm: 'Continue', cancel: "Cancel" },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: clearMessages
|
||||
});
|
||||
}
|
||||
|
||||
const onClickBlockUser = () => {
|
||||
if(opponent.publicKey != "DELETED"
|
||||
&& opponent.publicKey != publicKey){
|
||||
blockUser();
|
||||
}
|
||||
}
|
||||
|
||||
const onClickUnblockUser = () => {
|
||||
if(opponent.publicKey != "DELETED"
|
||||
&& opponent.publicKey != publicKey){
|
||||
unblockUser();
|
||||
}
|
||||
}
|
||||
|
||||
const onClickProfile = () => {
|
||||
if(opponent.publicKey != "DELETED" && opponent.publicKey != publicKey){
|
||||
navigate("/main/profile/" + opponent.publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (<>
|
||||
<Box bg={colors.boxColor} style={{
|
||||
userSelect: 'none',
|
||||
}} h={60}>
|
||||
{(replyMessages.messages.length <= 0 || replyMessages.inDialogInput) && <Flex p={'sm'} h={'100%'} justify={'space-between'} align={'center'} gap={'sm'}>
|
||||
<Flex style={{
|
||||
cursor: 'pointer'
|
||||
}} h={'100%'} align={'center'} gap={'sm'}>
|
||||
{!lg && <BackToDialogs></BackToDialogs>}
|
||||
{
|
||||
publicKey == opponent.publicKey ? <Avatar
|
||||
size={'md'}
|
||||
color={'blue'}
|
||||
variant={'filled'}
|
||||
onClick={onClickProfile}
|
||||
>
|
||||
<IconBookmark stroke={2} size={20}></IconBookmark>
|
||||
</Avatar> : <Avatar onClick={onClickProfile} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={opponent.title}></Avatar>
|
||||
}
|
||||
<Flex direction={'column'} onClick={onClickProfile}>
|
||||
<Flex align={'center'} gap={3}>
|
||||
<Text size={'sm'} c={computedTheme == 'light' ? 'black' : 'white'} fw={500}>
|
||||
{(
|
||||
publicKey == opponent.publicKey ? "Saved messages" : opponent.title
|
||||
)}
|
||||
</Text>
|
||||
{(opponent.verified > 0 && publicKey != opponent.publicKey) && <VerifiedBadge size={17} verified={opponent.verified}></VerifiedBadge>}
|
||||
</Flex>
|
||||
{(publicKey != opponent.publicKey && protocolState == ProtocolState.CONNECTED && !userTypeing) && <>
|
||||
{(
|
||||
opponent.online == OnlineState.ONLINE ?
|
||||
<Text c={theme.colors.green[7]} fz={12}>online</Text> :
|
||||
<Text c={theme.colors.gray[5]} fz={12}>{isSystemAccount ? 'official account' : 'offline'}</Text>
|
||||
)}
|
||||
</>}
|
||||
{userTypeing && publicKey != opponent.publicKey && protocolState == ProtocolState.CONNECTED && <>
|
||||
<Flex gap={5} align={'center'}>
|
||||
<Text c={theme.colors.blue[3]} fz={12}>typing </Text>
|
||||
<Loader size={15} color={theme.colors.blue[3]} type={'dots'}></Loader>
|
||||
</Flex>
|
||||
</>}
|
||||
{protocolState != ProtocolState.CONNECTED &&
|
||||
<Flex gap={'xs'} align={'center'}>
|
||||
<Loader size={8} color={colors.chevrons.active}></Loader>
|
||||
<Text c={theme.colors.gray[5]} fz={12}>connecting...</Text>
|
||||
</Flex>
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
||||
<Tooltip onClick={onClickClearMessages} withArrow position={'bottom'} label={"Clear all messages"}>
|
||||
<IconTrashX
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX>
|
||||
</Tooltip>
|
||||
{publicKey != opponent.publicKey && !blocked && !isSystemAccount && (
|
||||
<Tooltip onClick={onClickBlockUser} withArrow position={'bottom'} label={"Block user"}>
|
||||
<IconLockCancel
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.red[7]} size={24}
|
||||
>
|
||||
</IconLockCancel>
|
||||
</Tooltip>
|
||||
)}
|
||||
{blocked && !isSystemAccount && (
|
||||
<Tooltip onClick={onClickUnblockUser} withArrow position={'bottom'} label={"Unblock user"}>
|
||||
<IconLockAccess
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.green[7]} size={24}
|
||||
>
|
||||
</IconLockAccess>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>}
|
||||
{replyMessages.messages.length > 0 && !replyMessages.inDialogInput && <ReplyHeader></ReplyHeader>}
|
||||
</Box>
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
</>)
|
||||
}
|
||||
33
app/components/CopyButtonIcon/CopyButtonIcon.tsx
Normal file
33
app/components/CopyButtonIcon/CopyButtonIcon.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ActionIcon, Button, CopyButton, MantineSize } from "@mantine/core";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string;
|
||||
caption: string;
|
||||
timeout?: number;
|
||||
size?: MantineSize;
|
||||
fullWidth?: boolean;
|
||||
onClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function CopyButtonIcon(props : CopyButtonProps) {
|
||||
return (
|
||||
<div onClick={props.onClick} style={{
|
||||
...props.style,
|
||||
width: props.fullWidth ? '100%' : undefined,
|
||||
}}>
|
||||
<CopyButton value={props.value} timeout={props.timeout ? props.timeout : 2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Button fullWidth={props.fullWidth} size={props.size} variant={'light'} color={copied ? 'teal' : 'blue'} onClick={copy}>
|
||||
<>
|
||||
<ActionIcon component="span" color={copied ? 'teal' : 'blue'} variant="subtle" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon> {props.caption}
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
app/components/CopyInput/CopyInput.tsx
Normal file
61
app/components/CopyInput/CopyInput.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { MantineSize, Input } from "@mantine/core";
|
||||
import { useState, useEffect } from "react";
|
||||
import { IconCopy, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
export interface CopyInputProps {
|
||||
value: string;
|
||||
caption: string;
|
||||
timeout?: number;
|
||||
size?: MantineSize;
|
||||
fullWidth?: boolean;
|
||||
onClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function CopyInput(props : CopyInputProps) {
|
||||
const { value, caption, timeout = 1200, size = 'sm', fullWidth = true, onClick, style } = props;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const t = setTimeout(() => setCopied(false), timeout);
|
||||
return () => clearTimeout(t);
|
||||
}, [copied, timeout]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
onClick?.();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={handleCopy} style={{ width: fullWidth ? '100%' : undefined, cursor: 'pointer' }}>
|
||||
<Input
|
||||
size={size}
|
||||
value={value}
|
||||
readOnly
|
||||
disabled
|
||||
rightSection={copied ? <IconCheck size={16} color="#2fb344" /> : <IconCopy size={16} />}
|
||||
rightSectionPointerEvents={"none"}
|
||||
placeholder={caption}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
transition: 'background-color 160ms ease, box-shadow 160ms ease',
|
||||
//boxShadow: copied ? '0 0 0 1px rgba(47, 179, 68, 0.4) inset' : undefined,
|
||||
...style,
|
||||
}}
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: copied ? 'rgba(0, 255, 0, 0.15)' : undefined,
|
||||
border: copied ? '1px solid #2fb344' : undefined,
|
||||
color: copied ? '#2fb344' : undefined,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
app/components/Dialog/Dialog.module.css
Normal file
7
app/components/Dialog/Dialog.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.dialogs_wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--mantine-color-dark-light);
|
||||
overflow-y: scroll;
|
||||
user-select: none;
|
||||
}
|
||||
166
app/components/Dialog/Dialog.tsx
Normal file
166
app/components/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Avatar, Badge, Box, Divider, Flex, Loader, Skeleton, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { IconAlertCircle, IconBellOff, IconBookmark, IconCheck, IconChecks, IconClock, IconPin } from "@tabler/icons-react";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
import { dotMessageIfNeeded, isMessageDeliveredByTime } from "@/app/utils/utils";
|
||||
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
|
||||
import { useRef, useState } from "react";
|
||||
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { TextParser } from "../TextParser/TextParser";
|
||||
import { useMemory } from "@/app/providers/MemoryProvider/useMemory";
|
||||
import { DialogRow } from "@/app/providers/DialogListProvider/DialogListProvider";
|
||||
import { useDialogInfo } from "@/app/providers/DialogListProvider/useDialogInfo";
|
||||
import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
|
||||
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
|
||||
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
|
||||
|
||||
export interface DialogProps extends DialogRow {
|
||||
onClickDialog: (dialog: string) => void;
|
||||
}
|
||||
|
||||
export function Dialog(props : DialogProps) {
|
||||
const colors = useRosettaColors();
|
||||
const theme = useMantineTheme();
|
||||
const computedTheme = useComputedColorScheme();
|
||||
const publicKey = usePublicKey();
|
||||
|
||||
/**
|
||||
* Принимает public_key оппонента, для групп
|
||||
* есть отдельный компонент GroupDialog
|
||||
*/
|
||||
const opponent = props.dialog_id;
|
||||
const {isMuted} = useDialogMute(opponent);
|
||||
const {isPinned} = useDialogPin(opponent);
|
||||
|
||||
const [userInfo] = useUserInformation(opponent);
|
||||
|
||||
const {lastMessage, unreaded, loading} = useDialogInfo(props);
|
||||
|
||||
const lastMessageFromMe = lastMessage.from_me == 1;
|
||||
const fromMe = opponent == publicKey;
|
||||
const [userTypeing, setUserTypeing] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(undefined);
|
||||
const avatars = useAvatars(opponent);
|
||||
const [сurrentDialogPublicKeyView] = useMemory("current-dialog-public-key-view", "", true);
|
||||
const {openContextMenu} = useDialogContextMenu();
|
||||
|
||||
const isInCurrentDialog = props.dialog_id == сurrentDialogPublicKeyView;
|
||||
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
|
||||
|
||||
usePacket(0x0B, (packet : PacketTyping) => {
|
||||
if(packet.getFromPublicKey() == opponent && packet.getToPublicKey() == publicKey && !fromMe){
|
||||
console.info("User typeing packet received in Dialog");
|
||||
setUserTypeing(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setUserTypeing(false);
|
||||
}, 3000);
|
||||
}
|
||||
}, [opponent]);
|
||||
|
||||
return (
|
||||
<Box style={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
backgroundColor: isInCurrentDialog ? currentDialogColor : 'unset',
|
||||
}}
|
||||
onClick={() => props.onClickDialog(props.dialog_id)}
|
||||
onContextMenu={() => {
|
||||
openContextMenu(props.dialog_id)
|
||||
}}
|
||||
>
|
||||
<Flex p={'sm'} gap={'sm'}>
|
||||
{
|
||||
fromMe ?
|
||||
<Avatar
|
||||
size={50}
|
||||
color={'blue'}
|
||||
variant={'filled'}
|
||||
>
|
||||
<IconBookmark stroke={2} size={20}></IconBookmark>
|
||||
</Avatar> :
|
||||
<Box style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Avatar src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={userInfo.title} size={50} color={'initials'} />
|
||||
{userInfo.online == OnlineState.ONLINE && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 12,
|
||||
height: 12,
|
||||
backgroundColor: colors.brandColor,
|
||||
borderRadius: '50%',
|
||||
border: computedTheme == 'dark' ? '2px solid #1A1B1E' : '2px solid #FFFFFF',
|
||||
bottom: 4,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
<Flex w={'100%'} justify={'space-between'} direction={'row'} gap={'sm'}>
|
||||
<Flex direction={'column'} gap={3}>
|
||||
<Flex align={'center'} gap={5}>
|
||||
<Text size={'sm'} c={computedTheme == 'light' && !isInCurrentDialog ? 'black' : 'white'} fw={500}>
|
||||
{fromMe ? "Saved messages" : dotMessageIfNeeded(userInfo.title, 15)}
|
||||
</Text>
|
||||
<VerifiedBadge color={isInCurrentDialog ? 'white' : ''} size={15} verified={userInfo.verified}></VerifiedBadge>
|
||||
{isMuted && <IconBellOff color={isInCurrentDialog ? '#fff' : colors.chevrons.active} size={13}></IconBellOff>}
|
||||
{isPinned && <IconPin color={isInCurrentDialog ? '#fff' : colors.chevrons.active} size={13}></IconPin>}
|
||||
</Flex>
|
||||
{!userTypeing && <>
|
||||
<Text component="div" c={
|
||||
isInCurrentDialog ? '#fff' : colors.chevrons.active
|
||||
} size={'xs'} style={{
|
||||
maxWidth: '130px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{loading && <Skeleton height={10} mt={4} width={100}></Skeleton>}
|
||||
{!loading && <TextParser __reserved_1={isInCurrentDialog} noHydrate={true} text={lastMessage.plain_message}></TextParser>}
|
||||
</Text>
|
||||
</>}
|
||||
{userTypeing && <>
|
||||
<Flex gap={5} align={'center'}>
|
||||
<Text c={isInCurrentDialog ? '#fff' : theme.colors.blue[3]} fz={12}>typing </Text>
|
||||
<Loader size={15} color={isInCurrentDialog ? '#fff' : theme.colors.blue[3]} type={'dots'}></Loader>
|
||||
</Flex>
|
||||
</>}
|
||||
</Flex>
|
||||
<Flex direction={'column'} align={'flex-end'} gap={8}>
|
||||
{!loading && (
|
||||
<Text c={сurrentDialogPublicKeyView == props.dialog_id ? '#fff' : colors.chevrons.active} fz={10}>
|
||||
{new Date(lastMessage.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
)}
|
||||
{loading && (
|
||||
<Skeleton height={8} mt={4} width={30}></Skeleton>
|
||||
)}
|
||||
{lastMessage.delivered == DeliveredMessageState.DELIVERED && <>
|
||||
{lastMessageFromMe && unreaded > 0 &&
|
||||
<IconCheck stroke={3} color={isInCurrentDialog ? '#fff' : theme.colors.blue[3]} size={14}></IconCheck>}
|
||||
{lastMessageFromMe && unreaded <= 0 &&
|
||||
<IconChecks stroke={3} color={isInCurrentDialog ? '#fff' : theme.colors.blue[3]} size={14}></IconChecks>}
|
||||
</>}
|
||||
{(lastMessage.delivered == DeliveredMessageState.WAITING && (isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length))) && <>
|
||||
<IconClock stroke={2} size={13} color={theme.colors.gray[5]}></IconClock>
|
||||
</>}
|
||||
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
||||
)}
|
||||
{unreaded > 0 && !lastMessageFromMe && <Badge
|
||||
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
|
||||
c={isInCurrentDialog ? colors.brandColor : 'white'}
|
||||
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider></Divider>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
72
app/components/DialogAffix/DialogAffix.tsx
Normal file
72
app/components/DialogAffix/DialogAffix.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { Box, Flex, Paper, Transition } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface DialogAffixProps {
|
||||
mounted: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function DialogAffix(props : DialogAffixProps) {
|
||||
const {messages} = useDialog();
|
||||
const [updates, setUpdates] = useState(false);
|
||||
const colors = useRosettaColors();
|
||||
const lastMessageTimeRef = useRef(0);
|
||||
//const {isMentioned} = useMentions();
|
||||
//const {hasGroup} = useGroups();
|
||||
const mentionedAffix = false;
|
||||
|
||||
useEffect(() => {
|
||||
if(!props.mounted){
|
||||
setUpdates(false);
|
||||
}
|
||||
if(messages.length === 0){
|
||||
return;
|
||||
}
|
||||
lastMessageTimeRef.current = messages[messages.length - 1].timestamp;
|
||||
}, [props.mounted]);
|
||||
|
||||
useEffect(() => {
|
||||
if(!props.mounted ||
|
||||
(messages.length > 0 && lastMessageTimeRef.current >= messages[messages.length - 1].timestamp)
|
||||
){
|
||||
return;
|
||||
}
|
||||
setUpdates(true);
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<Transition transition="slide-up" mounted={props.mounted}>
|
||||
{(transitionStyles) => (
|
||||
<Paper withBorder pos={'absolute'} bottom={20} right={20} style={{
|
||||
...transitionStyles,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
radius={35} h={35} w={35} onClick={props.onClick}
|
||||
>
|
||||
<Transition transition={'scale'} mounted={updates || mentionedAffix}>
|
||||
{(transitionStyles) => (
|
||||
<Box pos={'absolute'}
|
||||
h={11}
|
||||
w={11}
|
||||
right={-3}
|
||||
top={-3}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
...transitionStyles
|
||||
}}
|
||||
bg={mentionedAffix ? colors.brandColor : colors.error}>
|
||||
</Box>
|
||||
)}
|
||||
</Transition>
|
||||
<Flex h={'100%'} w={'100%'} align={'center'} justify={'center'}>
|
||||
{!mentionedAffix && <IconArrowDown color={'var(--mantine-color-gray-6)'} size={16} />}
|
||||
{mentionedAffix && <IconArrowUp color={'var(--mantine-color-gray-6)'} size={16} />}
|
||||
</Flex>
|
||||
</Paper>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
60
app/components/DialogAttachment/AttachAvatar.tsx
Normal file
60
app/components/DialogAttachment/AttachAvatar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Box, Flex, Paper, Text } from "@mantine/core";
|
||||
import { IconLock, IconX } from "@tabler/icons-react";
|
||||
import { DialogAttachmentProps } from "./DialogAttachment";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
|
||||
|
||||
|
||||
export function AttachAvatar (props : DialogAttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
const {dialog} = useDialog();
|
||||
const {hasGroup} = useGroups();
|
||||
|
||||
return (
|
||||
<Paper withBorder p={'sm'} style={{
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
key={props.attach.id}>
|
||||
<Flex gap={'xs'}>
|
||||
<img style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
userSelect: 'none'
|
||||
}} src={props.attach.blob}>
|
||||
</img>
|
||||
<Flex direction={"column"} justify={"center"}>
|
||||
<Flex direction={"row"} align={"center"} gap={5}>
|
||||
<Text fw={500} fz={'sm'}>{hasGroup(dialog) ? 'Group' : 'Your'} avatar</Text>
|
||||
<IconLock size={14} stroke={2} color={colors.success}></IconLock>
|
||||
</Flex>
|
||||
<Text fz={'xs'} c={'dimmed'}>
|
||||
This avatar will be visible {hasGroup(dialog) ? 'to the group' : 'to your opponent'}.
|
||||
All avatars are end-to-end encrypted.
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{props.onRemove &&
|
||||
<Box bg={colors.error} style={{
|
||||
position: 'absolute',
|
||||
top: -5,
|
||||
right: -5,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
height: 18,
|
||||
width: 18,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => {
|
||||
props.onRemove && props.onRemove(props.attach);
|
||||
}}>
|
||||
<IconX size={13} stroke={2} color="white"></IconX>
|
||||
</Box>
|
||||
}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
85
app/components/DialogAttachment/AttachFile.tsx
Normal file
85
app/components/DialogAttachment/AttachFile.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Box, Flex, Text } from "@mantine/core";
|
||||
import { DialogAttachmentProps } from "./DialogAttachment";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { IconFile, IconFileTypeJpg, IconFileTypeJs, IconFileTypePng, IconFileTypeZip, IconX } from "@tabler/icons-react";
|
||||
import { dotCenterIfNeeded, humanFilesize } from "@/app/utils/utils";
|
||||
|
||||
export function AttachFile(props : DialogAttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
const filesize = parseInt(props.attach.preview.split("::")[0]);
|
||||
const filename = props.attach.preview.split("::")[1];
|
||||
const filetype = filename.split(".")[filename.split(".").length - 1];
|
||||
|
||||
const getIconByFiletype = (type : string) : React.ReactNode => {
|
||||
type = type.trim().toLocaleLowerCase();
|
||||
const iconAttributes = {
|
||||
size: 23,
|
||||
color: colors.chevrons.active
|
||||
}
|
||||
switch(type){
|
||||
case 'js':
|
||||
return <IconFileTypeJs {...iconAttributes}></IconFileTypeJs>
|
||||
case 'jpeg':
|
||||
return <IconFileTypeJpg {...iconAttributes}></IconFileTypeJpg>
|
||||
case 'jpg':
|
||||
return <IconFileTypeJpg {...iconAttributes}></IconFileTypeJpg>
|
||||
case 'png':
|
||||
return <IconFileTypePng {...iconAttributes}></IconFileTypePng>
|
||||
case 'zip':
|
||||
return <IconFileTypeZip {...iconAttributes}></IconFileTypeZip>
|
||||
case '7z':
|
||||
return <IconFileTypeZip {...iconAttributes}></IconFileTypeZip>
|
||||
default:
|
||||
return <IconFile {...iconAttributes}></IconFile>
|
||||
}
|
||||
}
|
||||
|
||||
const icon = getIconByFiletype(filetype);
|
||||
|
||||
return (
|
||||
<Box style={{
|
||||
minWidth: 100,
|
||||
minHeight: 70,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
border: '1px solid ' + colors.borderColor,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.boxColor,
|
||||
flexDirection: 'row',
|
||||
gap: 4
|
||||
}}
|
||||
key={props.attach.id}>
|
||||
{icon}
|
||||
<Flex direction={'column'} gap={4}>
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
{dotCenterIfNeeded(filename, 10)}
|
||||
</Text>
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
{humanFilesize(filesize)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{props.onRemove &&
|
||||
<Box bg={colors.brandColor} style={{
|
||||
position: 'absolute',
|
||||
top: -5,
|
||||
right: -5,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
height: 18,
|
||||
width: 18,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => {
|
||||
props.onRemove && props.onRemove(props.attach);
|
||||
}}>
|
||||
<IconX size={13} stroke={2} color="white"></IconX>
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
44
app/components/DialogAttachment/AttachImage.tsx
Normal file
44
app/components/DialogAttachment/AttachImage.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Box } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { DialogAttachmentProps } from "./DialogAttachment";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
|
||||
|
||||
export function AttachImage (props : DialogAttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
return (
|
||||
<Box style={{
|
||||
maxWidth: 100,
|
||||
maxHeight: 70,
|
||||
position: 'relative'
|
||||
}}
|
||||
key={props.attach.id}>
|
||||
<img style={{
|
||||
maxWidth: 100,
|
||||
maxHeight: 70,
|
||||
borderRadius: 8,
|
||||
userSelect: 'none'
|
||||
}} src={props.attach.blob}>
|
||||
</img>
|
||||
{props.onRemove &&
|
||||
<Box bg={colors.error} style={{
|
||||
position: 'absolute',
|
||||
top: -5,
|
||||
right: -5,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
height: 18,
|
||||
width: 18,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => {
|
||||
props.onRemove && props.onRemove(props.attach);
|
||||
}}>
|
||||
<IconX size={13} stroke={2} color="white"></IconX>
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
44
app/components/DialogAttachment/AttachMessages.tsx
Normal file
44
app/components/DialogAttachment/AttachMessages.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Flex, Text } from "@mantine/core";
|
||||
import { DialogAttachmentProps } from "./DialogAttachment";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { dotMessageIfNeeded } from "@/app/utils/utils";
|
||||
import { TextParser } from "../TextParser/TextParser";
|
||||
|
||||
export function AttachMessages(props : DialogAttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
const {deselectAllMessages} = useReplyMessages();
|
||||
|
||||
const onClickCancel = () => {
|
||||
deselectAllMessages();
|
||||
props.onRemove && props.onRemove(props.attach);
|
||||
}
|
||||
|
||||
const jsonMessages = JSON.parse(props.attach.blob);
|
||||
|
||||
return (
|
||||
<Flex style={{
|
||||
borderLeft: '2px solid ' + colors.brandColor,
|
||||
paddingLeft: 8,
|
||||
borderRadius: 1,
|
||||
}} w={'100%'} justify={'space-between'} align={'center'} gap={8} key={props.attach.id}>
|
||||
<Flex direction={'column'} gap={4}>
|
||||
<Text fz={13} c={colors.brandColor} fw={'bold'}>Reply messages</Text>
|
||||
<Text fz={12} c={'dimmed'} lineClamp={3}>
|
||||
{jsonMessages.length > 1 && <>
|
||||
Reply to {jsonMessages.length} messages
|
||||
</>}
|
||||
{jsonMessages.length == 1 && <>
|
||||
{jsonMessages[0].message.trim().length > 0 ? <TextParser noHydrate text={dotMessageIfNeeded(jsonMessages[0].message.trim(), 40)}></TextParser> : 'Attachment'}
|
||||
</>}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex onClick={onClickCancel} style={{
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
<IconX size={17} stroke={1.1}></IconX>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
29
app/components/DialogAttachment/DialogAttachment.tsx
Normal file
29
app/components/DialogAttachment/DialogAttachment.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
import { AttachImage } from "./AttachImage";
|
||||
import { AttachMessages } from "./AttachMessages";
|
||||
import { AttachFile } from "./AttachFile";
|
||||
import { AttachAvatar } from "./AttachAvatar";
|
||||
|
||||
export interface DialogAttachmentProps {
|
||||
attach: Attachment;
|
||||
onRemove?: (attach: Attachment) => void;
|
||||
}
|
||||
|
||||
export function DialogAttachment(props : DialogAttachmentProps) {
|
||||
return (
|
||||
<>
|
||||
{props.attach.type == AttachmentType.IMAGE &&
|
||||
<AttachImage attach={props.attach} onRemove={props.onRemove}></AttachImage>
|
||||
}
|
||||
{props.attach.type == AttachmentType.MESSAGES &&
|
||||
<AttachMessages attach={props.attach} onRemove={props.onRemove}></AttachMessages>
|
||||
}
|
||||
{props.attach.type == AttachmentType.FILE &&
|
||||
<AttachFile attach={props.attach} onRemove={props.onRemove}></AttachFile>
|
||||
}
|
||||
{props.attach.type == AttachmentType.AVATAR &&
|
||||
<AttachAvatar attach={props.attach} onRemove={props.onRemove}></AttachAvatar>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
420
app/components/DialogInput/DialogInput.tsx
Normal file
420
app/components/DialogInput/DialogInput.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
|
||||
import { base64ImageToBlurhash, filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
|
||||
import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
import { DialogAttachment } from "../DialogAttachment/DialogAttachment";
|
||||
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { usePrivateKeyHash } from "@/app/providers/AccountProvider/usePrivateKeyHash";
|
||||
import { useSender } from "@/app/providers/ProtocolProvider/useSender";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { useFileDialog, useHotkeys } from "@mantine/hooks";
|
||||
import { ATTACHMENTS_NOT_ALLOWED_TO_REPLY, MAX_ATTACHMENTS_IN_MESSAGE, MAX_UPLOAD_FILESIZE_MB } from "@/app/constants";
|
||||
import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts";
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { RichTextInput } from "../RichTextInput/RichTextInput";
|
||||
import EmojiPicker, { EmojiClickData, Theme } from 'emoji-picker-react';
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
|
||||
import { useGroupMembers } from "@/app/providers/InformationProvider/useGroupMembers";
|
||||
import { AnimatedButton } from "../AnimatedButton/AnimatedButton";
|
||||
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
|
||||
import { MentionList, Mention } from "../MentionList/MentionList";
|
||||
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
|
||||
|
||||
|
||||
export function DialogInput() {
|
||||
const colors = useRosettaColors();
|
||||
const {sendMessage, dialog} = useDialog();
|
||||
const {members, loading} = useGroupMembers(dialog);
|
||||
const {hasGroup, leaveGroup} = useGroups();
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [blocked] = useBlacklist(dialog);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const publicKey = usePublicKey();
|
||||
const isAdmin = hasGroup(dialog) && members[0] == publicKey;
|
||||
const isBannedGroup = hasGroup(dialog) && members.length == 0 && !loading;
|
||||
const privateKey = usePrivateKeyHash();
|
||||
const sendPacket = useSender();
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const {replyMessages, deselectAllMessages} = useReplyMessages();
|
||||
const computedTheme = useComputedColorScheme();
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const [mentionList, setMentionList] = useState<Mention[]>([]);
|
||||
const mentionHandling = useRef<string>("");
|
||||
const {getDraft, saveDraft} = useDrafts(dialog);
|
||||
|
||||
|
||||
const avatars = useAvatars(
|
||||
hasGroup(dialog) ? dialog : publicKey
|
||||
, false);
|
||||
|
||||
const editableDivRef = useRef<any>(null);
|
||||
const getUserFromCache = useUserCacheFunc();
|
||||
const regexp = new RegExp(/@([\w\d_]{3,})$/);
|
||||
|
||||
useHotkeys([
|
||||
['Esc', () => {
|
||||
setAttachments([]);
|
||||
}]
|
||||
], [], true);
|
||||
|
||||
const fileDialog = useFileDialog({
|
||||
multiple: false,
|
||||
//naccept: '*',
|
||||
onChange: async (files) => {
|
||||
if(!files){
|
||||
return;
|
||||
}
|
||||
if(files.length == 0){
|
||||
return;
|
||||
}
|
||||
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
|
||||
return;
|
||||
}
|
||||
if(attachments.find(a => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(a.type))){
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
if(((file.size / 1024) / 1024) > MAX_UPLOAD_FILESIZE_MB){
|
||||
return;
|
||||
}
|
||||
const fileContent = await filePrapareForNetworkTransfer(file);
|
||||
setAttachments([...attachments, {
|
||||
blob: fileContent,
|
||||
id: generateRandomKey(8),
|
||||
type: AttachmentType.FILE,
|
||||
preview: files[0].size + "::" + files[0].name
|
||||
}]);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const draftMessage = getDraft();
|
||||
console.info("GET DRAFT", draftMessage);
|
||||
if(draftMessage == "" || !editableDivRef){
|
||||
return;
|
||||
}
|
||||
setMessage(draftMessage);
|
||||
editableDivRef.current.insertHTMLInCurrentCarretPosition(draftMessage);
|
||||
}, [dialog, editableDivRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if(replyMessages.inDialogInput && replyMessages.inDialogInput == dialog){
|
||||
setAttachments([{
|
||||
type: AttachmentType.MESSAGES,
|
||||
id: generateRandomKey(8),
|
||||
blob: JSON.stringify([...replyMessages.messages]),
|
||||
preview: ""
|
||||
}]);
|
||||
editableDivRef.current.focus();
|
||||
}
|
||||
}, [dialog, replyMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
saveDraft(message);
|
||||
if(regexp.test(message) && hasGroup(dialog)){
|
||||
const username = regexp.exec(message);
|
||||
if(!username){
|
||||
return;
|
||||
}
|
||||
if(username[1].length > 2){
|
||||
handleMention(username[1]);
|
||||
}
|
||||
}else{
|
||||
setMentionList([]);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
|
||||
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const handleMention = async (username: string) => {
|
||||
const regexpToFindAllMentionedUsernames = new RegExp(`@([\\w\\d_]{2,})`, 'g');
|
||||
const mentionedUsernamesInMessage = message.match(regexpToFindAllMentionedUsernames);
|
||||
|
||||
|
||||
const mentionsList : Mention[] = [];
|
||||
|
||||
if(!isAdmin && username.startsWith('adm') && (mentionedUsernamesInMessage && !mentionedUsernamesInMessage.includes('@admin'))){
|
||||
mentionsList.push({
|
||||
username: 'admin',
|
||||
title: 'Administrator',
|
||||
publicKey: ''
|
||||
});
|
||||
}
|
||||
|
||||
for(let i = 0; i < members.length; i++){
|
||||
const userInfo = await getUserFromCache(members[i]);
|
||||
if(!userInfo){
|
||||
continue;
|
||||
}
|
||||
if(!userInfo.username.startsWith(username)){
|
||||
continue;
|
||||
}
|
||||
if(mentionedUsernamesInMessage && mentionedUsernamesInMessage.includes(`@${userInfo.username}`)){
|
||||
continue;
|
||||
}
|
||||
mentionsList.push({
|
||||
username: userInfo.username,
|
||||
title: userInfo.title,
|
||||
publicKey: userInfo.publicKey
|
||||
});
|
||||
}
|
||||
setMentionList(mentionsList);
|
||||
mentionHandling.current = username;
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
if(blocked || (message.trim() == "" && attachments.length <= 0)) {
|
||||
return;
|
||||
}
|
||||
sendMessage(message, attachments);
|
||||
editableDivRef.current.clear();
|
||||
setAttachments([]);
|
||||
deselectAllMessages();
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if(mentionList.length <= 0){
|
||||
send();
|
||||
}
|
||||
}
|
||||
if (!event.shiftKey && event.key.length === 1 && !blocked) {
|
||||
if (!typingTimeoutRef.current) {
|
||||
sendTypeingPacket();
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
typingTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveAttachment = (attachment: Attachment) => {
|
||||
setAttachments(attachments.filter(a => a.id != attachment.id));
|
||||
editableDivRef.current.focus();
|
||||
}
|
||||
|
||||
const onClickPaperclip = () => {
|
||||
fileDialog.open();
|
||||
}
|
||||
|
||||
const onClickCamera = async () => {
|
||||
if(avatars.length == 0){
|
||||
return;
|
||||
}
|
||||
setAttachments([{
|
||||
blob: avatars[0].avatar,
|
||||
id: generateRandomKey(8),
|
||||
type: AttachmentType.AVATAR,
|
||||
preview: await base64ImageToBlurhash(avatars[0].avatar)
|
||||
}]);
|
||||
editableDivRef.current.focus();
|
||||
}
|
||||
|
||||
const sendTypeingPacket = () => {
|
||||
let packet = new PacketTyping();
|
||||
packet.setToPublicKey(dialog);
|
||||
packet.setFromPublicKey(publicKey);
|
||||
packet.setPrivateKey(privateKey);
|
||||
sendPacket(packet);
|
||||
}
|
||||
|
||||
const onPaste = async (event: React.ClipboardEvent) => {
|
||||
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
|
||||
return;
|
||||
}
|
||||
if(attachments.find(a => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(a.type))){
|
||||
return;
|
||||
}
|
||||
const items = event.clipboardData.items;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
const base64Image = await imagePrepareForNetworkTransfer(file);
|
||||
setAttachments([...attachments, {
|
||||
blob: base64Image,
|
||||
id: generateRandomKey(8),
|
||||
type: AttachmentType.IMAGE,
|
||||
preview: await base64ImageToBlurhash(base64Image)
|
||||
}]);
|
||||
}
|
||||
editableDivRef.current.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onDragAndDrop = async (files : File[]) => {
|
||||
if(!files){
|
||||
return;
|
||||
}
|
||||
if(files.length == 0){
|
||||
return;
|
||||
}
|
||||
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
|
||||
return;
|
||||
}
|
||||
if(files.length > 1){
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
if(((file.size / 1024) / 1024) > MAX_UPLOAD_FILESIZE_MB){
|
||||
return;
|
||||
}
|
||||
let fileContent = await filePrapareForNetworkTransfer(file);
|
||||
setAttachments([...attachments, {
|
||||
blob: fileContent,
|
||||
id: generateRandomKey(8),
|
||||
type: AttachmentType.FILE,
|
||||
preview: files[0].size + "::" + files[0].name
|
||||
}]);
|
||||
}
|
||||
|
||||
const onEmojiClick = (emojiData : EmojiClickData) => {
|
||||
editableDivRef.current.insertHTML(
|
||||
`<img alt=":emoji_${emojiData.unified}:" height="20" width="20" data=":emoji_${emojiData.unified}:" src="${emojiData.imageUrl}" />`
|
||||
);
|
||||
}
|
||||
|
||||
const onSelectMention = (mention : Mention) => {
|
||||
editableDivRef.current.insertHTMLInCurrentCarretPosition(mention.username.substring(mentionHandling.current.length));
|
||||
mentionHandling.current = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition
|
||||
transition={'fade-down'}
|
||||
mounted={mentionList.length > 0}>
|
||||
{(styles) => (
|
||||
<MentionList onSelectMention={onSelectMention} style={{...styles}} mentions={mentionList}></MentionList>
|
||||
)}
|
||||
</Transition>
|
||||
{attachments.length > 0 &&
|
||||
<Flex direction={'row'} wrap={'wrap'} p={'sm'} gap={'sm'}>
|
||||
{attachments.map((m) => (
|
||||
<DialogAttachment onRemove={onRemoveAttachment} attach={m} key={m.id}></DialogAttachment>
|
||||
))}
|
||||
</Flex>}
|
||||
<Dropzone.FullScreen active={true} onDrop={onDragAndDrop}>
|
||||
<Flex justify={'center'} align={'center'} mih={document.body.clientHeight - 60} style={{ pointerEvents: 'none' }}>
|
||||
<Text ta={'center'} size={'md'}>Drop files here to attach without compression</Text>
|
||||
</Flex>
|
||||
</Dropzone.FullScreen>
|
||||
{!isBannedGroup && (
|
||||
<Box bg={colors.boxColor}>
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
{!blocked &&
|
||||
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
|
||||
<Flex w={25} mt={10} justify={'center'}>
|
||||
<Menu width={150} withArrow>
|
||||
<Menu.Target>
|
||||
<IconPaperclip stroke={1.5} style={{
|
||||
cursor: 'pointer'
|
||||
}} size={25} color={colors.chevrons.active}></IconPaperclip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
userSelect: 'none'
|
||||
}}>
|
||||
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
|
||||
<Menu.Item fz={'xs'} fw={500} leftSection={
|
||||
<IconFile size={14}></IconFile>
|
||||
} onClick={onClickPaperclip}>File</Menu.Item>
|
||||
{((avatars.length > 0 && !hasGroup(dialog))
|
||||
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
|
||||
&& <Menu.Item fz={'xs'} fw={500} leftSection={
|
||||
<IconCamera size={14}></IconCamera>
|
||||
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
<Flex
|
||||
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
|
||||
align={'center'}
|
||||
>
|
||||
<RichTextInput
|
||||
ref={editableDivRef}
|
||||
style={{
|
||||
border: 0,
|
||||
minHeight: 45,
|
||||
fontSize: 14,
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
outline: 'none',
|
||||
paddingTop: 10,
|
||||
paddingBottom: 8
|
||||
}}
|
||||
placeholder="Type message..."
|
||||
autoFocus
|
||||
//ref={textareaRef}
|
||||
//onPaste={onPaste}
|
||||
//maxLength={2500}
|
||||
//w={'100%'}
|
||||
//h={'100%'}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={setMessage}
|
||||
onPaste={onPaste}
|
||||
|
||||
//dangerouslySetInnerHTML={{__html: message}}
|
||||
></RichTextInput>
|
||||
</Flex>
|
||||
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
|
||||
<Popover withArrow>
|
||||
<Popover.Target>
|
||||
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
|
||||
cursor: 'pointer'
|
||||
}}></IconMoodSmile>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<EmojiPicker
|
||||
onEmojiClick={onEmojiClick}
|
||||
searchDisabled
|
||||
skinTonesDisabled
|
||||
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{
|
||||
cursor: 'pointer'
|
||||
}} size={25}></IconSend>
|
||||
</Flex>
|
||||
</Flex>}
|
||||
{blocked && <Box mih={62} bg={colors.boxColor}>
|
||||
<Flex align={'center'} justify={'center'} h={62}>
|
||||
<Flex gap={'sm'} align={'center'} justify={'center'}>
|
||||
<IconBarrierBlock size={24} color={colors.error} stroke={1.2}></IconBarrierBlock>
|
||||
<Text ta={'center'} c={'dimmed'} size={'xs'}>You need unblock user for send messages.</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>}
|
||||
</Box>
|
||||
)}
|
||||
{isBannedGroup && (
|
||||
<Flex align={'center'} bg={colors.boxColor} justify={'center'}>
|
||||
<AnimatedButton onClick={() => {
|
||||
leaveGroup(dialog)
|
||||
}} animated={[
|
||||
'#ff5656',
|
||||
'#e03131'
|
||||
]} color="red" animationDurationMs={1000} w={'80%'} size={'sm'} radius={'xl'} leftSection={
|
||||
<IconDoorExit size={15} />
|
||||
} mb={'md'}>Leave</AnimatedButton>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
56
app/components/DialogsList/DialogsList.tsx
Normal file
56
app/components/DialogsList/DialogsList.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Dialog } from "../Dialog/Dialog";
|
||||
import Lottie from "lottie-react";
|
||||
import animationData from './lottie.json';
|
||||
import { Box, Flex, Skeleton, Text } from "@mantine/core";
|
||||
import { useDialogsList } from "@/app/providers/DialogListProvider/useDialogsList";
|
||||
import { GroupDialog } from "../GroupDialog/GroupDialog";
|
||||
import React from "react";
|
||||
|
||||
interface DialogsListProps {
|
||||
mode: 'all' | 'requests';
|
||||
onSelectDialog: (publicKey: string) => void;
|
||||
}
|
||||
|
||||
export function DialogsList(props : DialogsListProps) {
|
||||
const {dialogs, loadingDialogs} = useDialogsList();
|
||||
|
||||
return (
|
||||
<>
|
||||
{loadingDialogs === 0 && dialogs.filter(v => (v.is_request == (props.mode == 'requests'))).length <= 0 && (
|
||||
<Flex align={'center'} mt={100} direction={'column'} justify={'center'}>
|
||||
<Lottie style={{
|
||||
width: 90,
|
||||
height: 90
|
||||
}} loop={true} animationData={animationData}></Lottie>
|
||||
<Text mt={'sm'} c={'dimmed'} fz={13}>
|
||||
Write to someone
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{loadingDialogs > 0 && (
|
||||
<>
|
||||
{Array.from({ length: loadingDialogs }).map((_, index) => (
|
||||
<Box w={'100%'} h={74} key={index}>
|
||||
<Skeleton height={74} radius={0} mb={index == loadingDialogs - 1 ? 0 : 1} />
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{loadingDialogs === 0 && dialogs.filter(v => (v.is_request == (props.mode == 'requests'))).map((dialog) => (
|
||||
<React.Fragment key={dialog.dialog_id}>
|
||||
{dialog.dialog_id.startsWith('#group:') ? (
|
||||
<GroupDialog
|
||||
onClickDialog={props.onSelectDialog}
|
||||
{...dialog}
|
||||
/>
|
||||
) : (
|
||||
<Dialog
|
||||
onClickDialog={props.onSelectDialog}
|
||||
{...dialog}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
app/components/DialogsList/lottie.json
Normal file
1
app/components/DialogsList/lottie.json
Normal file
File diff suppressed because one or more lines are too long
0
app/components/DialogsPanel/DialogsPanel.module.css
Normal file
0
app/components/DialogsPanel/DialogsPanel.module.css
Normal file
73
app/components/DialogsPanel/DialogsPanel.tsx
Normal file
73
app/components/DialogsPanel/DialogsPanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Box, Divider, Flex, ScrollArea } from '@mantine/core';
|
||||
import { RequestsButton } from '../RequestsButton/RequestsButton';
|
||||
import { UserButton } from '../UserButton/UserButton';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRosettaColors } from '@/app/hooks/useRosettaColors';
|
||||
import { UpdateAlert } from '../UpdateAlert/UpdateAlert';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DialogsList } from '../DialogsList/DialogsList';
|
||||
import { DialogsPanelHeader } from '../DialogsPanelHeader/DialogsPanelHeader';
|
||||
import { useDialogsList } from '@/app/providers/DialogListProvider/useDialogsList';
|
||||
|
||||
export function DialogsPanel() {
|
||||
const [dialogsMode, setDialogsMode] = useState<'all' | 'requests'>('all');
|
||||
const [requestsCount, setRequestsCount] = useState(0);
|
||||
const {dialogs} = useDialogsList();
|
||||
const colors = useRosettaColors();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
((async () => {
|
||||
let requests = dialogs.filter(d => d.is_request);
|
||||
setRequestsCount(requests.length);
|
||||
if(requests.length == 0 && dialogsMode == 'requests'){
|
||||
setDialogsMode('all');
|
||||
}
|
||||
}))();
|
||||
}, [dialogs]);
|
||||
|
||||
const changeDialogMode = () => {
|
||||
if(dialogsMode == 'all'){
|
||||
setDialogsMode('requests');
|
||||
} else {
|
||||
setDialogsMode('all');
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectDialog = async (dialog: string) => {
|
||||
console.info("[PT] SELECT DIALOG ", Date.now());
|
||||
navigate(`/main/chat/${dialog.replace("#", "%23")}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
style={{
|
||||
minWidth: 300,
|
||||
minHeight: 'calc(100vh - 30px)',
|
||||
maxHeight: 'calc(100vh - 30px)'
|
||||
}}
|
||||
direction={'column'}
|
||||
justify={'space-between'}
|
||||
>
|
||||
<Box>
|
||||
<DialogsPanelHeader></DialogsPanelHeader>
|
||||
{requestsCount > 0 && <RequestsButton mode={dialogsMode} onClick={changeDialogMode} count={requestsCount}></RequestsButton>}
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
</Box>
|
||||
<ScrollArea.Autosize scrollbarSize={5} style={{
|
||||
flexGrow: 1,
|
||||
display: 'flex'
|
||||
}}>
|
||||
<Flex direction={'column'}>
|
||||
<DialogsList onSelectDialog={onSelectDialog} mode={dialogsMode}>
|
||||
</DialogsList>
|
||||
</Flex>
|
||||
</ScrollArea.Autosize>
|
||||
<Box>
|
||||
<UpdateAlert></UpdateAlert>
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
<UserButton></UserButton>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
112
app/components/DialogsPanelHeader/DialogsPanelHeader.tsx
Normal file
112
app/components/DialogsPanelHeader/DialogsPanelHeader.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Flex, Menu, Text } from "@mantine/core";
|
||||
import { IconBuildingBroadcastTower, IconDoorExit, IconEdit, IconNote, IconPalette, IconPencil, IconUser, IconUsersGroup } from "@tabler/icons-react";
|
||||
import { DialogsSearch } from "../DialogsSearch/DialogsSearch";
|
||||
import { useLogout } from "@/app/providers/AccountProvider/useLogout";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
|
||||
export function DialogsPanelHeader() {
|
||||
const colors = useRosettaColors();
|
||||
const logout = useLogout();
|
||||
const navigate = useNavigate();
|
||||
const publicKey = usePublicKey();
|
||||
const viewKeys = window.platform == 'darwin' ? '⌘' : 'Ctrl+';
|
||||
const triggerKeys = window.platform == 'darwin' ? 'mod' : 'Ctrl';
|
||||
|
||||
useHotkeys([
|
||||
[`${triggerKeys}+L`, () => logout()],
|
||||
[`${triggerKeys}+P`, () => navigate('/main/profile/me')],
|
||||
], [], true);
|
||||
|
||||
return (
|
||||
<Flex direction={'column'}>
|
||||
<Flex direction={'row'} p={'sm'} justify={'space-between'} align={'center'}>
|
||||
<Menu withArrow width={150} shadow="md">
|
||||
<Menu.Target>
|
||||
<IconUser style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.3} size={20} color={colors.brandColor}></IconUser>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
userSelect: 'none'
|
||||
}} fz={'xs'} fw={500}>
|
||||
<Menu.Label>Profile</Menu.Label>
|
||||
<Menu.Item
|
||||
fz={'xs'}
|
||||
onClick={() => navigate('/main/profile/me')}
|
||||
leftSection={<IconPencil color={colors.brandColor} size={14} />}
|
||||
rightSection={
|
||||
<Text size="xs" c="dimmed">
|
||||
{viewKeys}P
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
fz={'xs'}
|
||||
onClick={() => navigate('/main/theme')}
|
||||
leftSection={<IconPalette color={colors.brandColor} size={14} />}
|
||||
>
|
||||
Theme
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
fz={'xs'}
|
||||
onClick={logout}
|
||||
leftSection={<IconDoorExit color={colors.error} size={14} />}
|
||||
rightSection={
|
||||
<Text size="xs" c="dimmed">
|
||||
{viewKeys}L
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
Lock
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Text fw={500} style={{
|
||||
userSelect: 'none'
|
||||
}} size={'sm'}>Chats</Text>
|
||||
<Menu withArrow width={150} shadow="md">
|
||||
<Menu.Target>
|
||||
<IconEdit style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.3} size={22} color={colors.brandColor}></IconEdit>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
userSelect: 'none'
|
||||
}} fz={'xs'} fw={500}>
|
||||
<Menu.Label>Write</Menu.Label>
|
||||
<Menu.Item
|
||||
fz={'xs'}
|
||||
onClick={() => navigate('/main/chat/' + publicKey)}
|
||||
leftSection={<IconNote color={colors.brandColor} size={14} />}
|
||||
>
|
||||
Note
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
fz={'xs'}
|
||||
onClick={() => navigate('/main/newgroup')}
|
||||
leftSection={<IconUsersGroup color={colors.brandColor} size={14} />}
|
||||
>
|
||||
Group chat
|
||||
</Menu.Item>
|
||||
<Menu.Item disabled
|
||||
fz={'xs'}
|
||||
//onClick={() => navigate('/main/chat/' + publicKey)}
|
||||
leftSection={<IconBuildingBroadcastTower color={colors.brandColor} size={14} />}
|
||||
>
|
||||
Channel
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
<Flex w={'100%'} justify={'center'} align={'center'}
|
||||
pl={'sm'} pr={'sm'} pb={'xs'}>
|
||||
<DialogsSearch></DialogsSearch>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
136
app/components/DialogsSearch/DialogsSearch.tsx
Normal file
136
app/components/DialogsSearch/DialogsSearch.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { PacketSearch, PacketSearchUser } from "@/app/providers/ProtocolProvider/protocol/packets/packet.search";
|
||||
import { Avatar, Box, Flex, Loader, Popover, Text } from "@mantine/core";
|
||||
import { IconBookmark } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { usePrivateKeyHash } from "@/app/providers/AccountProvider/usePrivateKeyHash";
|
||||
import { useSender } from "@/app/providers/ProtocolProvider/useSender";
|
||||
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
|
||||
import { DEVTOOLS_CHEATCODE } from "@/app/constants";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState";
|
||||
import InputCustomPlaceholder from "../InputCustomPlaceholder/InputCustomPlaceholder";
|
||||
import { SearchRow } from "../SearchRow/SearchRow";
|
||||
|
||||
export function DialogsSearch() {
|
||||
const publicKey = usePublicKey();
|
||||
const privateKey = usePrivateKeyHash();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const send = useSender();
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const [viewState] = useViewPanelsState();
|
||||
const [searchResults, setSearchResults] = useState<PacketSearchUser[]>([]);
|
||||
const colors = useRosettaColors();
|
||||
|
||||
useEffect(() => {
|
||||
if(searchValue.trim() == DEVTOOLS_CHEATCODE){
|
||||
window.electron.ipcRenderer.invoke('open-dev-tools');
|
||||
}
|
||||
}, [searchValue]);
|
||||
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.value.
|
||||
replace("@", "");
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
if (value.trim() === "") {
|
||||
if(timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
setSearchResults([]);
|
||||
setOpened(false);
|
||||
setSearchValue(value);
|
||||
return;
|
||||
}
|
||||
setOpened(true);
|
||||
setLoading(true);
|
||||
setSearchValue(value);
|
||||
timeout.current = setTimeout(() => {
|
||||
let packetSearch = new PacketSearch();
|
||||
packetSearch.setSearch(value);
|
||||
packetSearch.setPrivateKey(privateKey);
|
||||
send(packetSearch);
|
||||
}, 1000);
|
||||
|
||||
}
|
||||
|
||||
usePacket(0x03, (packet : PacketSearch) => {
|
||||
setSearchResults(packet.getUsers());
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const onDialogClick = (publicKey : string) => {
|
||||
setOpened(false);
|
||||
navigate(`/main/chat/${publicKey}`);
|
||||
setSearchValue("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{
|
||||
borderRadius: 8,
|
||||
}} bg={colors.mainColor} w={'100%'}>
|
||||
<Popover
|
||||
opened={(lg || viewState == ViewPanelsState.DIALOGS_PANEL_ONLY) && opened}
|
||||
onClose={() => setOpened(false)}
|
||||
width={'target'}
|
||||
shadow="md"
|
||||
clickOutsideEvents={['mouseup', 'touchend']}
|
||||
//withOverlay
|
||||
withArrow
|
||||
overlayProps={{ zIndex: 10000, blur: '3px' }}
|
||||
zIndex={10001}
|
||||
position="bottom"
|
||||
>
|
||||
<Popover.Target>
|
||||
<InputCustomPlaceholder onBlur={() => setOpened(false)} onChange={handleSearch}></InputCustomPlaceholder>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
{!loading && searchResults.length === 0 && (
|
||||
<Text fz={12} c="dimmed" p={'sm'} ta={'center'}>
|
||||
You can search by username or public key.
|
||||
</Text>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex align={'center'} justify={'center'} p={10}>
|
||||
<Loader size={20} color={colors.chevrons.active}></Loader>
|
||||
</Flex>
|
||||
)}
|
||||
{searchResults.length > 0 && !loading &&
|
||||
(<Flex direction={'column'} p={0}>
|
||||
{searchResults.map((user, index) => (
|
||||
<div key={index}>
|
||||
{user.publicKey !== publicKey && (
|
||||
<SearchRow user={user} onDialogClick={onDialogClick}></SearchRow>
|
||||
)}
|
||||
{user.publicKey === publicKey && (
|
||||
<Flex onClick={() => onDialogClick(user.publicKey)} p={'sm'} direction={'row'} gap={'sm'}>
|
||||
<Avatar
|
||||
size={'md'}
|
||||
color={'blue'}
|
||||
variant={'filled'}
|
||||
>
|
||||
<IconBookmark stroke={2} size={20}></IconBookmark>
|
||||
</Avatar>
|
||||
<Flex direction={'column'}>
|
||||
<Text fz={12}>Saved messages</Text>
|
||||
<Text fz={10} c="dimmed">Notes</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Flex>)
|
||||
}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
90
app/components/DiceDropdown/DiceDropdown.tsx
Normal file
90
app/components/DiceDropdown/DiceDropdown.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AccountBase } from "@/app/providers/AccountProvider/AccountProvider";
|
||||
import { useAccountProvider } from "@/app/providers/AccountProvider/useAccountProvider";
|
||||
import { Avatar, Box, Flex, Popover, Text } from "@mantine/core";
|
||||
import { UserAccountSelect } from "../UserAccountSelect/UserAccountSelect";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DiceDropdownProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: (accountBase: AccountBase) => void;
|
||||
selectedPublicKey?: string;
|
||||
}
|
||||
|
||||
export function DiceDropdown(props: DiceDropdownProps) {
|
||||
const { allAccounts } = useAccountProvider();
|
||||
const navigate = useNavigate();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const createAccount = () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Create account',
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
You may be create new account or import existing
|
||||
</Text>
|
||||
),
|
||||
withCloseButton: false,
|
||||
labels: { confirm: 'Create new', cancel: "Import" },
|
||||
cancelProps: {
|
||||
autoFocus: false,
|
||||
style: {
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
navigate("/exists-seed");
|
||||
},
|
||||
onConfirm: () => {
|
||||
navigate("/create-seed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover transitionProps={{
|
||||
transition: 'pop-top-right'
|
||||
}} withArrow opened={opened} onChange={setOpened} closeOnEscape closeOnClickOutside width={150}>
|
||||
<Popover.Target>
|
||||
<Box onClick={() => setOpened(!opened)}>
|
||||
{props.children}
|
||||
</Box>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<Box style={{
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{allAccounts.map((accountBase: AccountBase) => {
|
||||
return (<UserAccountSelect
|
||||
key={accountBase.publicKey}
|
||||
accountBase={accountBase}
|
||||
selected={props.selectedPublicKey == accountBase.publicKey}
|
||||
onClick={() => {
|
||||
if (props.onClick) {
|
||||
props.onClick(accountBase);
|
||||
}
|
||||
setOpened(false);
|
||||
}}></UserAccountSelect>)
|
||||
})}
|
||||
<Flex direction={'row'} style={{
|
||||
cursor: 'pointer'
|
||||
}} onClick={() => {
|
||||
createAccount();
|
||||
setOpened(false);
|
||||
}} pl={'xs'} pr={'xs'} pt={10} pb={10} gap={'xs'} align={'center'}>
|
||||
<Avatar size={20} color="green">
|
||||
<IconPlus size={14} />
|
||||
</Avatar>
|
||||
<Flex direction={'column'}>
|
||||
<Text fw={500} size="xs">New account</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
40
app/components/Emoji/Emoji.tsx
Normal file
40
app/components/Emoji/Emoji.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface EmojiProps {
|
||||
unified: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Emoji(props: EmojiProps) {
|
||||
const [error, setError] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const handleError = () => setError(true);
|
||||
const handleLoad = () => setLoaded(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span style={{ userSelect: 'auto' }}>
|
||||
{!error && (
|
||||
<>
|
||||
<img
|
||||
style={{
|
||||
width: props.size ? `${props.size}px` : '1.4em',
|
||||
height: props.size ? `${props.size}px` : '1.4em',
|
||||
verticalAlign: 'sub',
|
||||
marginLeft: '2px',
|
||||
marginRight: '2px',
|
||||
// display: loaded ? 'inline' : 'none'
|
||||
}}
|
||||
onError={handleError}
|
||||
onLoad={handleLoad}
|
||||
src={`https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${props.unified}.png`}
|
||||
alt={props.unified}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{error && props.unified}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
170
app/components/GroupDialog/GroupDialog.tsx
Normal file
170
app/components/GroupDialog/GroupDialog.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Avatar, Badge, Box, Divider, Flex, Loader, Skeleton, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { IconAlertCircle, IconBellOff, IconCheck, IconChecks, IconClock, IconPin, IconUsers } from "@tabler/icons-react";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
import { dotMessageIfNeeded, isMessageDeliveredByTime } from "@/app/utils/utils";
|
||||
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { TextParser } from "../TextParser/TextParser";
|
||||
import { useMemory } from "@/app/providers/MemoryProvider/useMemory";
|
||||
import { DialogRow } from "@/app/providers/DialogListProvider/DialogListProvider";
|
||||
import { useGroupInformation } from "@/app/providers/InformationProvider/useGroupInformation";
|
||||
import { useDialogInfo } from "@/app/providers/DialogListProvider/useDialogInfo";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { useDialogContextMenu } from "@/app/hooks/useDialogContextMenu";
|
||||
import { useDialogMute } from "@/app/providers/DialogStateProvider.tsx/useDialogMute";
|
||||
import { useDialogPin } from "@/app/providers/DialogStateProvider.tsx/useDialogPin";
|
||||
import { useMentions } from "@/app/providers/DialogStateProvider.tsx/useMentions";
|
||||
|
||||
export interface DialogProps extends DialogRow {
|
||||
onClickDialog: (dialog: string) => void;
|
||||
}
|
||||
|
||||
export function GroupDialog(props : DialogProps) {
|
||||
const colors = useRosettaColors();
|
||||
const theme = useMantineTheme();
|
||||
const computedTheme = useComputedColorScheme();
|
||||
|
||||
/**
|
||||
* Принимает #group:group_id, для
|
||||
* диалогов между пользователями есть просто public_key собеседника
|
||||
*/
|
||||
const groupId = props.dialog_id;
|
||||
const {isMuted} = useDialogMute(groupId);
|
||||
const {isPinned} = useDialogPin(groupId);
|
||||
|
||||
const {groupInfo} = useGroupInformation(groupId);
|
||||
|
||||
const {lastMessage, unreaded, loading} = useDialogInfo(props);
|
||||
const lastMessageFromMe = lastMessage.from_me == 1;
|
||||
|
||||
const [usersTypeing, setUsersTypeing] = useState<{
|
||||
timeout: NodeJS.Timeout | null,
|
||||
fromPublicKey: string
|
||||
}[]>([]);
|
||||
|
||||
const avatars = useAvatars(groupId);
|
||||
const [сurrentDialogPublicKeyView] = useMemory("current-dialog-public-key-view", "", true);
|
||||
const [userInfo] = useUserInformation(lastMessage.from_public_key);
|
||||
const [typingUser] = useUserInformation(usersTypeing[0]?.fromPublicKey || '');
|
||||
|
||||
const isInCurrentDialog = props.dialog_id == сurrentDialogPublicKeyView;
|
||||
const currentDialogColor = computedTheme == 'dark' ? '#2a6292' :'#438fd1';
|
||||
const {openContextMenu} = useDialogContextMenu();
|
||||
const {isMentioned} = useMentions();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
clearUsersTypeing();
|
||||
}, [props.dialog_id]);
|
||||
|
||||
const clearUsersTypeing = () => {
|
||||
usersTypeing.forEach(ut => {
|
||||
if(ut.timeout){
|
||||
clearTimeout(ut.timeout);
|
||||
}
|
||||
});
|
||||
setUsersTypeing([]);
|
||||
}
|
||||
|
||||
usePacket(0x0B, (packet : PacketTyping) => {
|
||||
if(packet.getToPublicKey() == props.dialog_id){
|
||||
setUsersTypeing((prev) => [...prev, {
|
||||
fromPublicKey: packet.getFromPublicKey(),
|
||||
timeout: setTimeout(() => {
|
||||
setUsersTypeing((prev) => {
|
||||
return prev.filter(ut => ut.fromPublicKey != packet.getFromPublicKey());
|
||||
});
|
||||
}, 3000)
|
||||
}]);
|
||||
}
|
||||
}, [props.dialog_id]);
|
||||
|
||||
return (
|
||||
<Box style={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
backgroundColor: isInCurrentDialog ? currentDialogColor : 'unset',
|
||||
}} onClick={() => props.onClickDialog(props.dialog_id)} onContextMenu={() => {
|
||||
openContextMenu(props.dialog_id)
|
||||
}}>
|
||||
<Flex p={'sm'} gap={'sm'}>
|
||||
<Box style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Avatar src={avatars.length > 0 ? avatars[0].avatar : undefined} variant={isInCurrentDialog ? 'filled' : 'light'} name={groupInfo.title} size={50} color={'initials'} />
|
||||
</Box>
|
||||
<Flex w={'100%'} justify={'space-between'} direction={'row'} gap={'sm'}>
|
||||
<Flex direction={'column'} gap={3}>
|
||||
<Flex align={'center'} gap={5}>
|
||||
<Text size={'sm'} c={computedTheme == 'light' && !isInCurrentDialog ? 'black' : 'white'} fw={500}>
|
||||
{dotMessageIfNeeded(groupInfo.title, 15)}
|
||||
</Text>
|
||||
<IconUsers color={isInCurrentDialog ? '#fff' : colors.chevrons.active} size={13}></IconUsers>
|
||||
{isMuted && <IconBellOff color={isInCurrentDialog ? '#fff' : colors.chevrons.active} size={13}></IconBellOff>}
|
||||
{isPinned && <IconPin color={isInCurrentDialog ? '#fff' : colors.chevrons.active} size={13}></IconPin>}
|
||||
</Flex>
|
||||
{usersTypeing.length <= 0 && <>
|
||||
<Text component="div" c={
|
||||
isInCurrentDialog ? '#fff' : colors.chevrons.active
|
||||
} size={'xs'} style={{
|
||||
maxWidth: '130px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{loading && <Skeleton height={10} mt={4} width={100}></Skeleton>}
|
||||
{!loading && (
|
||||
<>
|
||||
<span style={{
|
||||
color: isInCurrentDialog ? '#fff' : theme.colors.blue[6]
|
||||
}}>{userInfo.title}: </span>
|
||||
<TextParser
|
||||
__reserved_1={isInCurrentDialog}
|
||||
noHydrate={true}
|
||||
text={lastMessage.plain_message}
|
||||
></TextParser>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</>}
|
||||
{usersTypeing.length > 0 && <>
|
||||
<Flex gap={5} align={'center'}>
|
||||
<Text c={isInCurrentDialog ? '#fff' : theme.colors.blue[3]} fz={12}>{typingUser.title} {usersTypeing.length > 1 && 'and ' + (usersTypeing.length - 1)} typing </Text>
|
||||
<Loader size={15} color={isInCurrentDialog ? '#fff' : theme.colors.blue[3]} type={'dots'}></Loader>
|
||||
</Flex>
|
||||
</>}
|
||||
</Flex>
|
||||
<Flex direction={'column'} align={'flex-end'} gap={8}>
|
||||
{!loading && (
|
||||
<Text c={сurrentDialogPublicKeyView == props.dialog_id ? '#fff' : colors.chevrons.active} fz={10}>
|
||||
{new Date(lastMessage.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
)}
|
||||
{loading && (
|
||||
<Skeleton height={8} mt={4} width={30}></Skeleton>
|
||||
)}
|
||||
{lastMessage.delivered == DeliveredMessageState.DELIVERED && <>
|
||||
{lastMessageFromMe && unreaded > 0 &&
|
||||
<IconCheck stroke={3} color={isInCurrentDialog ? '#fff' : theme.colors.blue[3]} size={14}></IconCheck>}
|
||||
{lastMessageFromMe && unreaded <= 0 &&
|
||||
<IconChecks stroke={3} color={isInCurrentDialog ? '#fff' : theme.colors.blue[3]} size={14}></IconChecks>}
|
||||
</>}
|
||||
{(lastMessage.delivered == DeliveredMessageState.WAITING && (isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length))) && <>
|
||||
<IconClock stroke={2} size={13} color={theme.colors.gray[5]}></IconClock>
|
||||
</>}
|
||||
{!loading && (lastMessage.delivered == DeliveredMessageState.ERROR || (!isMessageDeliveredByTime(lastMessage.timestamp, lastMessage.attachments.length) && lastMessage.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
||||
)}
|
||||
{unreaded > 0 && !lastMessageFromMe && !isMentioned(props.dialog_id) && <Badge
|
||||
color={isInCurrentDialog ? 'white' : (isMuted ? colors.chevrons.active : colors.brandColor)}
|
||||
c={isInCurrentDialog ? colors.brandColor : 'white'}
|
||||
size={'sm'} circle={unreaded < 10}>{unreaded > 99 ? '99+' : unreaded}</Badge>}
|
||||
{isMentioned(props.dialog_id) && !lastMessageFromMe && <Badge size={'sm'} circle c={isInCurrentDialog ? colors.brandColor : 'white'} color={isInCurrentDialog ? 'white' : colors.brandColor}>@</Badge>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider></Divider>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
152
app/components/GroupHeader/GroupHeader.tsx
Normal file
152
app/components/GroupHeader/GroupHeader.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||
import { Avatar, Box, Divider, Flex, Loader, Skeleton, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconTrashX } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
|
||||
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { ReplyHeader } from "../ReplyHeader/ReplyHeader";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { BackToDialogs } from "../BackToDialogs/BackToDialogs";
|
||||
import { useGroupInformation } from "@/app/providers/InformationProvider/useGroupInformation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useGroupMembers } from "@/app/providers/InformationProvider/useGroupMembers";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
|
||||
|
||||
export function GroupHeader() {
|
||||
const colors = useRosettaColors();
|
||||
const computedTheme = useComputedColorScheme();
|
||||
const {deleteMessages, dialog} = useDialog();
|
||||
const theme = useMantineTheme();
|
||||
const {groupInfo} = useGroupInformation(dialog);
|
||||
const protocolState = useProtocolState();
|
||||
const [usersTypeing, setUsersTypeing] = useState<{
|
||||
timeout: NodeJS.Timeout | null,
|
||||
fromPublicKey: string
|
||||
}[]>([]);
|
||||
const avatars = useAvatars(dialog);
|
||||
const {replyMessages} = useReplyMessages();
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
const [userInfo] = useUserInformation(usersTypeing[0]?.fromPublicKey || '');
|
||||
const navigate = useNavigate();
|
||||
/**
|
||||
* Указывем force для того, чтобы при открытии диалога
|
||||
* с группой подгружался сразу актуальный список участников
|
||||
* даже если он уже был загружен ранее. Потому что
|
||||
* событие добавления/удаления участников могло произойти
|
||||
* когда диалог был закрыт.
|
||||
*/
|
||||
const {members, loading} = useGroupMembers(groupInfo.groupId, true);
|
||||
|
||||
useEffect(() => {
|
||||
clearUsersTypeing();
|
||||
}, [dialog]);
|
||||
|
||||
const clearUsersTypeing = () => {
|
||||
usersTypeing.forEach(ut => {
|
||||
if(ut.timeout){
|
||||
clearTimeout(ut.timeout);
|
||||
}
|
||||
});
|
||||
setUsersTypeing([]);
|
||||
}
|
||||
|
||||
usePacket(0x0B, (packet : PacketTyping) => {
|
||||
if(packet.getToPublicKey() == dialog){
|
||||
setUsersTypeing((prev) => [...prev, {
|
||||
fromPublicKey: packet.getFromPublicKey(),
|
||||
timeout: setTimeout(() => {
|
||||
setUsersTypeing((prev) => {
|
||||
return prev.filter(ut => ut.fromPublicKey != packet.getFromPublicKey());
|
||||
});
|
||||
}, 3000)
|
||||
}]);
|
||||
}
|
||||
}, [dialog]);
|
||||
|
||||
const clearMessages = async () => {
|
||||
deleteMessages();
|
||||
modals.closeAll();
|
||||
}
|
||||
|
||||
const onClickClearMessages = () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Clear all messages?',
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to clear all messages? This action cannot be undone.
|
||||
</Text>
|
||||
),
|
||||
withCloseButton: false,
|
||||
labels: { confirm: 'Continue', cancel: "Cancel" },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: clearMessages
|
||||
});
|
||||
}
|
||||
|
||||
const onClickProfile = () => {
|
||||
navigate(`/main/group/${groupInfo.groupId.replace('#group:', '')}`);
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Box bg={colors.boxColor} style={{
|
||||
userSelect: 'none',
|
||||
}} h={60}>
|
||||
{(replyMessages.messages.length <= 0 || replyMessages.inDialogInput) && <Flex p={'sm'} h={'100%'} justify={'space-between'} align={'center'} gap={'sm'}>
|
||||
<Flex style={{
|
||||
cursor: 'pointer'
|
||||
}} h={'100%'} align={'center'} gap={'sm'}>
|
||||
{!lg && <BackToDialogs></BackToDialogs>}
|
||||
<Avatar onClick={onClickProfile} color={'initials'} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={groupInfo.title}></Avatar>
|
||||
<Flex direction={'column'} onClick={onClickProfile}>
|
||||
<Flex align={'center'} gap={3}>
|
||||
<Text size={'sm'} c={computedTheme == 'light' ? 'black' : 'white'} fw={500}>
|
||||
{groupInfo.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
{members.length > 0 && usersTypeing.length <= 0 && protocolState == ProtocolState.CONNECTED && (
|
||||
<Text c={theme.colors.gray[5]} fz={12}>{members.length} member{members.length > 1 ? 's' : ''}</Text>
|
||||
)}
|
||||
{loading && usersTypeing.length <= 0 && protocolState == ProtocolState.CONNECTED && members.length == 0 && (
|
||||
<Skeleton height={12} mt={7} width={80} radius="xl" />
|
||||
)}
|
||||
{!loading && members.length == 0 && (
|
||||
<Text c={theme.colors.gray[5]} fz={12}>
|
||||
Deleted group
|
||||
</Text>
|
||||
)}
|
||||
{usersTypeing.length > 0 && protocolState == ProtocolState.CONNECTED && <>
|
||||
<Flex gap={5} align={'center'}>
|
||||
<Text c={theme.colors.blue[3]} fz={12}>{userInfo.title} {usersTypeing.length > 1 && 'and ' + (usersTypeing.length - 1)} typing </Text>
|
||||
<Loader size={15} color={theme.colors.blue[3]} type={'dots'}></Loader>
|
||||
</Flex>
|
||||
</>}
|
||||
{protocolState != ProtocolState.CONNECTED &&
|
||||
<Flex gap={'xs'} align={'center'}>
|
||||
<Loader size={8} color={colors.chevrons.active}></Loader>
|
||||
<Text c={theme.colors.gray[5]} fz={12}>connecting...</Text>
|
||||
</Flex>
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
||||
<Tooltip onClick={onClickClearMessages} withArrow position={'bottom'} label={"Clear all messages"}>
|
||||
<IconTrashX
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconTrashX>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>}
|
||||
{replyMessages.messages.length > 0 && !replyMessages.inDialogInput && <ReplyHeader></ReplyHeader>}
|
||||
</Box>
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
</>)
|
||||
}
|
||||
55
app/components/GroupInvite/GroupInvite.tsx
Normal file
55
app/components/GroupInvite/GroupInvite.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ActionIcon, Avatar, CopyButton, Flex, MantineSize, Text } from "@mantine/core";
|
||||
import { SettingsPaper } from "../SettingsPaper/SettingsPaper";
|
||||
import { IconCheck, IconCopy, IconLink } from "@tabler/icons-react";
|
||||
import { useGroupInformation } from "@/app/providers/InformationProvider/useGroupInformation";
|
||||
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
|
||||
export interface GroupInviteProps {
|
||||
groupId: string;
|
||||
mt?: MantineSize;
|
||||
}
|
||||
|
||||
export function GroupInvite(props: GroupInviteProps) {
|
||||
const {groupInfo} = useGroupInformation(props.groupId);
|
||||
const {constructGroupString, getGroupKey} = useGroups();
|
||||
const [groupString, setGroupString] = useState<string>('');
|
||||
const colors = useRosettaColors();
|
||||
|
||||
useEffect(() => {
|
||||
initGroupString();
|
||||
}, [props.groupId])
|
||||
|
||||
const initGroupString = async () => {
|
||||
const groupKey = await getGroupKey(groupInfo.groupId);
|
||||
if(!groupKey){
|
||||
return;
|
||||
}
|
||||
const str = await constructGroupString(groupInfo.groupId, groupInfo.title, groupKey, groupInfo.description);
|
||||
setGroupString(str);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPaper p={'xs'} mt={props.mt}>
|
||||
<Flex direction={'row'} justify={'space-between'} align={'center'} gap={'sm'}>
|
||||
<Flex align={'center'} gap={'md'}>
|
||||
<Avatar size={45} color={'blue'}>
|
||||
<IconLink size={25} />
|
||||
</Avatar>
|
||||
<Flex direction={'column'}>
|
||||
<Text fz={13} c={colors.brandColor} fw={500}>Group Invite Code</Text>
|
||||
<Text fz={10} c={'gray'}>Copy and share this invite code with any Rosetta users to the group.</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<CopyButton value={groupString} timeout={1500}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon size={'lg'} component="span" color={copied ? 'teal' : 'blue'} variant="subtle" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Flex>
|
||||
</SettingsPaper>
|
||||
)
|
||||
}
|
||||
108
app/components/GroupInviteMessage/GroupInviteMessage.tsx
Normal file
108
app/components/GroupInviteMessage/GroupInviteMessage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { useGroupInviteStatus } from "@/app/providers/DialogProvider/useGroupInviteStatus";
|
||||
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
|
||||
import { GroupStatus } from "@/app/providers/ProtocolProvider/protocol/packets/packet.group.invite.info";
|
||||
import { Avatar, Button, Flex, Paper, Skeleton, Text } from "@mantine/core";
|
||||
import { IconBan, IconCheck, IconLink, IconPlus, IconX } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export interface GroupInviteMessageProps {
|
||||
groupInviteCode: string;
|
||||
}
|
||||
|
||||
export function GroupInviteMessage(props: GroupInviteMessageProps) {
|
||||
const {parseGroupString, prepareForRoute, joinGroup, loading} = useGroups();
|
||||
const [groupInfo, setGroupInfo] = useState({
|
||||
groupId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
encryptKey: ''
|
||||
});
|
||||
const {inviteStatus} =
|
||||
useGroupInviteStatus(groupInfo.groupId);
|
||||
const colorStatus = (
|
||||
inviteStatus === GroupStatus.NOT_JOINED ? 'blue' :
|
||||
inviteStatus === GroupStatus.JOINED ? 'green' :
|
||||
'red'
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
|
||||
useEffect(() => {
|
||||
initGroupInfo();
|
||||
}, [props.groupInviteCode]);
|
||||
|
||||
const initGroupInfo = async () => {
|
||||
const info = await parseGroupString(props.groupInviteCode);
|
||||
if(!info){
|
||||
setGroupInfo({
|
||||
groupId: 'Invalid',
|
||||
title: 'Invalid',
|
||||
description: 'Invalid',
|
||||
encryptKey: ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
setGroupInfo(info);
|
||||
}
|
||||
|
||||
const onClickButton = async () => {
|
||||
if(inviteStatus === GroupStatus.NOT_JOINED){
|
||||
await joinGroup(props.groupInviteCode);
|
||||
return;
|
||||
}
|
||||
if(inviteStatus === GroupStatus.JOINED){
|
||||
navigate(`/main/chat/${prepareForRoute(groupInfo.groupId)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupInfo.groupId === '' && (
|
||||
<Skeleton miw={200} height={100} mt={'xs'} p={'sm'} />
|
||||
)}
|
||||
{groupInfo.groupId != '' && (
|
||||
<Paper withBorder mt={'xs'} p={'sm'}>
|
||||
<Flex align={'center'} gap={'md'}>
|
||||
{lg && (
|
||||
<Avatar size={50} bg={colorStatus}>
|
||||
<IconLink color={'white'} size={25} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Flex direction={'column'}>
|
||||
<Text w={150} fz={13} c={colorStatus} fw={500}>{groupInfo.title}</Text>
|
||||
<Text fz={10} c={'gray'}>
|
||||
{inviteStatus === GroupStatus.NOT_JOINED && "Invite to join in this group."}
|
||||
{inviteStatus === GroupStatus.JOINED && "You are already a member of this group."}
|
||||
{inviteStatus === GroupStatus.INVALID && "This group invite is invalid."}
|
||||
{inviteStatus === GroupStatus.BANNED && "You are banned in this group."}
|
||||
</Text>
|
||||
{inviteStatus === GroupStatus.NOT_JOINED && (
|
||||
<Button loading={loading} onClick={onClickButton} leftSection={
|
||||
<IconPlus size={14} />
|
||||
} mt={'xs'} variant={'light'} size={'compact-xs'}>Join Group</Button>
|
||||
)}
|
||||
{inviteStatus === GroupStatus.JOINED && (
|
||||
<Button loading={loading} onClick={onClickButton} leftSection={
|
||||
<IconCheck size={14} />
|
||||
} mt={'xs'} variant={'light'} color={'green'} size={'compact-xs'}>In group</Button>
|
||||
)}
|
||||
{inviteStatus === GroupStatus.INVALID && (
|
||||
<Button loading={loading} onClick={onClickButton} leftSection={
|
||||
<IconX size={14} />
|
||||
} mt={'xs'} variant={'light'} color={'red'} size={'compact-xs'}>Invalid</Button>
|
||||
)}
|
||||
{inviteStatus === GroupStatus.BANNED && (
|
||||
<Button loading={loading} onClick={onClickButton} leftSection={
|
||||
<IconBan size={14} />
|
||||
} mt={'xs'} variant={'light'} color={'red'} size={'compact-xs'}>Banned</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
app/components/InputChainHidden/InputChain.module.css
Normal file
46
app/components/InputChainHidden/InputChain.module.css
Normal file
@@ -0,0 +1,46 @@
|
||||
.wrapper{
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.targetArea {
|
||||
padding: 16px;
|
||||
border: 2px dashed light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: 8px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.availableArea {
|
||||
padding: 16px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: 8px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.staticWord {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.targetSlot {
|
||||
min-width: 100px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.targetSlot:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.availableWord {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.availableWord:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
177
app/components/InputChainHidden/InputChainHidden.tsx
Normal file
177
app/components/InputChainHidden/InputChainHidden.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Button, Group, Stack, Text, Box, Transition } from "@mantine/core";
|
||||
import classes from './InputChain.module.css'
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface InputChainProps {
|
||||
text: string;
|
||||
hidden: number;
|
||||
onPassed: () => void;
|
||||
onNotPassed?: () => void;
|
||||
size?: string;
|
||||
w?: number;
|
||||
}
|
||||
|
||||
export function InputChainHidden(props : InputChainProps) {
|
||||
const text = props.text;
|
||||
if(text.trim() == ""){
|
||||
return (<></>);
|
||||
}
|
||||
|
||||
const words = text.split(" ");
|
||||
const [hiddenIndices, setHiddenIndices] = useState<number[]>([]);
|
||||
const [selectedWords, setSelectedWords] = useState<(string | null)[]>([]);
|
||||
const [availableWords, setAvailableWords] = useState<string[]>([]);
|
||||
const [wrongIndices, setWrongIndices] = useState<number[]>([]);
|
||||
const [correctIndices, setCorrectIndices] = useState<number[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let hidden : number[] = [];
|
||||
while (hidden.length < props.hidden) {
|
||||
let num = Math.floor(Math.random() * words.length);
|
||||
if(hidden.indexOf(num) == -1){
|
||||
hidden.push(num);
|
||||
}
|
||||
}
|
||||
hidden.sort((a, b) => a - b);
|
||||
setHiddenIndices(hidden);
|
||||
|
||||
const hiddenWords = hidden.map(idx => words[idx]);
|
||||
const shuffled = [...hiddenWords].sort(() => Math.random() - 0.5);
|
||||
setAvailableWords(shuffled);
|
||||
setSelectedWords(new Array(hidden.length).fill(null));
|
||||
|
||||
setTimeout(() => setMounted(true), 100);
|
||||
}, []);
|
||||
|
||||
const handleWordClick = (word: string) => {
|
||||
const firstEmptyIndex = selectedWords.findIndex(w => w === null);
|
||||
if (firstEmptyIndex !== -1) {
|
||||
const newSelected = [...selectedWords];
|
||||
newSelected[firstEmptyIndex] = word;
|
||||
setSelectedWords(newSelected);
|
||||
setAvailableWords(availableWords.filter(w => w !== word));
|
||||
checkIfPassed(newSelected);
|
||||
validateWords(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveWord = (index: number) => {
|
||||
const word = selectedWords[index];
|
||||
if (word) {
|
||||
const newSelected = [...selectedWords];
|
||||
newSelected[index] = null;
|
||||
setSelectedWords(newSelected);
|
||||
setAvailableWords([...availableWords, word]);
|
||||
if(props.onNotPassed){
|
||||
props.onNotPassed();
|
||||
}
|
||||
validateWords(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const validateWords = (selected: (string | null)[]) => {
|
||||
const wrong: number[] = [];
|
||||
const correct: number[] = [];
|
||||
selected.forEach((word, idx) => {
|
||||
if (word !== null) {
|
||||
if (word === words[hiddenIndices[idx]]) {
|
||||
correct.push(idx);
|
||||
} else {
|
||||
wrong.push(idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
setWrongIndices(wrong);
|
||||
setCorrectIndices(correct);
|
||||
};
|
||||
|
||||
const checkIfPassed = (selected: (string | null)[]) => {
|
||||
const allFilled = selected.every(w => w !== null);
|
||||
if (allFilled) {
|
||||
const isCorrect = selected.every((word, idx) =>
|
||||
word === words[hiddenIndices[idx]]
|
||||
);
|
||||
if (isCorrect) {
|
||||
props.onPassed();
|
||||
} else if(props.onNotPassed) {
|
||||
props.onNotPassed();
|
||||
}
|
||||
} else {
|
||||
if(props.onNotPassed){
|
||||
props.onNotPassed();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{/* Target area - where words should be placed */}
|
||||
<Transition mounted={mounted} transition="slide-up" duration={400} timingFunction="ease">
|
||||
{(styles) => (
|
||||
<Box className={classes.targetArea} style={styles}>
|
||||
<Text size="sm" mb="xs" c="dimmed">
|
||||
Click the words in the correct order:
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{words.map((word, i) => {
|
||||
const hiddenIdx = hiddenIndices.indexOf(i);
|
||||
const isHidden = hiddenIdx !== -1;
|
||||
|
||||
if (!isHidden) {
|
||||
return (
|
||||
<Box key={i} className={classes.staticWord}>
|
||||
<Text size="sm" c="dimmed">{i + 1}.</Text>
|
||||
<Text size="sm">{word}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isWrong = wrongIndices.includes(hiddenIdx);
|
||||
const isCorrect = correctIndices.includes(hiddenIdx);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={i}
|
||||
variant={selectedWords[hiddenIdx] ? "filled" : "default"}
|
||||
size="sm"
|
||||
className={classes.targetSlot}
|
||||
color={isWrong ? "red" : isCorrect ? "green" : undefined}
|
||||
onClick={() => selectedWords[hiddenIdx] && handleRemoveWord(hiddenIdx)}
|
||||
>
|
||||
<Text size="sm" c={selectedWords[hiddenIdx] ? 'white' : 'dimmed'} mr={4}>{i + 1}.</Text>
|
||||
{selectedWords[hiddenIdx] || "_____"}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Transition>
|
||||
|
||||
{/* Available words area */}
|
||||
<Transition mounted={mounted} transition="slide-up" duration={500} timingFunction="ease">
|
||||
{(transitionStyles) => (
|
||||
<Box mih={100} className={classes.availableArea} style={transitionStyles}>
|
||||
<Text size="sm" mb="xs" c="dimmed">
|
||||
Available words:
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{availableWords.map((word, idx) => (
|
||||
<Button
|
||||
key={`${word}-${idx}`}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => handleWordClick(word)}
|
||||
className={classes.availableWord}
|
||||
>
|
||||
{word}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Transition>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
30
app/components/InputChainWords/InputChainWords.module.css
Normal file
30
app/components/InputChainWords/InputChainWords.module.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.displayArea {
|
||||
padding: 16px;
|
||||
border: 2px dashed light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: 8px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
min-height: 250px;
|
||||
max-height: 250px;
|
||||
height: 250px;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.wordInput {
|
||||
height: 36px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-5));
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.wordInput:hover {
|
||||
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.wordInput:focus {
|
||||
border-color: var(--mantine-color-blue-filled);
|
||||
}
|
||||
108
app/components/InputChainWords/InputChainWords.tsx
Normal file
108
app/components/InputChainWords/InputChainWords.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Box, Text, SimpleGrid, TextInput } from "@mantine/core";
|
||||
import classes from './InputChainWords.module.css'
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface InputChainWordsProps {
|
||||
words: number;
|
||||
onPassed: (words : string[]) => void;
|
||||
wordlist?: string[];
|
||||
onNotPassed: () => void;
|
||||
placeholderFunc?: (inputNumber : number) => string;
|
||||
}
|
||||
|
||||
export function InputChainWords(props : InputChainWordsProps) {
|
||||
const [inputValues, setInputValues] = useState<string[]>(Array(props.words).fill(""));
|
||||
const [mounted, setMounted] = useState<boolean[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(new Array(props.words).fill(false));
|
||||
|
||||
Array.from({ length: props.words }).forEach((_, index) => {
|
||||
setTimeout(() => {
|
||||
setMounted(prev => {
|
||||
const newMounted = [...prev];
|
||||
newMounted[index] = true;
|
||||
return newMounted;
|
||||
});
|
||||
}, index * 50);
|
||||
});
|
||||
}, [props.words]);
|
||||
|
||||
const handleInputChange = (value: string, index: number) => {
|
||||
const updatedValues = [...inputValues];
|
||||
updatedValues[index] = value;
|
||||
setInputValues(updatedValues);
|
||||
|
||||
const allFilled = updatedValues.every((word) => word.trim() !== "");
|
||||
const allValid = props.wordlist
|
||||
? updatedValues.every((word) => props.wordlist!.includes(word.trim()))
|
||||
: true;
|
||||
|
||||
if (allFilled && allValid) {
|
||||
props.onPassed(updatedValues);
|
||||
} else {
|
||||
props.onNotPassed();
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent) => {
|
||||
event.preventDefault();
|
||||
const pastedText = event.clipboardData.getData("text");
|
||||
const pastedWords = pastedText.split(/\s+/).slice(0, props.words);
|
||||
|
||||
const updatedValues = [...inputValues];
|
||||
pastedWords.forEach((word, index) => {
|
||||
if (index < props.words) {
|
||||
updatedValues[index] = word;
|
||||
}
|
||||
});
|
||||
|
||||
setInputValues(updatedValues);
|
||||
|
||||
const allFilled = updatedValues.every((word) => word.trim() !== "");
|
||||
const allValid = props.wordlist
|
||||
? updatedValues.every((word) => props.wordlist!.includes(word.trim()))
|
||||
: true;
|
||||
|
||||
if (allFilled && allValid) {
|
||||
props.onPassed(updatedValues);
|
||||
} else {
|
||||
props.onNotPassed();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box className={classes.displayArea}>
|
||||
<Text size="sm" mb="md" c="dimmed">
|
||||
Enter your seed phrase:
|
||||
</Text>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
{Array.from({ length: props.words }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
opacity: mounted[i] ? 1 : 0,
|
||||
transform: mounted[i] ? 'scale(1)' : 'scale(0.9)',
|
||||
transition: 'opacity 300ms ease, transform 300ms ease',
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={inputValues[i]}
|
||||
onChange={(e) => handleInputChange(e.target.value, i)}
|
||||
onPaste={(e) => handlePaste(e)}
|
||||
//placeholder={props.placeholderFunc ? props.placeholderFunc(i) : undefined}
|
||||
classNames={{ input: classes.wordInput }}
|
||||
leftSection={
|
||||
<Text size="xs" c="dimmed">{i + 1}.</Text>
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Flex, Input, Text } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { forwardRef, useState } from "react";
|
||||
|
||||
interface InputCustomPlaceholderProps {
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
const InputCustomPlaceholder = forwardRef<HTMLDivElement, InputCustomPlaceholderProps>((props: InputCustomPlaceholderProps, ref) => {
|
||||
const colors = useRosettaColors();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
setValue("");
|
||||
if (props.onBlur) props.onBlur();
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.currentTarget.value);
|
||||
if (props.onChange) props.onChange(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ width: '100%', position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: isFocused ? 'translateY(-50%) translateX(0)' : 'translateY(-50%) translateX(-50%)',
|
||||
left: isFocused ? 8 : '50%',
|
||||
transition: 'left 180ms ease, transform 180ms ease, opacity 180ms ease',
|
||||
width: 'auto',
|
||||
opacity: isFocused ? 0.85 : 1,
|
||||
}}
|
||||
>
|
||||
<Input.Placeholder>
|
||||
<Flex align={'center'} gap={5}>
|
||||
<IconSearch stroke={1.3} size={16} />
|
||||
<Text style={{
|
||||
transition: 'opacity 180ms ease',
|
||||
opacity: isFocused ? 0 : 1,
|
||||
userSelect: 'none'
|
||||
}} size="xs" c="dimmed">
|
||||
Search
|
||||
</Text>
|
||||
</Flex>
|
||||
</Input.Placeholder>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
w={'100%'}
|
||||
pl={'xl'}
|
||||
style={{ caretColor: colors.chevrons.active }}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default InputCustomPlaceholder;
|
||||
35
app/components/InternalScreen/InternalScreen.tsx
Normal file
35
app/components/InternalScreen/InternalScreen.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Box, Flex, ScrollArea } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface InternalScreenProps {
|
||||
children: any;
|
||||
}
|
||||
export function InternalScreen(props : InternalScreenProps) {
|
||||
const [scrollAreaHeight, setScrollAreaHeight] = useState(window.innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setScrollAreaHeight(window.innerHeight);
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize();
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex align={'center'} justify={'center'}>
|
||||
<Box maw={650} w={'100%'}>
|
||||
<ScrollArea
|
||||
type="hover"
|
||||
offsetScrollbars={"y"}
|
||||
scrollHideDelay={1500}
|
||||
scrollbarSize={7}
|
||||
w={'100%'}
|
||||
h={scrollAreaHeight - 100} // Adjust height with an offset
|
||||
>
|
||||
<Box pb={'lg'} pl={'md'} pt={'md'} pr={'sm'}>
|
||||
{props.children}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
76
app/components/KeyImage/KeyImage.tsx
Normal file
76
app/components/KeyImage/KeyImage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
export interface KeyImageProps {
|
||||
keyRender: string;
|
||||
size: number;
|
||||
radius?: number;
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export function KeyImage(props: KeyImageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
const colorsArr: string[] = useMemo(() => {
|
||||
/**
|
||||
* Random color generation based on keyRender
|
||||
*/
|
||||
let colors : string[] = [];
|
||||
for(let i = 0; i < props.keyRender.length; i++){
|
||||
const char = props.keyRender.charCodeAt(i);
|
||||
const colorIndex = char % props.colors.length;
|
||||
colors.push(props.colors[colorIndex]);
|
||||
}
|
||||
return colors;
|
||||
}, [props.colors, props.keyRender]);
|
||||
|
||||
const composition: string[] = useMemo(() => {
|
||||
const align = 64; // 8x8
|
||||
const total = colorsArr.length;
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < align; i++) {
|
||||
let color = colorsArr[i % total] ?? "gray";
|
||||
result.push(color);
|
||||
}
|
||||
return result;
|
||||
}, [colorsArr]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const size = props.size;
|
||||
const cells = 8;
|
||||
const cellSize = size / cells;
|
||||
|
||||
// Ensure crisp rendering
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
// Draw 8x8 grid
|
||||
for (let i = 0; i < composition.length; i++) {
|
||||
const row = Math.floor(i / cells);
|
||||
const col = i % cells;
|
||||
const posX = Math.floor(col * cellSize);
|
||||
const posY = Math.floor(row * cellSize);
|
||||
const sizePx = Math.ceil(cellSize);
|
||||
ctx.fillStyle = composition[i];
|
||||
ctx.fillRect(posX, posY, sizePx, sizePx);
|
||||
}
|
||||
}, [composition, props.size]);
|
||||
|
||||
return (
|
||||
<div style={{ width: props.size, height: props.size }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: props.size,
|
||||
height: props.size,
|
||||
borderRadius: props.radius,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
app/components/MacFrameButtons/MacFrameButtons.module.css
Normal file
27
app/components/MacFrameButtons/MacFrameButtons.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.traffic_lights {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
app-region: no-drag;
|
||||
}
|
||||
.close_btn, .minimize_btn, .maximize_btn {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.close_btn {
|
||||
background-color: #ff5f57;
|
||||
}
|
||||
.minimize_btn {
|
||||
background-color: #ffbd2e;
|
||||
}
|
||||
.maximize_btn {
|
||||
background-color: #28c840;
|
||||
}
|
||||
.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
36
app/components/MacFrameButtons/MacFrameButtons.tsx
Normal file
36
app/components/MacFrameButtons/MacFrameButtons.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import classes from './MacFrameButtons.module.css';
|
||||
import { cx } from '@/app/utils/style';
|
||||
import { useWindowState } from '@/app/hooks/useWindowState';
|
||||
import { useWindowFocus } from '@/app/hooks/useWindowFocus';
|
||||
import { useWindowActions } from '@/app/hooks/useWindowActions';
|
||||
|
||||
|
||||
export function MacFrameButtons() {
|
||||
const windowState = useWindowState();
|
||||
const focus = useWindowFocus();
|
||||
const {toggle, close, minimize} = useWindowActions();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.traffic_lights}>
|
||||
<div onClick={close} className={cx(
|
||||
classes.close_btn,
|
||||
!focus && classes.disabled,
|
||||
!windowState.isClosable && classes.disabled
|
||||
)}></div>
|
||||
<div onClick={minimize} className={cx(
|
||||
classes.minimize_btn,
|
||||
!focus && classes.disabled,
|
||||
windowState.isMinimized && classes.disabled,
|
||||
!windowState.isResizable && classes.disabled
|
||||
)}></div>
|
||||
<div onClick={toggle} className={cx(
|
||||
classes.maximize_btn,
|
||||
!focus && classes.disabled,
|
||||
//windowState.isMaximized && classes.disabled,
|
||||
(!windowState.isResizable && !windowState.isFullScreen) && classes.disabled
|
||||
)}></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
app/components/MentionList/MentionList.tsx
Normal file
74
app/components/MentionList/MentionList.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Box, Divider, Flex } from "@mantine/core";
|
||||
import { MentionRow } from "./MentionRow";
|
||||
import React, { useState } from "react";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
|
||||
export interface Mention {
|
||||
username: string;
|
||||
title: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
mentions: Mention[];
|
||||
style?: React.CSSProperties;
|
||||
onSelectMention?: (mention: Mention) => void;
|
||||
}
|
||||
|
||||
export function MentionList(props: MentionListProps) {
|
||||
const colors = useRosettaColors();
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
useHotkeys([
|
||||
['ArrowDown', () => {
|
||||
if(props.mentions.length === 1){
|
||||
//setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
setSelectedIndex((prev) => (prev + 1) % props.mentions.length);
|
||||
}],
|
||||
['ArrowUp', () => {
|
||||
if(props.mentions.length === 1){
|
||||
//setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
setSelectedIndex((prev) => (prev - 1 + props.mentions.length) % props.mentions.length);
|
||||
}],
|
||||
['Enter', () => {
|
||||
if(props.mentions.length === 1){
|
||||
if(props.onSelectMention){
|
||||
props.onSelectMention(props.mentions[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(selectedIndex >= 0 && selectedIndex < props.mentions.length) {
|
||||
const mention = props.mentions[selectedIndex];
|
||||
if(props.onSelectMention){
|
||||
props.onSelectMention(mention);
|
||||
}
|
||||
}
|
||||
}]
|
||||
], [], true);
|
||||
|
||||
const onClick = (mention: Mention) => {
|
||||
if(props.onSelectMention){
|
||||
props.onSelectMention(mention);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={props.style} bg={colors.mainColor}>
|
||||
<Flex direction={'column'}>
|
||||
{props.mentions.map((mention, index) => (
|
||||
<Box onClick={() => onClick(mention)} key={mention.publicKey}>
|
||||
<MentionRow selected={selectedIndex === index} {...mention}></MentionRow>
|
||||
{index < props.mentions.length - 1 &&
|
||||
<Divider color={colors.borderColor}></Divider>
|
||||
}
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
39
app/components/MentionList/MentionRow.tsx
Normal file
39
app/components/MentionList/MentionRow.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Avatar, Flex, Text, useMantineTheme } from "@mantine/core";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { Mention } from "./MentionList";
|
||||
|
||||
interface MentionRowProps extends Mention {
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function MentionRow(props : MentionRowProps) {
|
||||
const colors = useRosettaColors();
|
||||
const avatars = useAvatars(props.publicKey, false);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Flex align={'center'} bg={props.selected ? theme.colors.blue[8] + "10" : undefined} style={{
|
||||
cursor: 'pointer'
|
||||
}} gap={'sm'} p={'xs'} direction={'row'}>
|
||||
{props.username == 'all' && <Avatar title="@" variant="filled" color={colors.brandColor}>@</Avatar>}
|
||||
{props.username == 'admin' && <Avatar title="@" variant="filled" color={colors.error}>@</Avatar>}
|
||||
{props.username != 'all' && props.username != 'admin' && <Avatar
|
||||
title={props.title}
|
||||
variant="filled"
|
||||
color="initials"
|
||||
src={avatars.length > 0 ? avatars[0].avatar : null}
|
||||
></Avatar>}
|
||||
<Flex direction={'column'}>
|
||||
<Flex justify={'row'} align={'center'} gap={3}>
|
||||
<Text size="sm" fw={500}>
|
||||
{props.username == 'all' && 'All users'}
|
||||
{props.username == 'admin' && 'Administrator'}
|
||||
{props.username != 'all' && props.username != 'admin' && props.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Text size={'xs'} c={'dimmed'}>@{props.username}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
61
app/components/MessageAttachments/MessageAttachments.tsx
Normal file
61
app/components/MessageAttachments/MessageAttachments.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
import { Flex } from "@mantine/core";
|
||||
import { MessageImage } from "./MessageImage";
|
||||
import { MessageReplyMessages } from "./MessageReplyMessages";
|
||||
import { MessageFile } from "./MessageFile";
|
||||
import { ErrorBoundaryProvider } from "@/app/providers/ErrorBoundaryProvider/ErrorBoundaryProvider";
|
||||
import { AttachmentError } from "../AttachmentError/AttachmentError";
|
||||
import { MessageAvatar } from "./MessageAvatar";
|
||||
import { MessageProps } from "../Messages/Message";
|
||||
|
||||
export interface MessageAttachmentsProps {
|
||||
attachments: Attachment[];
|
||||
delivered: DeliveredMessageState;
|
||||
timestamp: number;
|
||||
text: string;
|
||||
chacha_key_plain: string;
|
||||
parent: MessageProps;
|
||||
}
|
||||
|
||||
export interface AttachmentProps {
|
||||
attachment: Attachment;
|
||||
attachments: Attachment[];
|
||||
delivered: DeliveredMessageState;
|
||||
timestamp: number;
|
||||
text: string;
|
||||
chacha_key_plain: string;
|
||||
parent: MessageProps;
|
||||
}
|
||||
|
||||
export function MessageAttachments(props: MessageAttachmentsProps) {
|
||||
return (
|
||||
<ErrorBoundaryProvider fallback={<AttachmentError></AttachmentError>}>
|
||||
<Flex gap={'xs'} direction={'column'} mt={'sm'} wrap={'wrap'}>
|
||||
{props.attachments.map((att, index) => {
|
||||
const attachProps : AttachmentProps = {
|
||||
chacha_key_plain: props.chacha_key_plain,
|
||||
attachment: att,
|
||||
attachments: props.attachments,
|
||||
delivered: props.delivered,
|
||||
timestamp: props.timestamp,
|
||||
text: props.text,
|
||||
parent: props.parent,
|
||||
}
|
||||
switch (att.type) {
|
||||
case AttachmentType.MESSAGES:
|
||||
return <MessageReplyMessages {...attachProps} key={index}></MessageReplyMessages>
|
||||
case AttachmentType.IMAGE:
|
||||
return <MessageImage {...attachProps} key={index}></MessageImage>
|
||||
case AttachmentType.FILE:
|
||||
return <MessageFile {...attachProps} key={index}></MessageFile>
|
||||
case AttachmentType.AVATAR:
|
||||
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
||||
default:
|
||||
return <AttachmentError key={index}></AttachmentError>;
|
||||
}
|
||||
})}
|
||||
</Flex>
|
||||
</ErrorBoundaryProvider>
|
||||
);
|
||||
}
|
||||
111
app/components/MessageAttachments/MessageAvatar.tsx
Normal file
111
app/components/MessageAttachments/MessageAvatar.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useImageViewer } from "@/app/providers/ImageViewerProvider/useImageViewer";
|
||||
import { AspectRatio, Button, Flex, Paper, Text } from "@mantine/core";
|
||||
import { IconArrowDown } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AttachmentProps } from "./MessageAttachments";
|
||||
import { blurhashToBase64Image } from "@/app/utils/utils";
|
||||
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||
import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider";
|
||||
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||
import { PopoverLockIconAvatar } from "../PopoverLockIconAvatar/PopoverLockIconAvatar";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
|
||||
export function MessageAvatar(props: AttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
const {
|
||||
downloadPercentage,
|
||||
//uploadedPercentage,
|
||||
download,
|
||||
downloadStatus,
|
||||
getBlob,
|
||||
getPreview} = useAttachment(props.attachment, props.chacha_key_plain);
|
||||
const mainRef = useRef<HTMLDivElement>(null);
|
||||
const { open } = useImageViewer();
|
||||
const preview = getPreview();
|
||||
const [blob, setBlob] = useState(props.attachment.blob);
|
||||
const {lg} = useRosettaBreakpoints();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
constructBlob();
|
||||
}, [downloadStatus]);
|
||||
|
||||
const constructBlob = async () => {
|
||||
let blob = await getBlob();
|
||||
setBlob(blob);
|
||||
}
|
||||
|
||||
const openImageViewer = () => {
|
||||
const images: ImageToView[] = [{
|
||||
src: blob,
|
||||
caption: props.text,
|
||||
timestamp: props.timestamp
|
||||
}];
|
||||
|
||||
open(images, 0);
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||
openImageViewer();
|
||||
return;
|
||||
}
|
||||
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
|
||||
download();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder p={'sm'}>
|
||||
<Flex gap={'sm'} direction={'row'}>
|
||||
<AspectRatio onClick={onClick} ref={mainRef} style={{
|
||||
height: 60,
|
||||
width: 60,
|
||||
userSelect: 'none'
|
||||
}} ratio={16 / 9} pos={'relative'}>
|
||||
{blob != "" && (
|
||||
<img style={{
|
||||
height: 60,
|
||||
width: 60,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover'
|
||||
}} src={blob}></img>)}
|
||||
{downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING && preview.length >= 20 && (
|
||||
<>
|
||||
<img style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover'
|
||||
}} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}></img>
|
||||
</>
|
||||
)}
|
||||
</AspectRatio>
|
||||
<Flex direction={"column"}>
|
||||
<Flex direction={'row'} gap={5} align={'center'}>
|
||||
<Text fw={500} fz={'sm'}>Avatar</Text>
|
||||
<PopoverLockIconAvatar></PopoverLockIconAvatar>
|
||||
</Flex>
|
||||
<Text fz={'xs'} c={'dimmed'}>
|
||||
An avatar image shared in the message.
|
||||
</Text>
|
||||
{downloadStatus != DownloadStatus.DOWNLOADED && (
|
||||
<Flex direction={'row'} mt={'xs'} justify={'flex-end'} align={'center'} gap={'sm'}>
|
||||
{lg && <Text fz={9} c={'dimmed'}>Avatars are end-to-end encrypted</Text>}
|
||||
<Flex align={'center'}>
|
||||
{downloadStatus == DownloadStatus.NOT_DOWNLOADED &&
|
||||
<Button leftSection={<IconArrowDown size={14}></IconArrowDown>} size={'xs'} variant={'light'} onClick={download}>Download</Button>
|
||||
}
|
||||
{downloadStatus == DownloadStatus.DOWNLOADING &&
|
||||
<Button leftSection={<AnimatedRoundedProgress size={14} color={colors.brandColor} value={downloadPercentage}></AnimatedRoundedProgress>} size={'xs'} variant={'light'}>Download</Button>
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
133
app/components/MessageAttachments/MessageFile.tsx
Normal file
133
app/components/MessageAttachments/MessageFile.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||
import { AttachmentProps } from "./MessageAttachments";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Avatar, Box, Flex, Loader, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconFile, IconX } from "@tabler/icons-react";
|
||||
import { dotCenterIfNeeded, humanFilesize } from "@/app/utils/utils";
|
||||
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
|
||||
export function MessageFile(props : AttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
const {
|
||||
downloadPercentage,
|
||||
downloadStatus,
|
||||
uploadedPercentage,
|
||||
download,
|
||||
getPreview,
|
||||
} =
|
||||
useAttachment(
|
||||
props.attachment,
|
||||
props.chacha_key_plain,
|
||||
);
|
||||
const preview = getPreview();
|
||||
const error = downloadStatus == DownloadStatus.ERROR;
|
||||
const filesize = parseInt(preview.split("::")[0]);
|
||||
const filename = preview.split("::")[1];
|
||||
const filetype = filename.split(".")[filename.split(".").length - 1];
|
||||
const isEncrypting = props.delivered == DeliveredMessageState.WAITING && uploadedPercentage <= 0;
|
||||
const isUploading = props.delivered == DeliveredMessageState.WAITING && uploadedPercentage > 0 && uploadedPercentage < 100;
|
||||
|
||||
const onClick = async () => {
|
||||
if(downloadStatus == DownloadStatus.ERROR){
|
||||
return;
|
||||
}
|
||||
if(downloadStatus == DownloadStatus.DOWNLOADED){
|
||||
//let content = await getBlob();
|
||||
//let buffer = Buffer.from(content.split(",")[1], 'base64');
|
||||
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
//await writeFile(pathInDownloads, buffer, false);
|
||||
window.shell.showItemInFolder(pathInDownloads);
|
||||
return;
|
||||
}
|
||||
if(downloadStatus == DownloadStatus.NOT_DOWNLOADED){
|
||||
download();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={'sm'} onClick={onClick} style={{
|
||||
background: colors.mainColor,
|
||||
border: '1px solid ' + colors.borderColor,
|
||||
borderRadius: 8,
|
||||
minWidth: 200,
|
||||
minHeight: 60
|
||||
}}>
|
||||
<Flex gap={'sm'} direction={'row'}>
|
||||
<Avatar bg={error ? colors.error : colors.brandColor} size={40}>
|
||||
{!error && <>
|
||||
{(downloadStatus == DownloadStatus.DOWNLOADING && downloadPercentage > 0 && downloadPercentage < 100) && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}>
|
||||
<AnimatedRoundedProgress size={40} value={downloadPercentage}></AnimatedRoundedProgress>
|
||||
</div>
|
||||
)}
|
||||
{downloadStatus != DownloadStatus.DOWNLOADED && (
|
||||
<IconArrowDown color={'white'} size={22}></IconArrowDown>
|
||||
)}
|
||||
{isUploading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}>
|
||||
<AnimatedRoundedProgress size={40} value={uploadedPercentage}></AnimatedRoundedProgress>
|
||||
</div>
|
||||
)}
|
||||
{downloadStatus == DownloadStatus.DOWNLOADED && <IconFile color={'white'} size={22}></IconFile>}
|
||||
</>}
|
||||
{error && <>
|
||||
<IconX color={'white'} size={22}></IconX>
|
||||
</>}
|
||||
</Avatar>
|
||||
<Flex direction={'column'} gap={5}>
|
||||
<Text size={'sm'}>{dotCenterIfNeeded(filename, 25, 8)}</Text>
|
||||
{!error && !isEncrypting && !isUploading && (downloadStatus == DownloadStatus.DOWNLOADED || downloadStatus == DownloadStatus.NOT_DOWNLOADED) &&
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
{humanFilesize(filesize)} {filetype.toUpperCase()}
|
||||
</Text>
|
||||
}
|
||||
{downloadStatus == DownloadStatus.DOWNLOADING &&
|
||||
<Flex gap={5} align={'center'}>
|
||||
<Loader size={10}></Loader>
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
Downloading... {downloadPercentage}%
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
{isEncrypting &&
|
||||
<Flex gap={5} align={'center'}>
|
||||
<Loader size={10}></Loader>
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
Encrypting...
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
{isUploading &&
|
||||
<Flex gap={5} align={'center'}>
|
||||
<Loader size={10}></Loader>
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
Uploading... {uploadedPercentage}%
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
{downloadStatus == DownloadStatus.DECRYPTING &&
|
||||
<Flex gap={5} align={'center'}>
|
||||
<Loader size={10}></Loader>
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
Decrypting...
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
{error && <Text size={'xs'} c={colors.error}>
|
||||
File expired
|
||||
</Text>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
154
app/components/MessageAttachments/MessageImage.tsx
Normal file
154
app/components/MessageAttachments/MessageImage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
import { useImageViewer } from "@/app/providers/ImageViewerProvider/useImageViewer";
|
||||
import { AspectRatio, Box, Flex, Overlay, Portal, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconCircleX, IconFlameFilled } from "@tabler/icons-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AttachmentProps } from "./MessageAttachments";
|
||||
import { blurhashToBase64Image, isMessageDeliveredByTime } from "@/app/utils/utils";
|
||||
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||
import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider";
|
||||
import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||
|
||||
export function MessageImage(props: AttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
const {
|
||||
downloadPercentage,
|
||||
uploadedPercentage,
|
||||
download,
|
||||
downloadStatus,
|
||||
getBlob,
|
||||
getPreview} = useAttachment(props.attachment, props.chacha_key_plain);
|
||||
const mainRef = useRef<HTMLDivElement>(null);
|
||||
const error = downloadStatus == DownloadStatus.ERROR;
|
||||
const { open } = useImageViewer();
|
||||
const preview = getPreview();
|
||||
const [blob, setBlob] = useState(props.attachment.blob);
|
||||
const [loadedImage, setLoadedImage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
constructBlob();
|
||||
}, [downloadStatus]);
|
||||
|
||||
const constructBlob = async () => {
|
||||
let blob = await getBlob();
|
||||
setBlob(blob);
|
||||
}
|
||||
|
||||
const openImageViewer = () => {
|
||||
const images: ImageToView[] = [{
|
||||
src: blob,
|
||||
caption: props.text,
|
||||
timestamp: props.timestamp
|
||||
}];
|
||||
|
||||
open(images, 0);
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||
openImageViewer();
|
||||
return;
|
||||
}
|
||||
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) {
|
||||
download();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AspectRatio onClick={onClick} ref={mainRef} style={{
|
||||
minWidth: 200,
|
||||
minHeight: 220,
|
||||
maxWidth: 200,
|
||||
maxHeight: 220,
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none'
|
||||
}} pos={'relative'}>
|
||||
{blob != "" && (
|
||||
<img style={{
|
||||
minHeight: 220,
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
objectFit: 'cover',
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid ' + colors.borderColor,
|
||||
display: loadedImage ? 'block' : 'none'
|
||||
}} src={blob} onLoad={() => setLoadedImage(true)}></img>)}
|
||||
{((downloadStatus != DownloadStatus.DOWNLOADED && downloadStatus != DownloadStatus.PENDING) || !loadedImage) && preview.length >= 20 && (
|
||||
<>
|
||||
<img style={{
|
||||
minHeight: 220,
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid ' + colors.borderColor
|
||||
}} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}></img>
|
||||
<Portal target={mainRef.current!}>
|
||||
<Flex direction={'column'} pos={'absolute'} justify={'center'} top={0} h={'100%'} align={'center'} gap={'xs'}>
|
||||
{!error && (
|
||||
<Box style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 50,
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
{downloadPercentage > 0 ? (
|
||||
<AnimatedRoundedProgress size={40} value={downloadPercentage} color="white"></AnimatedRoundedProgress>
|
||||
) : (
|
||||
<IconArrowDown size={25} color={'white'} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box p={'xs'} style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}>
|
||||
<Text size={'xs'} c={'white'}>
|
||||
Image expired
|
||||
</Text>
|
||||
<IconFlameFilled size={15} style={{
|
||||
fontSmooth: 'always'
|
||||
}} color={'white'} />
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Portal>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(props.delivered == DeliveredMessageState.WAITING && uploadedPercentage > 0 && isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)) &&
|
||||
<Portal target={mainRef.current!}>
|
||||
<Flex direction={'column'} pos={'absolute'} justify={'center'} top={0} h={'100%'} align={'center'} gap={'xs'}>
|
||||
<Box style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderRadius: 50,
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<AnimatedRoundedProgress size={40} value={uploadedPercentage > 95 ? 95 : uploadedPercentage}></AnimatedRoundedProgress>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Portal>}
|
||||
{(props.delivered == DeliveredMessageState.ERROR || (props.delivered != DeliveredMessageState.DELIVERED &&
|
||||
!isMessageDeliveredByTime(props.timestamp || 0, props.attachments.length)
|
||||
)) && (
|
||||
<Overlay center h={'100%'} radius={8} color="#000" opacity={0.85}>
|
||||
<IconCircleX size={40} color={colors.error} />
|
||||
</Overlay>
|
||||
)}
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
61
app/components/MessageAttachments/MessageReplyMessages.tsx
Normal file
61
app/components/MessageAttachments/MessageReplyMessages.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Alert, Flex, Skeleton, Text } from "@mantine/core";
|
||||
import { AttachmentProps } from "./MessageAttachments";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { ReplyedMessage } from "../ReplyedMessage/ReplyedMessage";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||||
import { modals } from "@mantine/modals";
|
||||
|
||||
export function MessageReplyMessages(props: AttachmentProps) {
|
||||
const colors = useRosettaColors();
|
||||
const [showAlertInReplyMessages, setShowAlertInReplyMessages] = useSetting<boolean>
|
||||
('showAlertInReplyMessages', true);
|
||||
const [bgInReplyMessages] = useSetting<string>
|
||||
('bgInReplyMessages', '');
|
||||
const reply = JSON.parse(props.attachment.blob);
|
||||
|
||||
//console.info("Mreply", reply);
|
||||
|
||||
const closeAlert = () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Disable Warning',
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to disable the warning about forged messages in replies?
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Yes, disable', cancel: 'No, keep it' },
|
||||
|
||||
onCancel: () => {},
|
||||
onConfirm: () => setShowAlertInReplyMessages(false),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex maw={'100%'} direction={'column'} bg={bgInReplyMessages != "" ? `var(--mantine-color-${bgInReplyMessages}-light)` : undefined} p={0} mb={'xs'} style={{
|
||||
borderLeft: '2px solid ' + (bgInReplyMessages != "" ? `var(--mantine-color-${bgInReplyMessages}-6)` : colors.error),
|
||||
borderRadius: 2
|
||||
}}>
|
||||
{reply.length <= 0 &&
|
||||
<Skeleton h={50} w={'100%'}></Skeleton>
|
||||
}
|
||||
{reply.map((msg, index) => (
|
||||
<ReplyedMessage parent={props.parent} chacha_key_plain={props.chacha_key_plain} key={index} messageReply={msg}></ReplyedMessage>
|
||||
))}
|
||||
{showAlertInReplyMessages && <Alert style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 4,
|
||||
}} variant="light" p={5} color={'red'}>
|
||||
<Flex align={'center'} gap={'sm'}>
|
||||
<Text c={'red'} fz={8}>
|
||||
Due to the use of encryption, these messages may be forged by the sender
|
||||
</Text>
|
||||
<IconX size={11} color={'red'} onClick={closeAlert}></IconX>
|
||||
</Flex>
|
||||
</Alert>}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
44
app/components/MessageError/MessageError.tsx
Normal file
44
app/components/MessageError/MessageError.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconAlertCircle, IconRefresh, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
interface MessageErrorProps {
|
||||
text: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export function MessageError(props : MessageErrorProps) {
|
||||
const colors = useRosettaColors();
|
||||
const {sendMessage, deleteMessageById} = useDialog();
|
||||
|
||||
const retry = async () => {
|
||||
deleteMessageById(props.messageId);
|
||||
sendMessage(props.text, []);
|
||||
}
|
||||
|
||||
const remove = () => {
|
||||
deleteMessageById(props.messageId);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu withArrow shadow="md" width={170}>
|
||||
<Menu.Target>
|
||||
<IconAlertCircle stroke={3} size={15} color={colors.error}></IconAlertCircle>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{props.text.trim() != "" && <Menu.Item onClick={retry} leftSection={<IconRefresh size={14} />}>
|
||||
Retry
|
||||
</Menu.Item> }
|
||||
<Menu.Item
|
||||
color="red" onClick={remove}
|
||||
leftSection={<IconTrash size={14} />}
|
||||
>
|
||||
Remove
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
app/components/MessageSkeleton/MessageSkeleton.tsx
Normal file
19
app/components/MessageSkeleton/MessageSkeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Flex, Skeleton } from "@mantine/core";
|
||||
|
||||
interface MessageSkeletonProps {
|
||||
messageHeight: number;
|
||||
}
|
||||
|
||||
export function MessageSkeleton(props : MessageSkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
<Flex p={'sm'}>
|
||||
<Skeleton width={40} height={40} radius="xl" />
|
||||
<Flex direction="column" ml="sm" style={{flex: 1}}>
|
||||
<Skeleton width={'30%'} height={10} mb={5} />
|
||||
<Skeleton width={'80%'} height={props.messageHeight} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
BIN
app/components/Messages/.DS_Store
vendored
Normal file
BIN
app/components/Messages/.DS_Store
vendored
Normal file
Binary file not shown.
369
app/components/Messages/Message.tsx
Normal file
369
app/components/Messages/Message.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { getInitialsColor, isMessageDeliveredByTime } from "@/app/utils/utils";
|
||||
import { Avatar, Box, Flex, MantineColor, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import { IconCheck, IconChecks, IconCircleCheck, IconCircleCheckFilled, IconCircleX, IconClock, IconTextCaption } from "@tabler/icons-react";
|
||||
import { MessageError } from "../MessageError/MessageError";
|
||||
import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider";
|
||||
import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
import { MessageAttachments } from "../MessageAttachments/MessageAttachments";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { useContextMenu } from "@/app/providers/ContextMenuProvider/useContextMenu";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MessageReply, useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||||
import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints";
|
||||
import { TextParser } from "../TextParser/TextParser";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { ATTACHMENTS_NOT_ALLOWED_TO_REPLY, ENTITY_LIMITS_TO_PARSE_IN_MESSAGE } from "@/app/constants";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
import { useGroupMembers } from "@/app/providers/InformationProvider/useGroupMembers";
|
||||
|
||||
|
||||
export enum MessageStyle {
|
||||
BUBBLES = 'bubbles',
|
||||
ROWS = 'rows'
|
||||
}
|
||||
|
||||
export interface MessageProps {
|
||||
message: string;
|
||||
from_me?: boolean;
|
||||
readed?: boolean;
|
||||
avatar_no_render?: boolean;
|
||||
delivered: DeliveredMessageState;
|
||||
from: string;
|
||||
timestamp: number;
|
||||
message_id: string;
|
||||
attachments: Attachment[];
|
||||
replyed?: boolean;
|
||||
is_last_message_in_stack?: boolean;
|
||||
chacha_key_plain: string;
|
||||
parent?: MessageProps;
|
||||
}
|
||||
|
||||
interface MessageSystemProps {
|
||||
message: string;
|
||||
c?: MantineColor;
|
||||
}
|
||||
|
||||
export function MessageSystem(props: MessageSystemProps) {
|
||||
const [wallpaper] = useSetting<string>
|
||||
('wallpaper', '');
|
||||
return (<>
|
||||
<Flex w={'100%'} mt={'xs'} mb={'xs'} justify={'center'} align={'center'}>
|
||||
<Box p={'sm'} style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: wallpaper != '' ? (useComputedColorScheme() == 'light' ? 'white' : '#2C2E33') : 'transparent',
|
||||
maxWidth: 200,
|
||||
borderRadius: 10,
|
||||
padding: 0
|
||||
}}>
|
||||
<Flex h={8} align={'center'} justify={'center'}>
|
||||
<Text fz={12} c={props.c || 'gray'} fw={600}>
|
||||
{props.message}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>);
|
||||
}
|
||||
|
||||
export function Message(props: MessageProps) {
|
||||
const computedTheme = useComputedColorScheme();
|
||||
const theme = useMantineTheme();
|
||||
const publicKey = usePublicKey();
|
||||
const openContextMenu = useContextMenu();
|
||||
const colors = useRosettaColors();
|
||||
const navigate = useNavigate();
|
||||
const { isSelectionStarted,
|
||||
selectMessage,
|
||||
deselectMessage,
|
||||
isMessageSelected,
|
||||
translateMessagesToDialogInput
|
||||
} = useReplyMessages();
|
||||
const { dialog } = useDialog();
|
||||
const { md } = useRosettaBreakpoints();
|
||||
const { members } = useGroupMembers(dialog);
|
||||
|
||||
const [showTimeInReplyMessages] = useSetting<boolean>
|
||||
('showTimeInReplyMessages', false);
|
||||
const [wallpaper] = useSetting<string>
|
||||
('wallpaper', '');
|
||||
|
||||
const [userInfo] = useUserInformation(publicKey);
|
||||
const [opponent] = useUserInformation(props.from);
|
||||
const user = props.from_me ? userInfo : {
|
||||
...opponent,
|
||||
avatar: ""
|
||||
};
|
||||
|
||||
const messageReply: MessageReply = {
|
||||
timestamp: props.timestamp,
|
||||
publicKey: user.publicKey,
|
||||
message: props.message,
|
||||
attachments: props.attachments.filter(a => a.type != AttachmentType.MESSAGES),
|
||||
message_id: props.message_id
|
||||
};
|
||||
|
||||
const avatars = useAvatars(user.publicKey);
|
||||
|
||||
const [messageStyle] = useSetting<MessageStyle>
|
||||
('messageStyle', MessageStyle.ROWS);
|
||||
|
||||
const computedMessageStyle = props.replyed ? MessageStyle.ROWS : messageStyle;
|
||||
|
||||
|
||||
const navigateToUserProfile = () => {
|
||||
if (isSelectionStarted()) {
|
||||
return;
|
||||
}
|
||||
navigate(`/main/profile/${user.publicKey}`);
|
||||
}
|
||||
|
||||
const canReply = () => {
|
||||
if (props.replyed) {
|
||||
return false;
|
||||
}
|
||||
if (messageReply.attachments.find((v) => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(v.type))) {
|
||||
return false;
|
||||
}
|
||||
if (messageReply.message.trim().length == 0 && messageReply.attachments.length == 0) {
|
||||
return false;
|
||||
}
|
||||
if (props.delivered != DeliveredMessageState.DELIVERED) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const onMessageSelectClick = () => {
|
||||
if (props.replyed || !canReply()) {
|
||||
return;
|
||||
}
|
||||
if (isMessageSelected(messageReply)) {
|
||||
deselectMessage(messageReply);
|
||||
} else {
|
||||
selectMessage(messageReply);
|
||||
}
|
||||
}
|
||||
|
||||
const onDobuleClick = () => {
|
||||
if (!canReply()) {
|
||||
return;
|
||||
}
|
||||
if (isSelectionStarted()) {
|
||||
return;
|
||||
}
|
||||
selectMessage(messageReply);
|
||||
translateMessagesToDialogInput(dialog);
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Box onDoubleClick={onDobuleClick} onClick={isSelectionStarted() ? onMessageSelectClick : undefined} onContextMenu={() => !props.replyed && openContextMenu([
|
||||
{
|
||||
label: 'Copy Message',
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.message);
|
||||
},
|
||||
icon: <IconTextCaption size={14}></IconTextCaption>,
|
||||
cond: async () => {
|
||||
return props.message.trim().length > 0;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: !isMessageSelected(messageReply) ? 'Select' : 'Deselect',
|
||||
action: onMessageSelectClick,
|
||||
icon: !isMessageSelected(messageReply) ? <IconCircleCheck size={14}></IconCircleCheck> : <IconCircleX color={theme.colors.red[5]} size={14}></IconCircleX>,
|
||||
cond: () => {
|
||||
return canReply();
|
||||
},
|
||||
}
|
||||
])} p={'sm'} pt={props.avatar_no_render ? 0 : 'sm'} style={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'auto'
|
||||
}}>
|
||||
{computedMessageStyle == MessageStyle.ROWS && (
|
||||
<Flex direction={'row'} justify={'space-between'} gap={'sm'}>
|
||||
<Flex direction={'row'} gap={'sm'}>
|
||||
{(!props.avatar_no_render && (md || !props.replyed)) && <Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} variant={props.parent ? 'filled' : 'light'} color="initials"></Avatar>}
|
||||
<Flex direction={'column'}>
|
||||
<Flex direction={'row'} gap={3} align={'center'}>
|
||||
{!props.avatar_no_render && (
|
||||
/** Только если не установлен флаг который
|
||||
* запрещает рендеринг аватарки и имени*/
|
||||
<>
|
||||
<Text style={{
|
||||
userSelect: 'none'
|
||||
}} size={'sm'} onClick={navigateToUserProfile} c={getInitialsColor(user.title)} fw={500}>{user.title}</Text>
|
||||
{(members.length > 0 && members[0] == props.from) && (
|
||||
<VerifiedBadge size={18} color={'gold'} verified={3}></VerifiedBadge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
{props.attachments.length > 0 &&
|
||||
<Box ml={props.avatar_no_render ? 50 : undefined}>
|
||||
<MessageAttachments parent={props} chacha_key_plain={props.chacha_key_plain} text={props.message.trim()} key={props.message_id} timestamp={props.timestamp} delivered={props.delivered} attachments={props.attachments}></MessageAttachments>
|
||||
</Box>
|
||||
}
|
||||
<Box style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
userSelect: 'text',
|
||||
fontSize: '13px',
|
||||
color: messageStyle == MessageStyle.BUBBLES ? (computedTheme == 'light' ? (props.parent?.from_me ? 'white' : 'black') : 'white') : (computedTheme == 'light' ? 'black' : 'white')
|
||||
}} ml={props.avatar_no_render ? 50 : undefined}>
|
||||
<TextParser performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex gap={5} justify={'end'} align={'flex-start'} style={{ flexShrink: 0, minWidth: props.replyed ? 0 : 50 }}>
|
||||
<Flex gap={5} justify={'end'} align={'center'}>
|
||||
{!isSelectionStarted() && <>
|
||||
{props.delivered == DeliveredMessageState.DELIVERED && <>
|
||||
{props.from_me && !props.readed && (
|
||||
<IconCheck color={theme.colors.gray[5]} stroke={3} size={16}></IconCheck>
|
||||
)}
|
||||
{props.from_me && props.readed && (
|
||||
<IconChecks stroke={3} size={16} color={theme.colors.blue[5]}></IconChecks>
|
||||
)}
|
||||
</>}
|
||||
{(props.delivered == DeliveredMessageState.WAITING && (isMessageDeliveredByTime(props.timestamp, props.attachments.length))) && <>
|
||||
<IconClock stroke={2} size={14} color={theme.colors.gray[5]}></IconClock>
|
||||
</>}
|
||||
{(props.delivered == DeliveredMessageState.ERROR || ((!isMessageDeliveredByTime(props.timestamp, props.attachments.length)) && props.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||
<MessageError messageId={props.message_id} text={props.message}></MessageError>
|
||||
)}
|
||||
</>}
|
||||
{(isSelectionStarted() && !props.replyed && canReply()) && <>
|
||||
{isMessageSelected(messageReply) ?
|
||||
<IconCircleCheckFilled size={16} color={theme.colors.green[5]}></IconCircleCheckFilled>
|
||||
:
|
||||
<IconCircleCheck size={16} color={theme.colors.gray[5]}></IconCircleCheck>
|
||||
}
|
||||
</>}
|
||||
{(isSelectionStarted() && !canReply() && !props.replyed) && <IconCircleX size={16} color={theme.colors.red[5]}></IconCircleX>}
|
||||
{(showTimeInReplyMessages || !props.replyed) && <Text style={{
|
||||
userSelect: 'none'
|
||||
}} fz={12} c={colors.chevrons.active} w={30}>{
|
||||
new Date(props.timestamp).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}</Text>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
{computedMessageStyle == MessageStyle.BUBBLES && (() => {
|
||||
const hasOnlyAttachments = props.attachments.length > 0 && props.message.trim().length === 0;
|
||||
|
||||
return (
|
||||
<Flex direction={props.from_me ? 'row-reverse' : 'row'} gap={'sm'} align={'flex-end'}>
|
||||
{(md && props.is_last_message_in_stack) && (
|
||||
<Avatar onClick={navigateToUserProfile} src={avatars.length > 0 ? avatars[0].avatar : undefined} name={user.title} color="initials" variant={wallpaper != '' ? 'filled' : 'light'} style={{ flexShrink: 0 }}></Avatar>
|
||||
)}
|
||||
{(md && !props.is_last_message_in_stack) && (
|
||||
<Box style={{ width: 40, height: 40, flexShrink: 0 }}></Box>
|
||||
)}
|
||||
<Flex direction={'column'} align={props.from_me ? 'flex-end' : 'flex-start'} gap={4}>
|
||||
{(!props.avatar_no_render && dialog.includes("#group") && wallpaper == '') && (
|
||||
<Flex direction={'row'} ml={2} gap={3} align={'center'}>
|
||||
<Text style={{
|
||||
userSelect: 'none'
|
||||
}} size={'sm'} onClick={navigateToUserProfile} c={getInitialsColor(user.title)} fw={500}>{user.title}</Text>
|
||||
{(members.length > 0 && members[0] == props.from) && (
|
||||
<VerifiedBadge size={18} color={'gold'} verified={3}></VerifiedBadge>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
<Box style={{
|
||||
backgroundColor: hasOnlyAttachments ? 'transparent' : (props.from_me
|
||||
? (computedTheme == 'light' ? theme.colors.blue[6] : theme.colors.blue[9])
|
||||
: (computedTheme == 'light' ? (wallpaper != '' ? 'white' : theme.colors.gray[3]) : theme.colors.dark[6])),
|
||||
borderRadius: props.avatar_no_render ? '8px' : '12px',
|
||||
//borderTopLeftRadius: (!props.from_me && props.avatar_no_render) ? '2px' : undefined,
|
||||
//borderTopRightRadius: (props.from_me && props.avatar_no_render) ? '2px' : undefined,
|
||||
padding: hasOnlyAttachments ? 0 : '8px 12px',
|
||||
maxWidth: md ? '500px' : '280px',
|
||||
position: 'relative',
|
||||
marginLeft: !props.from_me && props.is_last_message_in_stack ? 2 : 0
|
||||
}}>
|
||||
{props.attachments.length > 0 &&
|
||||
<Box mb={props.message.trim().length > 0 ? 4 : 0}>
|
||||
<MessageAttachments parent={props} chacha_key_plain={props.chacha_key_plain} text={props.message.trim()} key={props.message_id} timestamp={props.timestamp} delivered={props.delivered} attachments={props.attachments}></MessageAttachments>
|
||||
</Box>
|
||||
}
|
||||
{props.message.trim().length > 0 && (
|
||||
<Box style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
userSelect: 'text',
|
||||
fontSize: '14px',
|
||||
color: props.from_me ? 'white' : (computedTheme == 'light' ? 'black' : 'white')
|
||||
}}>
|
||||
<TextParser __reserved_2 performanceEntityLimit={ENTITY_LIMITS_TO_PARSE_IN_MESSAGE} oversizeIfTextSmallerThan={1} text={props.message.trim()}></TextParser>
|
||||
</Box>
|
||||
)}
|
||||
<Flex gap={5} justify={'flex-end'} align={'center'} mt={hasOnlyAttachments ? 0 : 4} style={{
|
||||
...(hasOnlyAttachments && props.attachments.find(a => a.type != AttachmentType.MESSAGES) ? {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '10px',
|
||||
backdropFilter: 'blur(4px)'
|
||||
} : {})
|
||||
}}>
|
||||
{wallpaper != '' && dialog.includes("#group") && (
|
||||
<Text style={{
|
||||
userSelect: 'none',
|
||||
color: hasOnlyAttachments ? 'white' : (props.from_me ? (computedTheme == 'light' ? theme.colors.blue[2] : theme.colors.blue[4]) : (computedTheme == 'light' ? theme.colors.gray[6] : theme.colors.gray[4]))
|
||||
}} fz={11} mr={4}>
|
||||
{user.title}
|
||||
</Text>
|
||||
)}
|
||||
{!isSelectionStarted() && <>
|
||||
{props.delivered == DeliveredMessageState.DELIVERED && <>
|
||||
{props.from_me && !props.readed && (
|
||||
<IconCheck color={hasOnlyAttachments ? 'white' : theme.colors.gray[5]} stroke={3} size={14}></IconCheck>
|
||||
)}
|
||||
{props.from_me && props.readed && (
|
||||
<IconChecks stroke={3} size={14} color={hasOnlyAttachments ? 'white' : theme.colors.blue[2]}></IconChecks>
|
||||
)}
|
||||
</>}
|
||||
{(props.delivered == DeliveredMessageState.WAITING && (isMessageDeliveredByTime(props.timestamp, props.attachments.length))) && <>
|
||||
<IconClock stroke={2} size={12} color={hasOnlyAttachments ? 'white' : theme.colors.gray[5]}></IconClock>
|
||||
</>}
|
||||
{(props.delivered == DeliveredMessageState.ERROR || ((!isMessageDeliveredByTime(props.timestamp, props.attachments.length)) && props.delivered != DeliveredMessageState.DELIVERED)) && (
|
||||
<MessageError messageId={props.message_id} text={props.message}></MessageError>
|
||||
)}
|
||||
</>}
|
||||
{(isSelectionStarted() && !props.replyed && canReply()) && <>
|
||||
{isMessageSelected(messageReply) ?
|
||||
<IconCircleCheckFilled size={14} color={hasOnlyAttachments ? 'white' : theme.colors.green[5]}></IconCircleCheckFilled>
|
||||
:
|
||||
<IconCircleCheck size={14} color={hasOnlyAttachments ? 'white' : theme.colors.gray[5]}></IconCircleCheck>
|
||||
}
|
||||
</>}
|
||||
{(isSelectionStarted() && !canReply() && !props.replyed) && <IconCircleX size={14} color={hasOnlyAttachments ? 'white' : theme.colors.red[5]}></IconCircleX>}
|
||||
{(showTimeInReplyMessages || !props.replyed) && <Text style={{
|
||||
userSelect: 'none',
|
||||
color: hasOnlyAttachments ? 'white' : (props.from_me ? (computedTheme == 'light' ? theme.colors.blue[2] : theme.colors.blue[4]) : (computedTheme == 'light' ? theme.colors.gray[6] : theme.colors.gray[4]))
|
||||
}} fz={11}>
|
||||
{new Date(props.timestamp).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</Text>}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</>);
|
||||
}
|
||||
286
app/components/Messages/Messages.tsx
Normal file
286
app/components/Messages/Messages.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Message, MessageSystem } from "./Message";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
|
||||
import { MessageSkeleton } from "../MessageSkeleton/MessageSkeleton";
|
||||
import { ScrollArea } from "@mantine/core";
|
||||
import { MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S, SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX } from "@/app/constants";
|
||||
import { DialogAffix } from "../DialogAffix/DialogAffix";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { useSetting } from "@/app/providers/SettingsProvider/useSetting";
|
||||
|
||||
export function Messages() {
|
||||
const colors = useRosettaColors();
|
||||
const publicKey = usePublicKey();
|
||||
const { messages, dialog, loadMessagesToTop, loading } = useDialog();
|
||||
const { replyMessages, isSelectionStarted } = useReplyMessages();
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastMessageRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
const isFirstRenderRef = useRef(true);
|
||||
const previousScrollHeightRef = useRef(0);
|
||||
|
||||
const [affix, setAffix] = useState(false);
|
||||
const [wallpaper] = useSetting<string>
|
||||
('wallpaper', '');
|
||||
|
||||
|
||||
const scrollToBottom = useCallback((smooth: boolean = false) => {
|
||||
if (!viewportRef.current) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!viewportRef.current) return;
|
||||
|
||||
viewportRef.current.scrollTo({
|
||||
top: viewportRef.current.scrollHeight,
|
||||
behavior: smooth ? 'smooth' : 'auto'
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Сброс состояния при смене диалога
|
||||
useEffect(() => {
|
||||
isFirstRenderRef.current = true;
|
||||
shouldAutoScrollRef.current = true;
|
||||
previousScrollHeightRef.current = 0;
|
||||
setAffix(false);
|
||||
}, [dialog]);
|
||||
|
||||
// IntersectionObserver - отслеживаем видимость последнего сообщения
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current || !viewportRef.current || loading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
//console.info("IntersectionObserver triggered ", entry.isIntersecting);
|
||||
//shouldAutoScrollRef.current = entry.isIntersecting;
|
||||
|
||||
// Если последнее сообщение видно, скрываем кнопку "вниз"
|
||||
if (entry.isIntersecting) {
|
||||
setAffix(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: viewportRef.current,
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(lastMessageRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [messages.length, loading]);
|
||||
|
||||
// MutationObserver - отслеживаем изменения контента (загрузка картинок, видео)
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
// Скроллим только если нужен авто-скролл
|
||||
if (shouldAutoScrollRef.current) {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(contentRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src', 'style', 'class']
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
// Первый рендер - скроллим вниз моментально
|
||||
useEffect(() => {
|
||||
if (loading || messages.length === 0) return;
|
||||
|
||||
if (isFirstRenderRef.current) {
|
||||
scrollToBottom(false);
|
||||
isFirstRenderRef.current = false;
|
||||
}
|
||||
}, [loading, messages.length, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if(affix){
|
||||
shouldAutoScrollRef.current = false;
|
||||
} else {
|
||||
shouldAutoScrollRef.current = true;
|
||||
}
|
||||
}, [affix]);
|
||||
|
||||
// Новое сообщение - скроллим если пользователь внизу или это его сообщение
|
||||
useEffect(() => {
|
||||
if (loading || messages.length === 0 || isFirstRenderRef.current) return;
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Скроллим если пользователь внизу или это его собственное сообщение
|
||||
if ((shouldAutoScrollRef.current || lastMessage.from_me) && !affix) {
|
||||
/**
|
||||
* Скролл только если пользователь не читает сейчас старую переписку
|
||||
* (!affix))
|
||||
*/
|
||||
//console.info("Scroll because", shouldAutoScrollRef.current);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
}, [messages.length, loading, affix, scrollToBottom]);
|
||||
|
||||
// Восстановление позиции после загрузки старых сообщений
|
||||
useEffect(() => {
|
||||
if (!viewportRef.current || previousScrollHeightRef.current === 0) return;
|
||||
|
||||
const scrollDiff = viewportRef.current.scrollHeight - previousScrollHeightRef.current;
|
||||
if (scrollDiff > 0) {
|
||||
viewportRef.current.scrollTop = scrollDiff;
|
||||
previousScrollHeightRef.current = 0;
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Скролл при отправке reply сообщения
|
||||
useEffect(() => {
|
||||
if (replyMessages.messages.length === 0 || isSelectionStarted()) return;
|
||||
scrollToBottom(true);
|
||||
}, [replyMessages.messages.length]);
|
||||
|
||||
const loadMessagesToScrollAreaTop = async () => {
|
||||
if (!viewportRef.current) return;
|
||||
|
||||
previousScrollHeightRef.current = viewportRef.current.scrollHeight;
|
||||
await loadMessagesToTop();
|
||||
};
|
||||
|
||||
const onAffixClick = () => {
|
||||
shouldAutoScrollRef.current = true;
|
||||
scrollToBottom(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea.Autosize
|
||||
type="hover"
|
||||
offsetScrollbars="y"
|
||||
scrollHideDelay={1500}
|
||||
scrollbarSize={7}
|
||||
scrollbars="y"
|
||||
h={100}
|
||||
style={{ flexGrow: 1 }}
|
||||
viewportRef={viewportRef}
|
||||
bg={colors.boxColor}
|
||||
styles={{
|
||||
viewport: {
|
||||
backgroundImage: `url(${wallpaper})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
padding: '10px',
|
||||
height: '100%'
|
||||
},
|
||||
root: {
|
||||
height: '100%'
|
||||
}
|
||||
}}
|
||||
onScrollPositionChange={(scroll) => {
|
||||
if (!viewportRef.current) return;
|
||||
|
||||
// Загружаем старые сообщения при достижении верха
|
||||
if (scroll.y === 0 && !loading && messages.length >= 20) {
|
||||
loadMessagesToScrollAreaTop();
|
||||
}
|
||||
|
||||
// Показываем/скрываем кнопку "вниз"
|
||||
const distanceFromBottom =
|
||||
(viewportRef.current.scrollHeight - viewportRef.current.clientHeight) - scroll.y;
|
||||
|
||||
setAffix(distanceFromBottom > SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX);
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{loading &&
|
||||
<>
|
||||
<MessageSkeleton messageHeight={20}></MessageSkeleton>
|
||||
<MessageSkeleton messageHeight={40}></MessageSkeleton>
|
||||
<MessageSkeleton messageHeight={70}></MessageSkeleton>
|
||||
<MessageSkeleton messageHeight={20}></MessageSkeleton>
|
||||
<MessageSkeleton messageHeight={38}></MessageSkeleton>
|
||||
</>
|
||||
}
|
||||
{!loading && messages.map((message, index) => {
|
||||
const prevMessage = messages[index - 1];
|
||||
const currentDate = new Date(message.timestamp).toDateString();
|
||||
const prevDate = prevMessage ? new Date(prevMessage.timestamp).toDateString() : null;
|
||||
const showSystem = prevDate !== currentDate;
|
||||
const isLastMessage = index === messages.length - 1;
|
||||
const isLastMessageInStack = isLastMessage || messages[index + 1].from_public_key !== message.from_public_key || (new Date(messages[index + 1].timestamp).toDateString() !== new Date(message.timestamp).toDateString()) || (messages[index + 1].timestamp - message.timestamp) >= (MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S * 1000) || (messages[index + 1].plain_message == "$a=Group created" || messages[index + 1].plain_message == "$a=Group joined");
|
||||
|
||||
return (
|
||||
<React.Fragment key={message.message_id}>
|
||||
{showSystem && (
|
||||
<MessageSystem message={
|
||||
(() => {
|
||||
const messageDate = new Date(message.timestamp);
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
const isToday = messageDate.toDateString() === today.toDateString();
|
||||
const isYesterday = messageDate.toDateString() === yesterday.toDateString();
|
||||
|
||||
if (isToday) return "today";
|
||||
if (isYesterday) return "yesterday";
|
||||
return messageDate.toLocaleDateString('en-EN', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
})()
|
||||
} />
|
||||
)}
|
||||
{index > 0 &&
|
||||
messages[index - 1].readed == 1 &&
|
||||
message.readed == 0 &&
|
||||
publicKey != message.from_public_key && (
|
||||
<MessageSystem c={colors.brandColor} message="New messages" />
|
||||
)
|
||||
}
|
||||
<div ref={isLastMessage ? lastMessageRef : undefined}>
|
||||
{message.plain_message != "$a=Group created" && message.plain_message != "$a=Group joined" && (
|
||||
<Message
|
||||
is_last_message_in_stack={isLastMessageInStack}
|
||||
chacha_key_plain={message.chacha_key}
|
||||
from={message.from_public_key}
|
||||
message={message.plain_message}
|
||||
delivered={message.delivered}
|
||||
timestamp={message.timestamp}
|
||||
message_id={message.message_id}
|
||||
attachments={message.attachments}
|
||||
from_me={message.from_public_key == publicKey}
|
||||
readed={message.readed == 1 || message.from_public_key == dialog}
|
||||
avatar_no_render={
|
||||
index > 0
|
||||
&& messages[index - 1].from_public_key == message.from_public_key
|
||||
&& (new Date(messages[index - 1].timestamp).toDateString() == new Date(message.timestamp).toDateString())
|
||||
&& (message.timestamp - messages[index - 1].timestamp) < (MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S * 1000)
|
||||
&& (messages[index - 1].plain_message != "$a=Group created" && messages[index - 1].plain_message != "$a=Group joined")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{message.plain_message == "$a=Group created" && (
|
||||
<MessageSystem c={colors.success} message="Group created" />
|
||||
)}
|
||||
{message.plain_message == "$a=Group joined" && (
|
||||
<MessageSystem c={colors.success} message="You joined the group" />
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogAffix mounted={affix} onClick={onAffixClick} />
|
||||
</ScrollArea.Autosize>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Popover, Text } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconLock } from "@tabler/icons-react";
|
||||
|
||||
export function PopoverLockIconAvatar() {
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
const colors = useRosettaColors();
|
||||
|
||||
return (
|
||||
<Popover opened={opened} withArrow position="top">
|
||||
<Popover.Target>
|
||||
<IconLock onMouseEnter={open} onMouseLeave={close} size={12} stroke={2} color={colors.success}></IconLock>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text size={'xs'} c={'dimmed'}>This avatar is end-to-end encrypted</Text>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
66
app/components/PrivateView/PrivateView.tsx
Normal file
66
app/components/PrivateView/PrivateView.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { PacketResult, ResultCode } from "@/app/providers/ProtocolProvider/protocol/packets/packet.result";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { Button, Flex, Text } from "@mantine/core";
|
||||
import { usePrivateKeyHash } from "@/app/providers/AccountProvider/usePrivateKeyHash";
|
||||
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
|
||||
|
||||
interface PrivateViewProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PrivateView(props : PrivateViewProps) {
|
||||
const privateKey = usePrivateKeyHash();
|
||||
|
||||
if(privateKey.trim() == "") {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
const openModal = (title : string, message : string) => {
|
||||
modals.open({
|
||||
title: title,
|
||||
children: (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{message}
|
||||
</Text>
|
||||
<Flex align={'center'} justify={'flex-end'}>
|
||||
<Button color={'red'} variant={'subtle'} onClick={() => modals.closeAll()} mt="md">
|
||||
Close
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
),
|
||||
centered: true,
|
||||
withCloseButton: true,
|
||||
closeOnClickOutside: true,
|
||||
closeOnEscape: true
|
||||
});
|
||||
};
|
||||
|
||||
usePacket(0x2, (packet : PacketResult) => {
|
||||
switch (packet.getResultCode()) {
|
||||
case ResultCode.SUCCESS:
|
||||
break;
|
||||
case ResultCode.ERROR:
|
||||
openModal("Error", "Unknown error from server, please try again");
|
||||
break;
|
||||
case ResultCode.USERNAME_TAKEN:
|
||||
openModal("Error", "Username is already taken");
|
||||
break;
|
||||
case ResultCode.INVALID:
|
||||
openModal("Error", "Invalid data provided");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{privateKey ? (
|
||||
props.children
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
app/components/ProfileCard/ProfileCard.module.css
Normal file
23
app/components/ProfileCard/ProfileCard.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.chevron {
|
||||
@mixin light {
|
||||
color: var(--mantine-color-dark-light);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
color: var(--mantine-color-dark-3);
|
||||
}
|
||||
}
|
||||
|
||||
.profile_card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile_card:hover{
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-9);
|
||||
}
|
||||
}
|
||||
27
app/components/ProfileCard/ProfileCard.tsx
Normal file
27
app/components/ProfileCard/ProfileCard.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Flex, Paper, Text } from "@mantine/core";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
import { ActionAvatar } from "../ActionAvatar/ActionAvatar";
|
||||
|
||||
interface ProfileCardProps {
|
||||
title: string;
|
||||
publicKey: string;
|
||||
username: string;
|
||||
verified: number;
|
||||
}
|
||||
|
||||
export function ProfileCard(props : ProfileCardProps) {
|
||||
return (
|
||||
<Paper radius="md" p="lg" bg={'transparent'}>
|
||||
<ActionAvatar title={props.title} publicKey={props.publicKey} />
|
||||
<Flex align={'center'} mt={'md'} justify={'center'} gap={5}>
|
||||
<Text ta="center" fz="lg" fw={500}>{props.title.trim() || props.publicKey.slice(0, 10)}</Text>
|
||||
{props.verified > 0 && <VerifiedBadge verified={props.verified}></VerifiedBadge>}
|
||||
</Flex>
|
||||
<Text ta="center" c="dimmed" fz="sm">
|
||||
{props.username.trim() == "" ? "" :
|
||||
"@" + props.username + " •"} {props.publicKey.slice(0, 3) + "..." +
|
||||
props.publicKey.slice(-3)}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
19
app/components/ReplyHeader/ReplyHeader.module.css
Normal file
19
app/components/ReplyHeader/ReplyHeader.module.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.short_text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block!important;
|
||||
height: unset!important;
|
||||
}
|
||||
|
||||
.button_inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.short_text {
|
||||
max-width: 60px;
|
||||
}
|
||||
}
|
||||
133
app/components/ReplyHeader/ReplyHeader.tsx
Normal file
133
app/components/ReplyHeader/ReplyHeader.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { Button, Flex, Modal, Text } from "@mantine/core";
|
||||
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { IconCornerUpLeft, IconCornerUpRightDouble, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import classes from "./ReplyHeader.module.css";
|
||||
import { DialogsList } from "../DialogsList/DialogsList";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
|
||||
import { modals } from "@mantine/modals";
|
||||
|
||||
export function ReplyHeader() {
|
||||
const colors = useRosettaColors();
|
||||
const {replyMessages,
|
||||
deselectAllMessages,
|
||||
translateMessagesToDialogInput,
|
||||
dialog,
|
||||
isSelectionInCurrentDialog} = useReplyMessages();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const navigate = useNavigate();
|
||||
const {deleteSelectedMessages} = useDialog();
|
||||
|
||||
useHotkeys([
|
||||
['Esc', deselectAllMessages]
|
||||
], [], true);
|
||||
|
||||
const onClickForward = () => {
|
||||
open();
|
||||
}
|
||||
|
||||
const selectDialogToForward = (publicKey: string) => {
|
||||
translateMessagesToDialogInput(publicKey);
|
||||
close();
|
||||
navigate(`/main/chat/${publicKey}`);
|
||||
}
|
||||
|
||||
const onClickReply = () => {
|
||||
translateMessagesToDialogInput(dialog);
|
||||
}
|
||||
|
||||
const onClickDelete = async () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Delete messages',
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to delete {replyMessages.messages.length} message{replyMessages.messages.length > 1 && 's'}? This action cannot be undone.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Delete', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'red' },
|
||||
centered: true,
|
||||
onConfirm: async () => {
|
||||
const messageIds = replyMessages.messages.map(m => m.message_id);
|
||||
await deleteSelectedMessages(messageIds);
|
||||
deselectAllMessages();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
deselectAllMessages();
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal styles={{
|
||||
root: {
|
||||
padding: 0
|
||||
},
|
||||
inner: {
|
||||
padding: 0
|
||||
},
|
||||
content: {
|
||||
padding: 0
|
||||
},
|
||||
body: {
|
||||
padding: 0
|
||||
}
|
||||
}} size={'xs'} closeOnEscape withCloseButton={false} closeOnClickOutside opened={opened} onClose={close} centered>
|
||||
<Modal.Header>
|
||||
<Text fw={600} c={'blue'} fz={14}>Select to forward</Text>
|
||||
<Button onClick={onCancel} leftSection={
|
||||
<IconX size={16}></IconX>
|
||||
} variant={'transparent'} c={'red'}>Cancel</Button>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<DialogsList mode={'all'} onSelectDialog={selectDialogToForward}></DialogsList>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Flex p={'sm'} h={'100%'} justify={'space-between'} align={'center'} gap={'xs'}>
|
||||
<Flex gap={'xs'} align={'center'}>
|
||||
<Text size={'sm'} fw={500} className={classes.short_text}>
|
||||
{replyMessages.messages.length} message{replyMessages.messages.length > 1 ? "s" : ""} selected
|
||||
</Text>
|
||||
<IconX onClick={deselectAllMessages} stroke={1.3} style={{
|
||||
cursor: 'pointer'
|
||||
}} size={20} color={colors.chevrons.active}></IconX>
|
||||
</Flex>
|
||||
<Flex gap={'sm'}>
|
||||
{isSelectionInCurrentDialog() &&
|
||||
<Button classNames={{
|
||||
label: classes.short_text,
|
||||
inner: classes.button_inner
|
||||
}} p={0} onClick={onClickDelete} color="red" variant={'transparent'} leftSection={
|
||||
<IconTrash stroke={2} size={16} ></IconTrash>
|
||||
}>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
{isSelectionInCurrentDialog() &&
|
||||
<Button classNames={{
|
||||
label: classes.short_text,
|
||||
inner: classes.button_inner
|
||||
}} p={0} onClick={onClickReply} variant={'transparent'} leftSection={
|
||||
<IconCornerUpLeft stroke={3} size={16} ></IconCornerUpLeft>
|
||||
}>
|
||||
Reply
|
||||
</Button>
|
||||
}
|
||||
<Button classNames={{
|
||||
label: classes.short_text,
|
||||
inner: classes.button_inner
|
||||
}} p={0} onClick={onClickForward} variant={'transparent'} leftSection={
|
||||
<IconCornerUpRightDouble stroke={2} size={16} ></IconCornerUpRightDouble>
|
||||
}>
|
||||
Forward
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
app/components/ReplyedMessage/ReplyedMessage.tsx
Normal file
25
app/components/ReplyedMessage/ReplyedMessage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { MessageReply } from "@/app/providers/DialogProvider/useReplyMessages";
|
||||
import { Message, MessageProps } from "../Messages/Message";
|
||||
|
||||
interface ReplyedMessageProps {
|
||||
messageReply: MessageReply;
|
||||
chacha_key_plain: string;
|
||||
parent: MessageProps;
|
||||
}
|
||||
|
||||
export function ReplyedMessage(props : ReplyedMessageProps) {
|
||||
return (
|
||||
<Message
|
||||
parent={props.parent}
|
||||
chacha_key_plain={props.chacha_key_plain}
|
||||
from={props.messageReply.publicKey}
|
||||
replyed={true}
|
||||
timestamp={props.messageReply.timestamp}
|
||||
from_me={false}
|
||||
message={props.messageReply.message}
|
||||
attachments={props.messageReply.attachments}
|
||||
delivered={1}
|
||||
message_id="replyed-message"
|
||||
></Message>
|
||||
)
|
||||
}
|
||||
3
app/components/RequestsButton/RequestsButton.module.css
Normal file
3
app/components/RequestsButton/RequestsButton.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
40
app/components/RequestsButton/RequestsButton.tsx
Normal file
40
app/components/RequestsButton/RequestsButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Box, Flex, Text } from "@mantine/core";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import classes from './RequestsButton.module.css'
|
||||
|
||||
interface RequestsButtonProps {
|
||||
count: number;
|
||||
onClick?: () => void;
|
||||
mode: 'all' | 'requests';
|
||||
}
|
||||
|
||||
export function RequestsButton(props : RequestsButtonProps) {
|
||||
return (
|
||||
<>
|
||||
{props.mode == 'all' && <>
|
||||
<Box pl={'sm'} pb={'xs'} pr={'xs'} onClick={props.onClick} className={classes.btn}>
|
||||
<Flex align={'center'} justify={'space-between'} onClick={() => {}}>
|
||||
<Text fz={12} c={'blue'} fw={'bold'} ta={'center'}>
|
||||
Requests +{props.count}
|
||||
</Text>
|
||||
<Flex align={'center'} gap={'sm'}>
|
||||
<IconChevronRight size={14} stroke={1.5} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>}
|
||||
{props.mode == 'requests' && <>
|
||||
<Box pl={'sm'} pb={'xs'} pr={'xs'} onClick={props.onClick} className={classes.btn}>
|
||||
<Flex align={'center'} justify={'space-between'} onClick={() => {}}>
|
||||
<Text fz={12} c={'red'} fw={'bold'} ta={'center'}>
|
||||
Back to all chats
|
||||
</Text>
|
||||
<Flex align={'center'} gap={'sm'}>
|
||||
<IconChevronLeft size={14} stroke={1.5} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
223
app/components/RichTextInput/RichTextInput.tsx
Normal file
223
app/components/RichTextInput/RichTextInput.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
|
||||
export interface RichTextInputProps {
|
||||
style?: React.CSSProperties;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (event: React.KeyboardEvent) => void;
|
||||
onPaste?: (event: React.ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export const RichTextInput = forwardRef((props : any, ref) => {
|
||||
const editableDivRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getValue,
|
||||
insertHTML,
|
||||
focus,
|
||||
clear,
|
||||
insertHTMLInCurrentCarretPosition
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if(props.autoFocus && editableDivRef.current){
|
||||
focusEditableElement(editableDivRef.current);
|
||||
}
|
||||
}, [props.autoFocus]);
|
||||
|
||||
const focus = () => {
|
||||
if(editableDivRef.current){
|
||||
focusEditableElement(editableDivRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
const insertHTMLInCurrentCarretPosition = (html: string) => {
|
||||
if(!editableDivRef.current){
|
||||
return;
|
||||
}
|
||||
if(document.activeElement !== editableDivRef.current){
|
||||
focusEditableElement(editableDivRef.current);
|
||||
}
|
||||
document.execCommand('insertHTML', false, html);
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
if(editableDivRef.current){
|
||||
editableDivRef.current.innerHTML = "";
|
||||
}
|
||||
if(props.onChange){
|
||||
props.onChange("");
|
||||
}
|
||||
}
|
||||
|
||||
const focusEditableElement = (element: HTMLElement) => {
|
||||
/**
|
||||
* Focus to the end of the contenteditable element
|
||||
*/
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
element.focus();
|
||||
}
|
||||
|
||||
const insertHTML = (html: string) => {
|
||||
if(!editableDivRef.current) return;
|
||||
let div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
if(props.style && props.style.fontSize){
|
||||
/**
|
||||
* Prepare all elements to have the same font size as the editable div
|
||||
*/
|
||||
div.querySelectorAll('*').forEach(el => {
|
||||
el.setAttribute('style', `
|
||||
font-size: ${props.style?.fontSize}px;
|
||||
vertical-align: sub;
|
||||
display: inline-block;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
user-select: none;
|
||||
`);
|
||||
el.setAttribute('width', '17');
|
||||
el.setAttribute('height', '17');
|
||||
});
|
||||
}
|
||||
let preparedHtml = div.innerHTML;
|
||||
//let carret = saveCarretPosition(editableDivRef.current);
|
||||
editableDivRef.current.innerHTML += preparedHtml;
|
||||
//editableDivRef.current.focus();
|
||||
//insertHtmlAtCarretPosition(preparedHtml);
|
||||
if(props.onChange){
|
||||
props.onChange(getValue());
|
||||
}
|
||||
//focusEditableElement(editableDivRef.current, carret);
|
||||
}
|
||||
|
||||
const getValue = () : string => {
|
||||
if(!editableDivRef.current) return "";
|
||||
editableDivRef.current.querySelectorAll('*').forEach(el => {
|
||||
if(el.tagName === 'BR'){
|
||||
el.textContent = '\n';
|
||||
return;
|
||||
}
|
||||
if(!el.hasAttribute("data")){
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
let text = el.getAttribute("data") || '';
|
||||
el.textContent = text;
|
||||
});
|
||||
let content = editableDivRef.current.textContent || "";
|
||||
if(content.endsWith('\n') && content.length == 1){
|
||||
/**
|
||||
* Remove trailing new line added by contenteditable div
|
||||
* (bug in some browsers includes electron)
|
||||
*/
|
||||
content = content.slice(0, -1);
|
||||
}
|
||||
return content || "";
|
||||
}
|
||||
|
||||
|
||||
// const onCopy = (event : React.ClipboardEvent) => {
|
||||
// //event.preventDefault();
|
||||
// //console.info("COPY EVENT", event);
|
||||
// //let value = getValue();
|
||||
// //console.info("COPY VALUE", value);
|
||||
// //event.clipboardData.setData('text/plain', value);
|
||||
// }
|
||||
|
||||
const onPaste = (event: React.ClipboardEvent) => {
|
||||
event.preventDefault();
|
||||
let text = event.clipboardData.getData('text/plain');
|
||||
if(text.trim() != '' && editableDivRef.current){
|
||||
const html = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
document.execCommand('insertHTML', false, html);
|
||||
if(props.onChange){
|
||||
props.onChange(getValue());
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if(event.clipboardData.items[0].kind !== 'string'){
|
||||
event.preventDefault();
|
||||
}
|
||||
if(props.onPaste){
|
||||
props.onPaste(event);
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyPress = (event : React.KeyboardEvent) => {
|
||||
if (event.keyCode == 13 && event.shiftKey == true) {
|
||||
//event.preventDefault();
|
||||
//addLineBreak();
|
||||
//return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if(editableDivRef.current?.innerHTML.trim() == '<br>'){
|
||||
editableDivRef.current.innerHTML = '';
|
||||
}
|
||||
if(props.onChange){
|
||||
props.onChange(getValue());
|
||||
}
|
||||
});
|
||||
if(props.onKeyDown){
|
||||
props.onKeyDown(event);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: props.style?.width || '100%',
|
||||
maxWidth: props.style?.width || '100%',
|
||||
display: 'inline-block',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div
|
||||
onDrag={e => e.preventDefault()}
|
||||
onPaste={onPaste}
|
||||
//onCopy={onCopy}
|
||||
onKeyDown={onKeyPress}
|
||||
ref={editableDivRef}
|
||||
contentEditable={true}
|
||||
suppressContentEditableWarning
|
||||
style={{
|
||||
overflowX: 'hidden',
|
||||
maxHeight: 150,
|
||||
maxWidth: '100%',
|
||||
overflowY: 'auto',
|
||||
wordWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
verticalAlign: 'middle',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
...props.style,
|
||||
width: '100%',
|
||||
}}
|
||||
></div>
|
||||
{getValue() == "" && props.placeholder && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
pointerEvents: 'none',
|
||||
color: '#888',
|
||||
userSelect: 'none',
|
||||
fontSize: props.style?.fontSize || 14,
|
||||
}}>
|
||||
{props.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
});
|
||||
17
app/components/RosettaPower/RosettaPower.tsx
Normal file
17
app/components/RosettaPower/RosettaPower.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Flex, MantineSize, Text } from "@mantine/core";
|
||||
import { SvgR } from "../SvgR/SvgR";
|
||||
|
||||
interface RosettaPowerProps {
|
||||
mt?: number | string | MantineSize;
|
||||
}
|
||||
|
||||
export function RosettaPower(props: RosettaPowerProps) {
|
||||
return (
|
||||
<Flex style={{
|
||||
userSelect: 'none'
|
||||
}} mt={props.mt} justify={"center"} direction={'row'} align={"center"} gap={'xs'}>
|
||||
<SvgR width={10} height={10} fill="gray"></SvgR>
|
||||
<Text size={'xs'} c={'dimmed'}>rosetta - powering freedom</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
7
app/components/SearchRow/SearchRow.module.css
Normal file
7
app/components/SearchRow/SearchRow.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.search_item {
|
||||
cursor: pointer;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
}
|
||||
}
|
||||
31
app/components/SearchRow/SearchRow.tsx
Normal file
31
app/components/SearchRow/SearchRow.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PacketSearchUser } from "@/app/providers/ProtocolProvider/protocol/packets/packet.search";
|
||||
import { Avatar, Flex, Text } from "@mantine/core";
|
||||
import classes from './SearchRow.module.css'
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
|
||||
interface SearchRowProps {
|
||||
user: PacketSearchUser;
|
||||
onDialogClick: (publicKey: string) => void;
|
||||
}
|
||||
|
||||
export function SearchRow(props: SearchRowProps) {
|
||||
const avatars = useAvatars(props.user.publicKey, false);
|
||||
return (
|
||||
<Flex onContextMenu={() => props.onDialogClick(props.user.publicKey)} onClick={() => props.onDialogClick(props.user.publicKey)} className={classes.search_item} p={'sm'} direction={'row'} gap={'sm'}>
|
||||
<Avatar
|
||||
size={'md'}
|
||||
color={'initials'}
|
||||
name={props.user.title || props.user.publicKey}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : undefined}
|
||||
></Avatar>
|
||||
<Flex direction={'column'}>
|
||||
<Flex gap={3} align={'center'}>
|
||||
<Text fz={12}>{props.user.title || props.user.publicKey.slice(0, 10)}</Text>
|
||||
{props.user.verified > 0 && <VerifiedBadge hideTooltip size={15} verified={props.user.verified}></VerifiedBadge>}
|
||||
</Flex>
|
||||
<Text fz={10} c="dimmed">@{props.user.username || props.user.publicKey.slice(0, 10) + "..."}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
35
app/components/SettingsAlert/SettingsAlert.tsx
Normal file
35
app/components/SettingsAlert/SettingsAlert.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Flex, Paper, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconAlertTriangleFilled } from "@tabler/icons-react";
|
||||
|
||||
interface SettingsAlertProps {
|
||||
text: string;
|
||||
type? : 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export function SettingsAlert(props : SettingsAlertProps) {
|
||||
const theme = useMantineTheme();
|
||||
const type = props.type || 'warning';
|
||||
const colors = useRosettaColors();
|
||||
return (
|
||||
<Paper withBorder style={{
|
||||
borderTop: '1px solid ' + colors.borderColor,
|
||||
borderBottom: '1px solid ' + colors.borderColor,
|
||||
borderLeft: '1px solid ' + colors.borderColor,
|
||||
borderRight: '1px solid ' + colors.borderColor,
|
||||
}} color={'red'} p={'lg'}>
|
||||
<Flex align={'center'} direction={'column'} justify={'center'}>
|
||||
{type == 'warning' && <>
|
||||
<IconAlertTriangleFilled size={48} color={theme.colors.yellow[6]}></IconAlertTriangleFilled>
|
||||
</>}
|
||||
{type == 'error' && <>
|
||||
<IconAlertTriangleFilled size={48} color={theme.colors.red[6]}></IconAlertTriangleFilled>
|
||||
</>}
|
||||
{type == 'info' && <>
|
||||
<IconAlertTriangleFilled size={48} color={theme.colors.blue[6]}></IconAlertTriangleFilled>
|
||||
</>}
|
||||
<Text mt={'sm'} c={'gray'} size={'sm'} ta={'center'}>{props.text}</Text>
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
17
app/components/SettingsIcon/SettingsIcon.tsx
Normal file
17
app/components/SettingsIcon/SettingsIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Flex, MantineColor } from "@mantine/core";
|
||||
|
||||
interface SettingsIconProps {
|
||||
bg: MantineColor;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
export function SettingsIcon(props : SettingsIconProps) {
|
||||
|
||||
return (
|
||||
<Flex style={{
|
||||
borderRadius: 6
|
||||
}} bg={props.bg} h={21} w={21} align={'center'} justify={'center'}>
|
||||
<props.icon size={15} color={'white'} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
14
app/components/SettingsInput/SettingsInput.module.css
Normal file
14
app/components/SettingsInput/SettingsInput.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.input{
|
||||
cursor: pointer!important;
|
||||
@mixin light {
|
||||
color: var(--mantine-color-dark-3);
|
||||
text-align: right;
|
||||
}
|
||||
@mixin dark {
|
||||
color: #CCC;
|
||||
text-align: right;
|
||||
}
|
||||
&[data-disabled] {
|
||||
background-color: transparent!important;
|
||||
}
|
||||
}
|
||||
339
app/components/SettingsInput/SettingsInput.tsx
Normal file
339
app/components/SettingsInput/SettingsInput.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { Box, DefaultMantineColor, Flex, Input, MantineSpacing, Paper, Select, StyleProp, Switch, Text } from "@mantine/core"
|
||||
import classes from './SettingsInput.module.css'
|
||||
import { Children, cloneElement, HTMLInputTypeAttribute, isValidElement, MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
|
||||
|
||||
export function SettingsInput() {}
|
||||
export interface SettingsInputCopy {
|
||||
hit: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
style?: any;
|
||||
mt?: StyleProp<MantineSpacing>;
|
||||
}
|
||||
export interface SettingsInputDefaultProps {
|
||||
hit: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (event : any) => void;
|
||||
style?: any;
|
||||
mt?: StyleProp<MantineSpacing>;
|
||||
rightSection?: ReactNode;
|
||||
type?: HTMLInputTypeAttribute;
|
||||
}
|
||||
export interface SettingsInputGroupProps {
|
||||
mt?: StyleProp<MantineSpacing>;
|
||||
children: any;
|
||||
}
|
||||
export interface SettingsInputClickableProps {
|
||||
onClick: () => void;
|
||||
hit: string;
|
||||
placeholder?: string;
|
||||
style?: any;
|
||||
mt?: StyleProp<MantineSpacing>;
|
||||
value?: string;
|
||||
rightSection?: ReactNode;
|
||||
c?: StyleProp<DefaultMantineColor>;
|
||||
rightChevronHide?: boolean;
|
||||
settingsIcon?: React.ReactNode;
|
||||
}
|
||||
export interface SettingsInputSelectProps {
|
||||
hit: string;
|
||||
variants: string[];
|
||||
mt?: StyleProp<MantineSpacing>;
|
||||
style?: any;
|
||||
leftSection?: ReactNode;
|
||||
onChange?: (value: string|undefined) => void;
|
||||
width?: number;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface SettingsInputSwitch {
|
||||
hit: string;
|
||||
mt?: StyleProp<MantineSpacing>;
|
||||
style?: any;
|
||||
onChange?: (value: boolean) => void;
|
||||
defaultValue: boolean;
|
||||
}
|
||||
|
||||
SettingsInput.Copy = SettingsInputCopy;
|
||||
SettingsInput.Clickable = SettingsInputClickable;
|
||||
SettingsInput.Default = SettingsInputDefault;
|
||||
SettingsInput.Group = SettingsInputGroup;
|
||||
SettingsInput.Select = SettingsInputSelect;
|
||||
SettingsInput.Switch = SettingsInputSwitch;
|
||||
|
||||
function SettingsInputSwitch(props: SettingsInputSwitch) {
|
||||
const colors = useRosettaColors();
|
||||
const [checked, setChecked] = useState(props.defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(props.defaultValue);
|
||||
}, [props.defaultValue]);
|
||||
|
||||
const onSwitch = (checked: boolean) => {
|
||||
if(props.onChange){
|
||||
props.onChange(checked);
|
||||
}
|
||||
setChecked(checked);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
||||
root: {
|
||||
borderTop: '1px solid ' + colors.borderColor,
|
||||
borderBottom: '1px solid ' + colors.borderColor,
|
||||
borderLeft: '1px solid ' + colors.borderColor,
|
||||
borderRight: '1px solid ' + colors.borderColor,
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex direction={'row'} pr={'sm'} pl={'sm'} align={'center'} justify={'space-between'}>
|
||||
<Text size={'sm'} fw={400}>{props.hit}</Text>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
height: 36,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(event) => onSwitch(event.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsInputSelect(props: SettingsInputSelectProps) {
|
||||
const colors = useRosettaColors();
|
||||
const [value, setValue] = useState(props.defaultValue);
|
||||
|
||||
const onChange = (selectValue : any) => {
|
||||
if(selectValue != value && selectValue != null){
|
||||
props.onChange!(selectValue);
|
||||
setValue(selectValue);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
||||
root: {
|
||||
borderTop: '1px solid ' + colors.borderColor,
|
||||
borderBottom: '1px solid ' + colors.borderColor,
|
||||
borderLeft: '1px solid ' + colors.borderColor,
|
||||
borderRight: '1px solid ' + colors.borderColor,
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex direction={'row'} pr={'sm'} pl={'sm'} align={'center'} justify={'space-between'}>
|
||||
<Text size={'sm'} fw={400}>{props.hit}</Text>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
height: 36,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
<Select allowDeselect={false} onChange={(v) => onChange(v)} style={{
|
||||
width: props.width
|
||||
}} leftSection={props.leftSection} color="gray" p={0} classNames={{
|
||||
input: classes.input
|
||||
}} variant={'unstyled'} comboboxProps={{
|
||||
middlewares: { flip: true, shift: true }, offset: 0, transitionProps: { transition: 'pop', duration: 50 } }} data={props.variants} value={props.defaultValue}>
|
||||
</Select>
|
||||
</div>
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsInputCopy(props : SettingsInputCopy) {
|
||||
const colors = useRosettaColors();
|
||||
const {copied, copy} = useClipboard({
|
||||
timeout: 1500
|
||||
});
|
||||
const onClick = (e : MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copy(props.value);
|
||||
}
|
||||
return (
|
||||
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
||||
root: {
|
||||
borderTop: '1px solid ' + colors.borderColor,
|
||||
borderBottom: '1px solid ' + colors.borderColor,
|
||||
borderLeft: '1px solid ' + colors.borderColor,
|
||||
borderRight: '1px solid ' + colors.borderColor,
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}}
|
||||
|
||||
onClick={onClick}
|
||||
>
|
||||
<Flex direction={'row'} pr={'sm'} pl={'sm'} align={'center'} justify={'space-between'}>
|
||||
<Text size={'sm'} fw={400}>{props.hit}</Text>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
height: 36,
|
||||
cursor: 'pointer'
|
||||
}} onClick={onClick}>
|
||||
{!copied && (
|
||||
<Input defaultValue={ props.value } readOnly onClick={onClick} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
|
||||
input: classes.input
|
||||
}} placeholder={props.placeholder}></Input>)}
|
||||
{copied && (
|
||||
<Input defaultValue={'copied'} readOnly spellCheck={false} styles={{
|
||||
input: {
|
||||
color: 'var(--mantine-color-green-6)',
|
||||
textAlign: 'right',
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}} variant={'unstyled'}></Input>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsInputClickable(
|
||||
props : SettingsInputClickableProps
|
||||
) {
|
||||
const colors = useRosettaColors();
|
||||
const onClick = (e : MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
props.onClick();
|
||||
}
|
||||
return (
|
||||
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
||||
root: {
|
||||
borderTop: '1px solid ' + colors.borderColor,
|
||||
borderBottom: '1px solid ' + colors.borderColor,
|
||||
borderLeft: '1px solid ' + colors.borderColor,
|
||||
borderRight: '1px solid ' + colors.borderColor,
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}}
|
||||
|
||||
onClick={onClick}
|
||||
>
|
||||
<Flex direction={'row'} pr={'sm'} pl={'sm'} align={'center'} justify={'space-between'}>
|
||||
<Flex gap={'sm'} align={'center'}>
|
||||
{props.settingsIcon}
|
||||
<Text size={'sm'} c={props.c} fw={400}>{props.hit}</Text>
|
||||
</Flex>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
height: 36,
|
||||
cursor: 'pointer'
|
||||
}} onClick={onClick}>
|
||||
{props.rightSection && (
|
||||
<Input defaultValue={props.value} readOnly onClick={onClick} rightSection={props.rightSection} classNames={{
|
||||
input: classes.input
|
||||
}} spellCheck={false} variant={'unstyled'}></Input>
|
||||
)}
|
||||
{!props.rightSection && (
|
||||
<Input readOnly defaultValue={props.value} onClick={onClick} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
|
||||
input: classes.input
|
||||
}} placeholder={props.placeholder}></Input>)
|
||||
}
|
||||
{!props.rightChevronHide && (<IconChevronRight size={20} onClick={onClick} color={colors.chevrons.active}></IconChevronRight>)}
|
||||
</div>
|
||||
</Flex>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsInputDefault(props : SettingsInputDefaultProps) {
|
||||
const colors = useRosettaColors();
|
||||
const input = useRef<any>(undefined);
|
||||
|
||||
const onClick = (e : MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if(!props.disabled){
|
||||
input.current.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return (<>
|
||||
<Paper mt={props.mt} style={props.style} withBorder styles={{
|
||||
root: {
|
||||
borderTop: '1px solid ' + colors.borderColor,
|
||||
borderBottom: '1px solid ' + colors.borderColor,
|
||||
borderLeft: '1px solid ' + colors.borderColor,
|
||||
borderRight: '1px solid ' + colors.borderColor,
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}}
|
||||
|
||||
onClick={onClick}
|
||||
>
|
||||
<Flex direction={'row'} pr={'sm'} pl={'sm'} align={'center'} justify={'space-between'}>
|
||||
<Text size={'sm'} fw={400}>{props.hit}</Text>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
height: 36,
|
||||
cursor: 'pointer'
|
||||
}} onClick={onClick}>
|
||||
{props.rightSection && (
|
||||
<Input type={props.type} defaultValue={props.value} readOnly onClick={onClick} rightSection={props.rightSection} classNames={{
|
||||
input: classes.input
|
||||
}} spellCheck={false} variant={'unstyled'}></Input>
|
||||
)}
|
||||
{!props.rightSection && (
|
||||
<Input type={props.type} defaultValue={!props.onChange ? props.value : undefined} value={!props.onChange ? undefined : props.value} ref={input} disabled={props.disabled} onClick={(e) => {
|
||||
onClick(e)
|
||||
}} onChange={props.onChange} variant={'unstyled'} spellCheck={false} color="gray" classNames={{
|
||||
input: classes.input
|
||||
}} placeholder={props.placeholder}></Input>)
|
||||
}
|
||||
</div>
|
||||
</Flex>
|
||||
</Paper>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
function SettingsInputGroup(props : SettingsInputGroupProps) {
|
||||
const colors = useRosettaColors();
|
||||
|
||||
const childrenArray = Children.toArray(props.children).filter(
|
||||
(child): child is React.ReactElement<{ style?: React.CSSProperties }> =>
|
||||
isValidElement(child)
|
||||
);
|
||||
return (
|
||||
<Box mt={props.mt}>
|
||||
{childrenArray.map((child, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === childrenArray.length - 1;
|
||||
return cloneElement(child, {
|
||||
style: {
|
||||
borderRadius: isFirst
|
||||
? "var(--mantine-radius-default) var(--mantine-radius-default) 0 0"
|
||||
: isLast
|
||||
? "0 0 var(--mantine-radius-default) var(--mantine-radius-default)"
|
||||
: "0",
|
||||
borderTop: isLast ? 'unset' : '1px solid ' + colors.borderColor,
|
||||
borderBottom: isFirst ? '1px solid ' + colors.borderColor : '1px solid ' + colors.borderColor,
|
||||
...(child.props.style || {}),
|
||||
},
|
||||
});
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
27
app/components/SettingsPaper/SettingsPaper.tsx
Normal file
27
app/components/SettingsPaper/SettingsPaper.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { MantineSize, Paper } from "@mantine/core";
|
||||
|
||||
export interface SettingsPaperProps {
|
||||
children: React.ReactNode;
|
||||
mt?: MantineSize;
|
||||
style?: React.CSSProperties;
|
||||
p?: MantineSize;
|
||||
}
|
||||
|
||||
export function SettingsPaper(props: SettingsPaperProps) {
|
||||
const colors = useRosettaColors();
|
||||
|
||||
return (
|
||||
<Paper mt={props.mt} p={props.p} style={props.style} withBorder styles={{
|
||||
root: {
|
||||
borderTop: '1px solid ' + colors.borderColor,
|
||||
borderBottom: '1px solid ' + colors.borderColor,
|
||||
borderLeft: '1px solid ' + colors.borderColor,
|
||||
borderRight: '1px solid ' + colors.borderColor,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
9
app/components/SettingsText/SettingsText.tsx
Normal file
9
app/components/SettingsText/SettingsText.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
export interface SettingsTextProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsText(props: SettingsTextProps) {
|
||||
return (<Text fz={10} mt={3} c={'gray'} pl={'xs'} pr={'xs'}>{props.children}</Text>);
|
||||
}
|
||||
13
app/components/SettingsTitle/SettingsTitle.tsx
Normal file
13
app/components/SettingsTitle/SettingsTitle.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MantineColor, MantineSize, Text } from "@mantine/core";
|
||||
|
||||
export interface SettingsTitleProps {
|
||||
children: React.ReactNode;
|
||||
mt?: MantineSize;
|
||||
c?: MantineColor;
|
||||
}
|
||||
|
||||
export function SettingsTitle(props: SettingsTitleProps) {
|
||||
return (<Text fz={12} c={'dimmed'} style={{
|
||||
textTransform: 'uppercase',
|
||||
}} pl={'xs'} pr={'xs'} mb={5} mt={props.mt}>{props.children}</Text>);
|
||||
}
|
||||
32
app/components/SparkText/SparkText.tsx
Normal file
32
app/components/SparkText/SparkText.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SparkTextProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const sparkTextStyle: React.CSSProperties = {
|
||||
textShadow: '0 0 5px rgba(255, 255, 255, 0.5), 0 0 10px rgba(255, 255, 255, 0.5), 0 0 15px rgba(255, 255, 255, 0.5)',
|
||||
animation: 'sparkle 1s infinite'
|
||||
};
|
||||
|
||||
const SparkText: React.FC<SparkTextProps> = ({ text }) => {
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 5px rgba(255, 255, 255, 0.5), 0 0 10px rgba(255, 255, 255, 0.5), 0 0 15px rgba(163, 13, 13, 0.5);
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 10px rgb(214, 44, 44), 0 0 20px rgb(139, 198, 31), 0 0 30px rgb(41, 195, 82);
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<span style={sparkTextStyle}>{text}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SparkText;
|
||||
10
app/components/SvgR/SvgR.tsx
Normal file
10
app/components/SvgR/SvgR.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
interface SvgRProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
export function SvgR(props: SvgRProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={props.width} height={props.height} viewBox="0 0 384 383.999986" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ab9f3e3410"><path d="M 134 109 L 369.46875 109 L 369.46875 381.34375 L 134 381.34375 Z M 134 109 " clipRule="nonzero" /></clipPath><clipPath id="39bead0a6b"><path d="M 14.71875 2.59375 L 249 2.59375 L 249 222 L 14.71875 222 Z M 14.71875 2.59375 " clipRule="nonzero" /></clipPath></defs><g clipPath="url(#ab9f3e3410)"><path fill={props.fill || "#ffffff"} d="M 254.15625 284.453125 C 288.414062 275.191406 316.179688 260.617188 337.414062 240.769531 C 358.632812 220.917969 369.257812 195.238281 369.257812 163.691406 L 369.257812 109.996094 L 249.550781 110.222656 L 249.550781 168.148438 C 249.550781 184.847656 241.75 198.195312 226.148438 208.21875 C 210.550781 218.226562 188.175781 223.234375 159.007812 223.234375 L 134.070312 223.234375 L 134.070312 300.996094 L 206.652344 381.429688 L 344.765625 381.429688 L 254.15625 284.453125 " fillOpacity="1" fillRule="nonzero" /></g><g clipPath="url(#39bead0a6b)"><path fill={props.fill || "#ffffff"} d="M 248.417969 109.257812 L 248.417969 2.605469 L 14.769531 2.605469 L 14.769531 221.519531 L 132.9375 221.519531 L 132.9375 109.257812 L 248.417969 109.257812 " fillOpacity="1" fillRule="nonzero" /></g></svg>)
|
||||
}
|
||||
43
app/components/TextChain/TextChain.module.css
Normal file
43
app/components/TextChain/TextChain.module.css
Normal file
@@ -0,0 +1,43 @@
|
||||
.displayArea {
|
||||
padding: 16px;
|
||||
border: 2px dashed light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: 8px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
min-height: 250px;
|
||||
max-height: 250px;
|
||||
height: 250px;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.wordBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-5));
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.wordBox:hover {
|
||||
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.wrapper{
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 5px 8px;
|
||||
background-color: var(--mantine-color-blue-light);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
56
app/components/TextChain/TextChain.tsx
Normal file
56
app/components/TextChain/TextChain.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Box, MantineSize, Text, SimpleGrid } from "@mantine/core";
|
||||
import classes from './TextChain.module.css'
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface TextChainProps {
|
||||
text: string;
|
||||
mt?: MantineSize;
|
||||
}
|
||||
|
||||
export function TextChain(props : TextChainProps) {
|
||||
const text = props.text;
|
||||
const [mounted, setMounted] = useState<boolean[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const words = text.split(" ");
|
||||
setMounted(new Array(words.length).fill(false));
|
||||
|
||||
words.forEach((_, index) => {
|
||||
setTimeout(() => {
|
||||
setMounted(prev => {
|
||||
const newMounted = [...prev];
|
||||
newMounted[index] = true;
|
||||
return newMounted;
|
||||
});
|
||||
}, index * 50);
|
||||
});
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<Box mt={props.mt}>
|
||||
<Box className={classes.displayArea}>
|
||||
<Text size="sm" mb="md" c="dimmed">
|
||||
Your seed phrase:
|
||||
</Text>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
{text.split(" ").map((v, i) => {
|
||||
return (
|
||||
<Box
|
||||
key={i}
|
||||
className={classes.wordBox}
|
||||
style={{
|
||||
opacity: mounted[i] ? 1 : 0,
|
||||
transform: mounted[i] ? 'scale(1)' : 'scale(0.9)',
|
||||
transition: 'opacity 300ms ease, transform 300ms ease',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mr={4}>{i + 1}.</Text>
|
||||
<Text size="sm" fw={500}>{v}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
222
app/components/TextParser/TextParser.tsx
Normal file
222
app/components/TextParser/TextParser.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Anchor, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import dark from 'react-syntax-highlighter/dist/esm/styles/prism/one-dark';
|
||||
import light from 'react-syntax-highlighter/dist/esm/styles/prism/one-light';
|
||||
import { UserMention } from "../UserMention/UserMention";
|
||||
import { Emoji } from "../Emoji/Emoji";
|
||||
import { GroupInviteMessage } from "../GroupInviteMessage/GroupInviteMessage";
|
||||
import { ALLOWED_DOMAINS_ZONES } from "@/app/constants";
|
||||
|
||||
interface TextParserProps {
|
||||
text: string;
|
||||
/**
|
||||
* If true, the parsed entities will be rendered without hydration (static rendering).
|
||||
*/
|
||||
noHydrate?: boolean;
|
||||
/**
|
||||
* If the text (excluding emojis) is smaller than this value, render emojis in oversize (40px).
|
||||
*/
|
||||
oversizeIfTextSmallerThan?: number;
|
||||
/**
|
||||
* Limits the number of parsed entities (like links, mentions, emojis, etc.) in the text.
|
||||
* If the limit is reached, the remaining text will be rendered as plain text.
|
||||
*/
|
||||
performanceEntityLimit?: number;
|
||||
/**
|
||||
* Flags to enable other effects
|
||||
*/
|
||||
__reserved_1?: boolean;
|
||||
__reserved_2?: boolean;
|
||||
__reserved_3?: boolean;
|
||||
}
|
||||
|
||||
interface FormatRule {
|
||||
pattern: RegExp[];
|
||||
render: (match: string) => React.ReactNode;
|
||||
flush: (match: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function TextParser(props: TextParserProps) {
|
||||
const computedTheme = useComputedColorScheme();
|
||||
const theme = useMantineTheme();
|
||||
let entityCount = 0;
|
||||
|
||||
const formatRules : FormatRule[] = [
|
||||
{
|
||||
pattern: [
|
||||
/(https?:\/\/[^\s]+)/g,
|
||||
/\b(?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:\/[^\s]*)?/g
|
||||
],
|
||||
render: (match: string) => {
|
||||
let domainZone = match.split('.').pop() || '';
|
||||
domainZone = domainZone.split('/')[0];
|
||||
if(!ALLOWED_DOMAINS_ZONES.includes(domainZone)) {
|
||||
return <>{match}</>;
|
||||
}
|
||||
return <Anchor style={{
|
||||
userSelect: 'auto',
|
||||
color: props.__reserved_2 ? theme.colors.blue[2] : undefined
|
||||
}} fz={14} href={match.startsWith('http') ? match : 'https://' + match} target="_blank" rel="noopener noreferrer">{match}</Anchor>;
|
||||
},
|
||||
flush: (match: string) => {
|
||||
return <>{match}</>;
|
||||
}
|
||||
},
|
||||
{
|
||||
pattern: [/\*\*(.+?)\*\*/],
|
||||
render: (match: string) => {
|
||||
const boldText = match.replace(/^\*\*(.+)\*\*$/, '$1');
|
||||
return <b style={{
|
||||
userSelect: 'auto'
|
||||
}}>{boldText}</b>;
|
||||
},
|
||||
flush: (match: string) => {
|
||||
const boldText = match.replace(/^\*\*(.+)\*\*$/, '$1');
|
||||
return <>{boldText}</>;
|
||||
}
|
||||
},
|
||||
{
|
||||
// language```code```
|
||||
pattern: [/([a-zA-Z0-9]+)```([\s\S]*?)```/],
|
||||
render: (match: string) => {
|
||||
const langMatch = match.match(/^([a-zA-Z0-9]+)```([\s\S]*?)```$/);
|
||||
const language = langMatch ? langMatch[1] : "plaintext";
|
||||
const codeContent = langMatch ? langMatch[2] : match;
|
||||
return (
|
||||
<SyntaxHighlighter customStyle={{
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
margin: '8px 0',
|
||||
overflowX: 'scroll',
|
||||
maxWidth: '40vw'
|
||||
}} showLineNumbers wrapLongLines lineProps={{
|
||||
style: {
|
||||
//flexWrap: 'wrap'
|
||||
}
|
||||
}} wrapLines language={language} style={
|
||||
computedTheme === 'dark' ? dark : light
|
||||
}>
|
||||
{codeContent.trim()}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
},
|
||||
flush: (match: string) => {
|
||||
const langMatch = match.match(/^([a-zA-Z0-9]+)```([\s\S]*?)```$/);
|
||||
const codeContent = langMatch ? langMatch[2] : match;
|
||||
return <>{codeContent}</>;
|
||||
}
|
||||
},
|
||||
{
|
||||
// @username
|
||||
pattern: [/@([a-zA-Z0-9_]+)/],
|
||||
render: (match: string) => {
|
||||
return <UserMention color={props.__reserved_2 ? theme.colors.blue[2] : undefined} key={match} username={match}></UserMention>;
|
||||
},
|
||||
flush: (match: string) => {
|
||||
return <>{match}</>;
|
||||
}
|
||||
},
|
||||
{
|
||||
// :emoji_code:
|
||||
pattern: [/:emoji_([a-zA-Z0-9_-]+):/],
|
||||
render: (match: string) => {
|
||||
const emojiCode = match.slice(1, -1);
|
||||
let textWithoutEmojis = props.text.replace(/:emoji_[a-zA-Z0-9_-]+:/g, '');
|
||||
if(textWithoutEmojis.length <= (props.oversizeIfTextSmallerThan ?? 0)) {
|
||||
return <Emoji size={40} unified={emojiCode.replace("emoji_", "")}></Emoji>;
|
||||
}
|
||||
return <Emoji unified={emojiCode.replace("emoji_", "")}></Emoji>;
|
||||
},
|
||||
flush: (match: string) => {
|
||||
const emojiCode = match.slice(1, -1);
|
||||
return <Emoji unified={emojiCode.replace("emoji_", "")}></Emoji>;
|
||||
}
|
||||
},
|
||||
{
|
||||
// $a=Attachment text
|
||||
pattern: [/^\$a=(.+)$/],
|
||||
render: (match: string) => {
|
||||
const attachmentText = match.replace(/^\$a=(.+)$/, '$1');
|
||||
return <>{attachmentText}</>;
|
||||
},
|
||||
flush: (match: string) => {
|
||||
const attachmentText = match.replace(/^\$a=(.+)$/, '$1');
|
||||
return <Text display={'inline-block'} c={props.__reserved_1 ? '#FFF' : 'blue'}>{attachmentText}</Text>;
|
||||
}
|
||||
},
|
||||
{
|
||||
//#group:stringbase64
|
||||
pattern: [/^#group:([A-Za-z0-9+/=:]+)$/],
|
||||
render: (match: string) => {
|
||||
const groupString = match.replace(/^#group:([A-Za-z0-9+/=]+)$/, '$1');
|
||||
return <GroupInviteMessage groupInviteCode={groupString}></GroupInviteMessage>;
|
||||
},
|
||||
flush: () => {
|
||||
return <Text c={props.__reserved_1 ? '#FFF' : 'blue'}>Group Invite Code</Text>;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function parseText(text: string): React.ReactNode[] {
|
||||
let result: React.ReactNode[] = [];
|
||||
let remainingText = text;
|
||||
let index = 0;
|
||||
|
||||
while (remainingText.length > 0) {
|
||||
let earliestMatch: {start: number, end: number, rule: FormatRule, match: string} | null = null;
|
||||
|
||||
for (const rule of formatRules) {
|
||||
for (const pattern of rule.pattern) {
|
||||
pattern.lastIndex = 0; // Reset regex state for global patterns
|
||||
const match = pattern.exec(remainingText);
|
||||
if (match && (earliestMatch === null || match.index < earliestMatch.start)) {
|
||||
earliestMatch = {
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
rule,
|
||||
match: match[0]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (earliestMatch) {
|
||||
// Performance limit check
|
||||
if (props.performanceEntityLimit !== undefined && entityCount >= props.performanceEntityLimit) {
|
||||
result.push(
|
||||
<React.Fragment key={index++}>
|
||||
{remainingText}
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
}
|
||||
entityCount += 1;
|
||||
|
||||
if (earliestMatch.start > 0) {
|
||||
result.push(
|
||||
<React.Fragment key={index++}>
|
||||
{remainingText.slice(0, earliestMatch.start)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
result.push(
|
||||
<React.Fragment key={index++}>
|
||||
{props.noHydrate ? earliestMatch.rule.flush(earliestMatch.match) : earliestMatch.rule.render(earliestMatch.match)}
|
||||
</React.Fragment>
|
||||
);
|
||||
remainingText = remainingText.slice(earliestMatch.end);
|
||||
} else {
|
||||
result.push(
|
||||
<React.Fragment key={index++}>
|
||||
{remainingText}
|
||||
</React.Fragment>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return <>{parseText(props.text)}</>;
|
||||
}
|
||||
3
app/components/TextVariator/TextVariator.module.css
Normal file
3
app/components/TextVariator/TextVariator.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.slide {
|
||||
animation: slide 1s ease-in-out infinite;
|
||||
}
|
||||
43
app/components/TextVariator/TextVariator.tsx
Normal file
43
app/components/TextVariator/TextVariator.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import classes from "./TextVariator.module.css";
|
||||
|
||||
interface TextVariatorProps {
|
||||
variants: string[];
|
||||
seconds?: number;
|
||||
}
|
||||
|
||||
export function TextVariator(props: TextVariatorProps) {
|
||||
const { variants } = props;
|
||||
const [currentVariant, setCurrentVariant] = useState(variants[0]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentVariant((prev) => {
|
||||
const currentIndex = variants.indexOf(prev);
|
||||
const nextIndex = (currentIndex + 1) % variants.length;
|
||||
return variants[nextIndex];
|
||||
});
|
||||
}, props.seconds ? props.seconds : 2 * 1000); // Change variant every 2 seconds if interval not passed
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [variants]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={currentVariant}
|
||||
initial={{ opacity: 0, y: -20, position: 'absolute' }}
|
||||
animate={{ opacity: 1, y: 0, position: 'relative' }}
|
||||
exit={{ opacity: 0, y: 20, position: 'absolute' }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className={classes.slide}
|
||||
>
|
||||
<Text component="span" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }} inherit>
|
||||
{currentVariant}
|
||||
</Text>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
3
app/components/Topbar/Topbar.module.css
Normal file
3
app/components/Topbar/Topbar.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.drag {
|
||||
app-region: drag;
|
||||
}
|
||||
43
app/components/Topbar/Topbar.tsx
Normal file
43
app/components/Topbar/Topbar.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Box, Flex, Loader, Text } from "@mantine/core";
|
||||
import classes from './Topbar.module.css'
|
||||
import { useProtocolState } from "@/app/providers/ProtocolProvider/useProtocolState";
|
||||
import { ProtocolState } from "@/app/providers/ProtocolProvider/ProtocolProvider";
|
||||
import { WindowsFrameButtons } from "../WindowsFrameButtons/WindowsFrameButtons";
|
||||
import { MacFrameButtons } from "../MacFrameButtons/MacFrameButtons";
|
||||
|
||||
export function Topbar() {
|
||||
const colors = useRosettaColors();
|
||||
const protocolState = useProtocolState();
|
||||
|
||||
|
||||
return (
|
||||
<Box className={classes.drag} ta={'center'} p={3} bg={colors.mainColor}>
|
||||
{window.platform == 'win32' && <WindowsFrameButtons></WindowsFrameButtons>}
|
||||
{window.platform == 'darwin' && <MacFrameButtons></MacFrameButtons>}
|
||||
{window.platform == 'linux' && <WindowsFrameButtons></WindowsFrameButtons>}
|
||||
{(protocolState == ProtocolState.CONNECTED || !window.location.hash.includes("main")) &&
|
||||
<Flex align={'center'} justify={'center'}>
|
||||
<Text fw={'bolder'} fz={13} c={'gray'}>
|
||||
Rosetta Messenger
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
{(protocolState != ProtocolState.CONNECTED && protocolState != ProtocolState.DEVICE_VERIFICATION_REQUIRED && window.location.hash.includes("main")) &&
|
||||
<Flex align={'center'} gap={5} justify={'center'}>
|
||||
<Loader size={12} color={colors.chevrons.active}></Loader>
|
||||
<Text fw={'bolder'} fz={13} c={'gray'}>
|
||||
Connecting...
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
{(protocolState == ProtocolState.DEVICE_VERIFICATION_REQUIRED && window.location.hash.includes("main")) &&
|
||||
<Flex align={'center'} gap={5} justify={'center'}>
|
||||
<Text fw={'bolder'} fz={13} c={'gray'}>
|
||||
Device verification required
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
65
app/components/UpdateAlert/UpdateAlert.tsx
Normal file
65
app/components/UpdateAlert/UpdateAlert.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Button, MantineRadius } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress";
|
||||
import { UpdateStatus, useUpdater } from "@/app/hooks/useUpdater";
|
||||
|
||||
interface UpdateAlertProps {
|
||||
radius?: MantineRadius;
|
||||
}
|
||||
|
||||
export function UpdateAlert(props : UpdateAlertProps) {
|
||||
const radius = props.radius || 0;
|
||||
const {
|
||||
appUpdateUrl,
|
||||
kernelUpdateUrl,
|
||||
downloadProgress,
|
||||
updateStatus,
|
||||
kernelOutdatedForNextAppUpdates,
|
||||
downloadLastApplicationUpdate,
|
||||
restartAppForUpdateApply,
|
||||
} = useUpdater();
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{updateStatus == UpdateStatus.IDLE && <>
|
||||
{kernelOutdatedForNextAppUpdates && <>
|
||||
<Button h={45} leftSection={
|
||||
<IconRefresh size={15}/>
|
||||
} onClick={() => {
|
||||
window.shell.openExternal(kernelUpdateUrl);
|
||||
}} fullWidth variant={'gradient'} gradient={{ from: 'red', to: 'orange', deg: 233 }} radius={radius}>
|
||||
Kernel update required
|
||||
</Button>
|
||||
</>}
|
||||
{!kernelOutdatedForNextAppUpdates && appUpdateUrl != "" && <>
|
||||
<Button h={45} onClick={downloadLastApplicationUpdate} leftSection={
|
||||
<IconRefresh size={15}/>
|
||||
} fullWidth variant={'gradient'} gradient={{ from: 'blue', to: 'green', deg: 233 }} radius={radius}>
|
||||
New version available
|
||||
</Button>
|
||||
</>}
|
||||
</>}
|
||||
{updateStatus == UpdateStatus.DOWNLOADING && <>
|
||||
<Button h={45} leftSection={
|
||||
<AnimatedRoundedProgress value={downloadProgress} />
|
||||
} fullWidth variant={'gradient'} gradient={{ from: 'blue', to: 'green', deg: 233 }} radius={radius}>
|
||||
Downloading... {downloadProgress}%
|
||||
</Button>
|
||||
</>}
|
||||
{updateStatus == UpdateStatus.COMPILE && <>
|
||||
<Button h={45} leftSection={
|
||||
<AnimatedRoundedProgress value={50} />
|
||||
} onClick={restartAppForUpdateApply} fullWidth variant={'gradient'} gradient={{ from: 'teal', to: 'lime', deg: 233 }} radius={radius}>
|
||||
Installing...
|
||||
</Button>
|
||||
</>}
|
||||
{updateStatus == UpdateStatus.READY_FOR_RESTART && <>
|
||||
<Button h={45} onClick={restartAppForUpdateApply} fullWidth variant={'gradient'} gradient={{ from: 'teal', to: 'lime', deg: 233 }} radius={radius}>
|
||||
Restart to apply update
|
||||
</Button>
|
||||
</>}
|
||||
</>
|
||||
);
|
||||
|
||||
}
|
||||
36
app/components/UserAccountSelect/UserAccountSelect.tsx
Normal file
36
app/components/UserAccountSelect/UserAccountSelect.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { AccountBase } from "@/app/providers/AccountProvider/AccountProvider";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { useUserCache } from "@/app/providers/InformationProvider/useUserCache";
|
||||
import { Avatar, Flex, Text } from "@mantine/core";
|
||||
|
||||
interface UserAccountSelectProps {
|
||||
accountBase: AccountBase;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function UserAccountSelect(props : UserAccountSelectProps) {
|
||||
const userInfo = useUserCache(props.accountBase.publicKey);
|
||||
const avatars = useAvatars(props.accountBase.publicKey);
|
||||
|
||||
return (
|
||||
<Flex w={'100%'} onClick={props.onClick} style={{
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer'
|
||||
}} pl={'xs'} pr={'xs'} pt={10} pb={10} direction={'row'} justify={'space-between'} align={'center'}>
|
||||
{userInfo && (
|
||||
<Flex direction={'row'} gap={'xs'} align={'center'}>
|
||||
<Avatar src={avatars.length > 0 ? avatars[0].avatar : undefined} size={20} color={'initials'} name={userInfo.title}></Avatar>
|
||||
<Flex direction={'column'}>
|
||||
<Text fw={500} style={{
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
width: 100,
|
||||
}} size="xs">{userInfo.title}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
9
app/components/UserButton/UserButton.module.css
Normal file
9
app/components/UserButton/UserButton.module.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.user {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
}
|
||||
}
|
||||
57
app/components/UserButton/UserButton.tsx
Normal file
57
app/components/UserButton/UserButton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { Avatar, Group, Skeleton, Text, UnstyledButton } from '@mantine/core';
|
||||
import classes from './UserButton.module.css';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUserInformation } from '@/app/providers/InformationProvider/useUserInformation';
|
||||
import { usePublicKey } from '@/app/providers/AccountProvider/usePublicKey';
|
||||
import { useAvatars } from '@/app/providers/AvatarProvider/useAvatars';
|
||||
|
||||
export function UserButton() {
|
||||
const navigate = useNavigate();
|
||||
const publicKey = usePublicKey();
|
||||
const [userInfo] = useUserInformation(publicKey);
|
||||
const avatars = useAvatars(publicKey);
|
||||
|
||||
const loading = userInfo.publicKey !== publicKey;
|
||||
|
||||
return (
|
||||
<UnstyledButton p={'sm'} className={classes.user} onClick={() => navigate("/main/profile/me")}>
|
||||
<Group>
|
||||
{!loading && (
|
||||
<>
|
||||
<Avatar
|
||||
radius="xl"
|
||||
name={userInfo.title}
|
||||
color={'initials'}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : undefined}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{userInfo.title}
|
||||
</Text>
|
||||
|
||||
{userInfo.username && (
|
||||
<Text c={'dimmed'} size="xs">
|
||||
@{userInfo.username}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IconChevronRight size={14} stroke={1.5} />
|
||||
</>
|
||||
)}
|
||||
{loading && (
|
||||
<>
|
||||
<Skeleton height={40} width={40} radius="xl" mr="sm" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton height={10} width="80%" mb={6} />
|
||||
<Skeleton height={8} width="40%" />
|
||||
</div>
|
||||
<Skeleton height={14} width={14} />
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
36
app/components/UserMention/UserMention.module.css
Normal file
36
app/components/UserMention/UserMention.module.css
Normal file
@@ -0,0 +1,36 @@
|
||||
@keyframes failVibrate {
|
||||
0%, 100% {
|
||||
left: 0px;
|
||||
}
|
||||
20%, 60% {
|
||||
left: -2px;
|
||||
}
|
||||
40%, 80% {
|
||||
left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0% {
|
||||
color: var(--mantine-primary-color-0);
|
||||
}
|
||||
50% {
|
||||
color: var(--mantine-primary-color-2);
|
||||
}
|
||||
100% {
|
||||
color: var(--mantine-primary-color-0);
|
||||
}
|
||||
}
|
||||
|
||||
.mention {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: skeletonPulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.fail_vibrate {
|
||||
position: relative;
|
||||
animation: failVibrate 0.3s linear;
|
||||
}
|
||||
86
app/components/UserMention/UserMention.tsx
Normal file
86
app/components/UserMention/UserMention.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { usePrivateKeyHash } from "@/app/providers/AccountProvider/usePrivateKeyHash";
|
||||
import { PacketSearch, PacketSearchUser } from "@/app/providers/ProtocolProvider/protocol/packets/packet.search";
|
||||
import { usePacket } from "@/app/providers/ProtocolProvider/usePacket";
|
||||
import { useSender } from "@/app/providers/ProtocolProvider/useSender";
|
||||
import { Anchor } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import classes from './UserMention.module.css';
|
||||
import { cx } from "@/app/utils/style";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export interface UserMentionProps {
|
||||
username: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function UserMention(props : UserMentionProps) {
|
||||
const send = useSender();
|
||||
const privateKey = usePrivateKeyHash();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fail, setFail] = useState(false);
|
||||
const [vibrate, setVibrate] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
usePacket(0x03, (packet: PacketSearch) => {
|
||||
if(!loading){
|
||||
return;
|
||||
}
|
||||
if(fail){
|
||||
vibrateCall();
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
let users = packet.getUsers();
|
||||
if(users.length <= 0){
|
||||
vibrateCall();
|
||||
setFail(true);
|
||||
return;
|
||||
}
|
||||
const user = findMatchuser(users);
|
||||
if(!user){
|
||||
vibrateCall();
|
||||
setFail(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/main/chat/${user.publicKey}`);
|
||||
}, [props.username, loading, fail]);
|
||||
|
||||
const findMatchuser = (users: PacketSearchUser[]) => {
|
||||
for(let user of users){
|
||||
if(user.username === props.username.replace('@', '')){
|
||||
return user;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if(fail){
|
||||
vibrateCall();
|
||||
return;
|
||||
}
|
||||
let packet = new PacketSearch();
|
||||
packet.setSearch(props.username.replace('@', ''));
|
||||
packet.setPrivateKey(privateKey);
|
||||
send(packet);
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const vibrateCall = () => {
|
||||
if(vibrate){
|
||||
return;
|
||||
}
|
||||
setVibrate(true);
|
||||
setTimeout(() => {
|
||||
setVibrate(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor className={cx(
|
||||
classes.mention,
|
||||
loading && classes.skeleton,
|
||||
vibrate && classes.fail_vibrate
|
||||
)} onClick={onClick} c={props.color} size="sm">{props.username}</Anchor>
|
||||
);
|
||||
}
|
||||
70
app/components/UserRow/UserRow.tsx
Normal file
70
app/components/UserRow/UserRow.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
||||
import { Avatar, Flex, MantineColor, Text } from "@mantine/core";
|
||||
import { VerifiedBadge } from "../VerifiedBadge/VerifiedBadge";
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { UserInformation } from "@/app/providers/InformationProvider/InformationProvider";
|
||||
|
||||
export enum AdditionalType {
|
||||
ONLINE,
|
||||
USERNAME
|
||||
}
|
||||
|
||||
export interface UserRowProps {
|
||||
publicKey: string;
|
||||
rightSection?: (publicKey: string) => React.ReactNode;
|
||||
onClick?: (userInfo: UserInformation) => void;
|
||||
renderCondition?: (userInfo: UserInformation) => boolean;
|
||||
additionalType?: AdditionalType;
|
||||
bg?: MantineColor;
|
||||
}
|
||||
|
||||
export function UserRow(props: UserRowProps) {
|
||||
const [userInfo] = useUserInformation(props.publicKey);
|
||||
const avatars = useAvatars(props.publicKey, false);
|
||||
const colors = useRosettaColors();
|
||||
|
||||
if(props.renderCondition && !props.renderCondition(userInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify={'space-between'} bg={props.bg} align={'center'} p={'xs'}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--rosetta-border-color)',
|
||||
cursor: props.onClick ? 'pointer' : 'default'
|
||||
}}>
|
||||
<Flex align={'center'} direction={'row'} gap={10}>
|
||||
<Avatar
|
||||
radius="xl"
|
||||
name={userInfo.title}
|
||||
color={'initials'}
|
||||
src={avatars.length > 0 ? avatars[0].avatar : undefined}
|
||||
/>
|
||||
<Flex direction={'column'}>
|
||||
<Flex justify={'row'} align={'center'} gap={3}>
|
||||
<Text size="sm" fw={500}>{userInfo.title}</Text>
|
||||
<VerifiedBadge size={17} verified={userInfo.verified}></VerifiedBadge>
|
||||
</Flex>
|
||||
{!props.additionalType && (
|
||||
<Text size={'xs'} c={userInfo.online == OnlineState.ONLINE ? colors.success : 'dimmed'}>{userInfo.online == OnlineState.ONLINE ? 'online' : 'offline'}</Text>
|
||||
)}
|
||||
{props.additionalType === AdditionalType.ONLINE && (
|
||||
<Text size={'xs'} c={userInfo.online == OnlineState.ONLINE ? colors.success : 'dimmed'}>{userInfo.online == OnlineState.ONLINE ? 'online' : 'offline'}</Text>
|
||||
)}
|
||||
{props.additionalType === AdditionalType.USERNAME && (
|
||||
<Text size={'xs'} c={'dimmed'}>@{userInfo.username}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
{props.rightSection && (
|
||||
<Flex align={'center'} style={{
|
||||
cursor: 'pointer'
|
||||
}} gap={'xs'} justify={'center'}>
|
||||
{props.rightSection(props.publicKey)}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
22
app/components/UsersTable/UsersTable.tsx
Normal file
22
app/components/UsersTable/UsersTable.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Flex, MantineSize } from "@mantine/core";
|
||||
import { UserRow } from "../UserRow/UserRow";
|
||||
import { SettingsPaper } from "../SettingsPaper/SettingsPaper";
|
||||
|
||||
interface GroupMembersProps {
|
||||
usersPublicKeys: string[];
|
||||
mt?: MantineSize;
|
||||
rightSection?: (publicKey: string) => React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function UsersTable(props: GroupMembersProps) {
|
||||
return (
|
||||
<SettingsPaper mt={props.mt} style={props.style}>
|
||||
<Flex direction="column" gap={0} style={{ width: '100%' }}>
|
||||
{props.usersPublicKeys.map((pk) => (
|
||||
<UserRow rightSection={props.rightSection} key={pk} publicKey={pk} />
|
||||
))}
|
||||
</Flex>
|
||||
</SettingsPaper>
|
||||
);
|
||||
}
|
||||
56
app/components/VerifiedBadge/VerifiedBadge.tsx
Normal file
56
app/components/VerifiedBadge/VerifiedBadge.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { Popover, Text } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconArrowBadgeDownFilled, IconRosetteDiscountCheckFilled, IconShieldCheckFilled } from "@tabler/icons-react";
|
||||
|
||||
interface VerifiedBadgeProps {
|
||||
verified: number;
|
||||
size?: number;
|
||||
hideTooltip?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function VerifiedBadge(props : VerifiedBadgeProps) {
|
||||
const colors = useRosettaColors();
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
return (
|
||||
<>
|
||||
{props.verified == 1 && <>
|
||||
<Popover width={200} opened={!props.hideTooltip && opened} position="bottom" withArrow arrowOffset={18}>
|
||||
<Popover.Target>
|
||||
<IconRosetteDiscountCheckFilled onMouseEnter={open} onMouseLeave={close} size={props.size || 21} color={props.color ? props.color : colors.brandColor}></IconRosetteDiscountCheckFilled>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text size="xs" c={'dimmed'}>
|
||||
This is an official account belonging to a public figure, brand, or organization.
|
||||
</Text>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</>}
|
||||
{props.verified == 2 && <>
|
||||
<Popover width={200} opened={!props.hideTooltip && opened} position="bottom" withArrow arrowOffset={18}>
|
||||
<Popover.Target>
|
||||
<IconShieldCheckFilled onMouseEnter={open} onMouseLeave={close} size={props.size || 21} color={props.color ? props.color : colors.success}></IconShieldCheckFilled>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text size="xs" c={'dimmed'}>
|
||||
This is official account belonging to administration of Rosetta.
|
||||
</Text>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</>}
|
||||
{props.verified == 3 && <>
|
||||
<Popover width={200} opened={!props.hideTooltip && opened} withArrow>
|
||||
<Popover.Target>
|
||||
<IconArrowBadgeDownFilled onMouseEnter={open} onMouseLeave={close} size={props.size || 21} color={props.color ? props.color : colors.brandColor}></IconArrowBadgeDownFilled>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text size="xs" c={'dimmed'}>
|
||||
This user is administrator of this group.
|
||||
</Text>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.close_btn:hover{
|
||||
color: var(--mantine-color-red-5);
|
||||
}
|
||||
|
||||
.maximize_btn:hover {
|
||||
color: var(--mantine-color-green-5);
|
||||
}
|
||||
|
||||
.minimize_btn:hover {
|
||||
color: var(--mantine-color-orange-5);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: var(--mantine-color-gray-5);
|
||||
pointer-events: none;
|
||||
}
|
||||
45
app/components/WindowsFrameButtons/WindowsFrameButtons.tsx
Normal file
45
app/components/WindowsFrameButtons/WindowsFrameButtons.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Flex } from "@mantine/core";
|
||||
import { IconMinus, IconRectangle, IconX } from "@tabler/icons-react";
|
||||
import classes from './WindowsFrameButtons.module.css'
|
||||
import { useWindowActions } from "@/app/hooks/useWindowActions";
|
||||
import { cx } from "@/app/utils/style";
|
||||
import { useWindowState } from "@/app/hooks/useWindowState";
|
||||
import { useWindowFocus } from "@/app/hooks/useWindowFocus";
|
||||
|
||||
export function WindowsFrameButtons() {
|
||||
const {close, minimize, toggle} = useWindowActions();
|
||||
const windowState = useWindowState();
|
||||
const focus = useWindowFocus();
|
||||
|
||||
return (<>
|
||||
<Flex gap={'sm'} style={{
|
||||
cursor: 'pointer',
|
||||
appRegion: 'no-drag'
|
||||
}} pos={'absolute'} right={4} h={20} align="center" justify="center">
|
||||
<Flex w={20} onClick={minimize} className={cx(
|
||||
classes.minimize_btn,
|
||||
!focus && classes.disabled,
|
||||
//windowState.isMinimized && classes.disabled,
|
||||
!windowState.isResizable && classes.disabled
|
||||
)}>
|
||||
<IconMinus stroke={4} size={12}></IconMinus>
|
||||
</Flex>
|
||||
<Flex w={20} onClick={toggle} className={cx(
|
||||
classes.maximize_btn,
|
||||
!focus && classes.disabled,
|
||||
//windowState.isMaximized && classes.disabled,
|
||||
(!windowState.isResizable && !windowState.isFullScreen) && classes.disabled
|
||||
)}>
|
||||
<IconRectangle stroke={3} size={12}></IconRectangle>
|
||||
</Flex>
|
||||
<Flex w={20} onClick={close} className={cx(
|
||||
classes.close_btn,
|
||||
!focus && classes.disabled,
|
||||
!windowState.isClosable && classes.disabled
|
||||
)}>
|
||||
<IconX stroke={3} size={12}></IconX>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>);
|
||||
|
||||
}
|
||||
65
app/constants.ts
Normal file
65
app/constants.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AttachmentType } from "./providers/ProtocolProvider/protocol/packets/packet.message";
|
||||
|
||||
export const CORE_VERSION = window.version || "1.0.0";
|
||||
|
||||
/**
|
||||
* Application directives
|
||||
*/
|
||||
export const APPLICATION_PLATFROM = window.platform || "unknown";
|
||||
export const APPLICATION_ARCH = window.arch || "unknown";
|
||||
export const APP_PATH = window.appPath || ".";
|
||||
export const SIZE_LOGIN_WIDTH_PX = 300;
|
||||
export const DEVTOOLS_CHEATCODE = "rosettadev1";
|
||||
export const AVATAR_PASSWORD_TO_ENCODE = "rosetta-a";
|
||||
/**
|
||||
* Connection
|
||||
*/
|
||||
export const RECONNECTING_INTERVAL = 5;
|
||||
export const RECONNECTING_TRYINGS_BEFORE_ALERT = 5;
|
||||
|
||||
/**
|
||||
* Messages
|
||||
*/
|
||||
export const MAX_MESSAGES_LOAD = 20;
|
||||
export const MESSAGE_MAX_TIME_TO_DELEVERED_S = 80; // in seconds
|
||||
export const MESSAGE_MAX_LOADED = 40;
|
||||
export const SCROLL_TOP_IN_MESSAGES_TO_VIEW_AFFIX = 200;
|
||||
export const TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD = 20;
|
||||
export const MAX_ATTACHMENTS_IN_MESSAGE = 5;
|
||||
export const MAX_UPLOAD_FILESIZE_MB = 1024;
|
||||
export const ENTITY_LIMITS_TO_PARSE_IN_MESSAGE = 50;
|
||||
export const ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [
|
||||
AttachmentType.AVATAR,
|
||||
AttachmentType.MESSAGES
|
||||
];
|
||||
export const DIALOG_DROP_TO_REQUESTS_IF_NO_MESSAGES_FROM_ME_COUNT = 30;
|
||||
/**
|
||||
* Если предыдущие сообщение было отправлено менее чем 300 секунд назад,
|
||||
* то не отображаем аватар отправителя
|
||||
*/
|
||||
export const MESSAGE_AVATAR_NO_RENDER_TIME_DIFF_S = 300; // 5 minutes
|
||||
/**
|
||||
* Разрешенные доменные зоны
|
||||
*/
|
||||
export const ALLOWED_DOMAINS_ZONES = [
|
||||
'com',
|
||||
'ru',
|
||||
'ua',
|
||||
'org',
|
||||
'net',
|
||||
'edu',
|
||||
'gov',
|
||||
'io',
|
||||
'tech',
|
||||
'info',
|
||||
'biz',
|
||||
'me',
|
||||
'online',
|
||||
'site',
|
||||
'app',
|
||||
'dev',
|
||||
'chat',
|
||||
'gg',
|
||||
'fm',
|
||||
'tv'
|
||||
];
|
||||
115
app/crypto/crypto.ts
Normal file
115
app/crypto/crypto.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { sha256, md5 } from "node-forge";
|
||||
import { generateRandomKey } from "../utils/utils";
|
||||
import * as secp256k1 from '@noble/secp256k1';
|
||||
|
||||
const worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
export const encodeWithPassword = async (password : string, data : any) : Promise<any> => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, _) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'encodeWithPasswordResult' && event.data.task === task) {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'encodeWithPassword', data: { password, payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const decodeWithPassword = (password : string, data : any) : Promise<any> => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'decodeWithPasswordResult' && event.data.task === task) {
|
||||
if(event.data.result === null){
|
||||
reject("Decryption failed");
|
||||
return;
|
||||
}
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'decodeWithPassword', data: { password, payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const generateKeyPairFromSeed = async (seed : string) => {
|
||||
//generate key pair using secp256k1 includes privatekey from seed
|
||||
const privateKey = sha256.create().update(seed).digest().toHex().toString();
|
||||
const publicKey = secp256k1.getPublicKey(Buffer.from(privateKey, "hex"), true);
|
||||
return {
|
||||
privateKey: privateKey,
|
||||
publicKey: Buffer.from(publicKey).toString('hex'),
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const encrypt = async (data : string, publicKey : string) : Promise<any> => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, _) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'encryptResult' && event.data.task === task) {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'encrypt', data: { publicKey, payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const decrypt = async (data : string, privateKey : string) : Promise<any> => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'decryptResult' && event.data.task === task) {
|
||||
if(event.data.result === null){
|
||||
reject("Decryption failed");
|
||||
return;
|
||||
}
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'decrypt', data: { privateKey, payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const chacha20Encrypt = async (data : string) => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, _) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'chacha20EncryptResult' && event.data.task === task) {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'chacha20Encrypt', data: { payload: data, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const chacha20Decrypt = async (ciphertext : string, nonce : string, key : string) => {
|
||||
let task = generateRandomKey(16);
|
||||
return new Promise((resolve, _) => {
|
||||
worker.addEventListener('message', (event: MessageEvent) => {
|
||||
if (event.data.action === 'chacha20DecryptResult' && event.data.task === task) {
|
||||
resolve(event.data.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage({ action: 'chacha20Decrypt', data: { ciphertext, nonce, key, task } });
|
||||
});
|
||||
}
|
||||
|
||||
export const generateMd5 = async (data : string) => {
|
||||
const hash = md5.create();
|
||||
hash.update(data);
|
||||
return hash.digest().toHex();
|
||||
}
|
||||
|
||||
export const generateHashFromPrivateKey = async (privateKey : string) => {
|
||||
return sha256.create().update(privateKey + "rosetta").digest().toHex().toString();
|
||||
}
|
||||
|
||||
export const isEncodedWithPassword = (data : string) => {
|
||||
try{
|
||||
atob(data).split(":");
|
||||
return true;
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user