'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