This commit is contained in:
rosetta
2026-01-30 05:01:05 +02:00
commit 83f38dc63f
327 changed files with 18725 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
out
package-lock.json
LICENSE
*.code-workspace

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

83
app/App.tsx Normal file
View 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

Binary file not shown.

View 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>
);
}

View 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
}
/>
);
}
);

View File

@@ -0,0 +1,12 @@
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animatedRoundedProgress {
animation: spin 2s linear infinite;
}

View File

@@ -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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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;
}

View 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>
</>
)
}

View 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>
</>)
}

View 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>
)
}

View 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>
);
}

View 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;
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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>
}
</>
)
}

View 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>
)}
</>
)
}

View 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>
))}
</>
);
}

File diff suppressed because one or more lines are too long

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
)
}

View 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>
)
}

View 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>
</>)
}

View 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>
)
}

View 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>
)}
</>
);
}

View 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);
}

View 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>
);
}

View 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);
}

View 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>
);
}

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
</>
);
}

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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

Binary file not shown.

View 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>
</>);
}

View 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>
)
}

View File

@@ -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>
)
}

View 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="/" />
)}
</>
);
}

View 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);
}
}

View 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>
);
}

View 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;
}
}

View 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>
</>
);
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
.btn {
cursor: pointer;
}

View 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>
</>}
</>
)
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.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>
)
});

View 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>
);
}

View 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));
}
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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>);
}

View 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>);
}

View 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;

View 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>)
}

View 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);
}

View 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>
)
}

View 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)}</>;
}

View File

@@ -0,0 +1,3 @@
.slide {
animation: slide 1s ease-in-out infinite;
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
.drag {
app-region: drag;
}

View 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>
)
}

View 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>
</>}
</>
);
}

View 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>
);
}

View 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));
}
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>}
</>
)
}

View File

@@ -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;
}

View 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
View 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
View 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