mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 19:57:46 +01:00
Improve ui for mobile
This commit is contained in:
@@ -2,10 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<title>Colanode</title>
|
<title>Colanode</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
|
import { useIsMobile } from '@colanode/ui/hooks/use-is-mobile';
|
||||||
|
import { cn } from '@colanode/ui/lib/utils';
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarHeader = ({ title, actions }: SidebarHeaderProps) => {
|
export const SidebarHeader = ({ title, actions }: SidebarHeaderProps) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between h-12 pl-2 pr-1 app-drag-region">
|
<div className="flex items-center justify-between h-12 pl-2 pr-1 app-drag-region">
|
||||||
<p className="font-bold text-muted-foreground flex-grow app-no-drag-region">
|
<p className="font-bold text-muted-foreground flex-grow app-no-drag-region">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
{actions && (
|
{actions && (
|
||||||
<div className="text-muted-foreground opacity-0 transition-opacity group-hover/sidebar:opacity-100 flex items-center justify-center app-no-drag-region">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground flex items-center justify-center app-no-drag-region',
|
||||||
|
!isMobile &&
|
||||||
|
'opacity-0 group-hover/sidebar:opacity-100 transition-opacity'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export const SidebarMobile = () => {
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="left"
|
side="left"
|
||||||
className="w-full p-0 border-0"
|
className="w-[90%] max-w-sm p-0 border-0"
|
||||||
|
showCloseButton={false}
|
||||||
aria-describedby="mobile-sidebar-description"
|
aria-describedby="mobile-sidebar-description"
|
||||||
>
|
>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const Conversation = ({
|
|||||||
>
|
>
|
||||||
<div ref={bottomRef} className="h-4"></div>
|
<div ref={bottomRef} className="h-4"></div>
|
||||||
</InView>
|
</InView>
|
||||||
<div className="sticky bottom-0 bg-background">
|
<div className="sticky bottom-0 bg-background pb-4 pt-2">
|
||||||
<MessageCreate ref={messageCreateRef} />
|
<MessageCreate ref={messageCreateRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { MessagesSquare, Reply } from 'lucide-react';
|
import { MessagesSquare, Reply, Trash2 } from 'lucide-react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { LocalMessageNode } from '@colanode/client/types';
|
|
||||||
import { MessageDeleteButton } from '@colanode/ui/components/messages/message-delete-button';
|
|
||||||
import { MessageQuickReaction } from '@colanode/ui/components/messages/message-quick-reaction';
|
import { MessageQuickReaction } from '@colanode/ui/components/messages/message-quick-reaction';
|
||||||
import { MessageReactionCreatePopover } from '@colanode/ui/components/messages/message-reaction-create-popover';
|
import { MessageReactionCreatePopover } from '@colanode/ui/components/messages/message-reaction-create-popover';
|
||||||
import { useConversation } from '@colanode/ui/contexts/conversation';
|
import { useConversation } from '@colanode/ui/contexts/conversation';
|
||||||
|
import { useMessage } from '@colanode/ui/contexts/message';
|
||||||
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
||||||
import { defaultEmojis } from '@colanode/ui/lib/assets';
|
import { defaultEmojis } from '@colanode/ui/lib/assets';
|
||||||
@@ -19,18 +18,12 @@ const MessageAction = ({ children }: { children: React.ReactNode }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MessageActionsProps {
|
export const MessageActions = () => {
|
||||||
message: LocalMessageNode;
|
const message = useMessage();
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageActions = ({ message }: MessageActionsProps) => {
|
|
||||||
const workspace = useWorkspace();
|
const workspace = useWorkspace();
|
||||||
const conversation = useConversation();
|
const conversation = useConversation();
|
||||||
const { mutate, isPending } = useMutation();
|
const { mutate, isPending } = useMutation();
|
||||||
|
|
||||||
const canDelete = conversation.canDeleteMessage(message);
|
|
||||||
const canReplyInThread = false;
|
|
||||||
|
|
||||||
const handleReactionClick = useCallback(
|
const handleReactionClick = useCallback(
|
||||||
(reaction: string) => {
|
(reaction: string) => {
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
@@ -75,7 +68,7 @@ export const MessageActions = ({ message }: MessageActionsProps) => {
|
|||||||
/>
|
/>
|
||||||
</MessageAction>
|
</MessageAction>
|
||||||
<div className="mx-1 h-6 w-[1px] bg-border" />
|
<div className="mx-1 h-6 w-[1px] bg-border" />
|
||||||
{canReplyInThread && (
|
{message.canReplyInThread && (
|
||||||
<MessageAction>
|
<MessageAction>
|
||||||
<MessagesSquare className="size-4 cursor-pointer" />
|
<MessagesSquare className="size-4 cursor-pointer" />
|
||||||
</MessageAction>
|
</MessageAction>
|
||||||
@@ -113,9 +106,14 @@ export const MessageActions = ({ message }: MessageActionsProps) => {
|
|||||||
/>
|
/>
|
||||||
</MessageAction>
|
</MessageAction>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{message.canDelete && (
|
||||||
<MessageAction>
|
<MessageAction>
|
||||||
<MessageDeleteButton id={message.id} />
|
<Trash2
|
||||||
|
className="size-4 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
message.openDelete();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</MessageAction>
|
</MessageAction>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -116,81 +116,78 @@ export const MessageCreate = forwardRef<MessageCreateRefProps>((_, ref) => {
|
|||||||
}, [messageEditorRef]);
|
}, [messageEditorRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-1">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
{conversation.canCreateMessage && replyTo && (
|
||||||
{conversation.canCreateMessage && replyTo && (
|
<MessageReplyBanner
|
||||||
<MessageReplyBanner
|
message={replyTo}
|
||||||
message={replyTo}
|
onCancel={() => setReplyTo(null)}
|
||||||
onCancel={() => setReplyTo(null)}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
<div className="flex min-h-0 flex-row items-center rounded bg-muted p-2 pl-0">
|
||||||
<div className="flex min-h-0 flex-row items-center rounded bg-muted p-2 pl-0">
|
<div className="flex w-10 items-center justify-center">
|
||||||
<div className="flex w-10 items-center justify-center">
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger
|
||||||
<DropdownMenuTrigger
|
disabled={isPending || !conversation.canCreateMessage}
|
||||||
disabled={isPending || !conversation.canCreateMessage}
|
className="cursor-pointer hover:bg-accent"
|
||||||
className="cursor-pointer hover:bg-accent"
|
>
|
||||||
>
|
<span>
|
||||||
<span>
|
<Plus size={20} />
|
||||||
<Plus size={20} />
|
</span>
|
||||||
</span>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuItem disabled={true}>
|
||||||
<DropdownMenuItem disabled={true}>
|
<div className="flex flex-row items-center gap-2 text-sm">
|
||||||
<div className="flex flex-row items-center gap-2 text-sm">
|
<Search className="size-4" />
|
||||||
<Search className="size-4" />
|
<span>Browse</span>
|
||||||
<span>Browse</span>
|
</div>
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem onClick={handleUploadClick}>
|
||||||
<DropdownMenuItem onClick={handleUploadClick}>
|
<div className="flex cursor-pointer flex-row items-center gap-2 text-sm">
|
||||||
<div className="flex cursor-pointer flex-row items-center gap-2 text-sm">
|
<Upload className="size-4" />
|
||||||
<Upload className="size-4" />
|
<span>Upload</span>
|
||||||
<span>Upload</span>
|
</div>
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuContent>
|
</DropdownMenu>
|
||||||
</DropdownMenu>
|
</div>
|
||||||
</div>
|
<div className="max-h-72 flex-grow overflow-y-auto">
|
||||||
<div className="max-h-72 flex-grow overflow-y-auto">
|
{conversation.canCreateMessage ? (
|
||||||
{conversation.canCreateMessage ? (
|
<MessageEditor
|
||||||
<MessageEditor
|
key={conversation.id}
|
||||||
key={conversation.id}
|
ref={messageEditorRef}
|
||||||
ref={messageEditorRef}
|
userId={workspace.userId}
|
||||||
userId={workspace.userId}
|
accountId={workspace.accountId}
|
||||||
accountId={workspace.accountId}
|
workspaceId={workspace.id}
|
||||||
workspaceId={workspace.id}
|
conversationId={conversation.id}
|
||||||
conversationId={conversation.id}
|
rootId={conversation.rootId}
|
||||||
rootId={conversation.rootId}
|
onChange={setContent}
|
||||||
onChange={setContent}
|
onSubmit={handleSubmit}
|
||||||
onSubmit={handleSubmit}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<p className="m-0 px-0 py-1 text-muted-foreground">
|
||||||
<p className="m-0 px-0 py-1 text-muted-foreground">
|
You don't have permission to create messages in this
|
||||||
You don't have permission to create messages in this
|
conversation
|
||||||
conversation
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="flex flex-row gap-2">
|
||||||
<div className="flex flex-row gap-2">
|
{isPending ? (
|
||||||
{isPending ? (
|
<Spinner size={20} />
|
||||||
<Spinner size={20} />
|
) : (
|
||||||
) : (
|
<button
|
||||||
<button
|
type="submit"
|
||||||
type="submit"
|
className={`${
|
||||||
className={`${
|
conversation.canCreateMessage && hasContent
|
||||||
conversation.canCreateMessage && hasContent
|
? 'cursor-pointer text-blue-600'
|
||||||
? 'cursor-pointer text-blue-600'
|
: 'cursor-default text-muted-foreground'
|
||||||
: 'cursor-default text-muted-foreground'
|
}`}
|
||||||
}`}
|
onClick={handleSubmit}
|
||||||
onClick={handleSubmit}
|
>
|
||||||
>
|
<Send size={20} />
|
||||||
<Send size={20} />
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-3 min-h-3 items-center text-xs text-muted-foreground"></div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { Trash2 } from 'lucide-react';
|
|
||||||
import { Fragment, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@colanode/ui/components/ui/alert-dialog';
|
|
||||||
import { Button } from '@colanode/ui/components/ui/button';
|
|
||||||
import { Spinner } from '@colanode/ui/components/ui/spinner';
|
|
||||||
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
|
||||||
import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
|
||||||
|
|
||||||
interface MessageDeleteButtonProps {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageDeleteButton = ({ id }: MessageDeleteButtonProps) => {
|
|
||||||
const workspace = useWorkspace();
|
|
||||||
const { mutate, isPending } = useMutation();
|
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<Trash2
|
|
||||||
className="size-4 cursor-pointer"
|
|
||||||
onClick={() => setShowDeleteModal(true)}
|
|
||||||
/>
|
|
||||||
<AlertDialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Are you sure you want delete this message?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. This message will no longer be
|
|
||||||
accessible by you or others you've shared it with.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => {
|
|
||||||
mutate({
|
|
||||||
input: {
|
|
||||||
type: 'message.delete',
|
|
||||||
messageId: id,
|
|
||||||
accountId: workspace.accountId,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
setShowDeleteModal(false);
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
toast.error(error.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPending && <Spinner className="mr-1" />}
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@colanode/ui/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@colanode/ui/components/ui/button';
|
||||||
|
import { Spinner } from '@colanode/ui/components/ui/spinner';
|
||||||
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
||||||
|
|
||||||
|
interface MessageDeleteDialogProps {
|
||||||
|
id: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageDeleteDialog = ({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: MessageDeleteDialogProps) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
const { mutate, isPending } = useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Are you sure you want delete this message?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This message will no longer be
|
||||||
|
accessible by you or others you've shared it with.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => {
|
||||||
|
mutate({
|
||||||
|
input: {
|
||||||
|
type: 'message.delete',
|
||||||
|
messageId: id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPending && <Spinner className="mr-1" />}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
178
packages/ui/src/components/messages/message-menu-mobile.tsx
Normal file
178
packages/ui/src/components/messages/message-menu-mobile.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||||
|
import { MessagesSquare, Reply, Trash2 } from 'lucide-react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { LocalMessageNode } from '@colanode/client/types';
|
||||||
|
import { MessageQuickReaction } from '@colanode/ui/components/messages/message-quick-reaction';
|
||||||
|
import { MessageReactionCreatePopover } from '@colanode/ui/components/messages/message-reaction-create-popover';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@colanode/ui/components/ui/sheet';
|
||||||
|
import { useConversation } from '@colanode/ui/contexts/conversation';
|
||||||
|
import { useMessage } from '@colanode/ui/contexts/message';
|
||||||
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
||||||
|
import { defaultEmojis } from '@colanode/ui/lib/assets';
|
||||||
|
import { cn } from '@colanode/ui/lib/utils';
|
||||||
|
|
||||||
|
interface MessageMenuMobileProps {
|
||||||
|
message: LocalMessageNode;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuAction = ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 w-full p-4 text-left hover:bg-accent transition-colors',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageMenuMobile = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: MessageMenuMobileProps) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
const conversation = useConversation();
|
||||||
|
const message = useMessage();
|
||||||
|
|
||||||
|
const { mutate, isPending } = useMutation();
|
||||||
|
|
||||||
|
const canReplyInThread = false;
|
||||||
|
|
||||||
|
const handleReactionClick = useCallback(
|
||||||
|
(reaction: string) => {
|
||||||
|
if (isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate({
|
||||||
|
input: {
|
||||||
|
type: 'node.reaction.create',
|
||||||
|
nodeId: message.id,
|
||||||
|
accountId: workspace.accountId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
reaction,
|
||||||
|
rootId: conversation.rootId,
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isPending,
|
||||||
|
mutate,
|
||||||
|
workspace.accountId,
|
||||||
|
message.id,
|
||||||
|
conversation.rootId,
|
||||||
|
onOpenChange,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReply = () => {
|
||||||
|
conversation.onReply(message);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<VisuallyHidden>
|
||||||
|
<SheetTitle>Message Actions</SheetTitle>
|
||||||
|
<SheetDescription>Actions for the selected message</SheetDescription>
|
||||||
|
</VisuallyHidden>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
className="rounded-t-3xl border-0 p-0"
|
||||||
|
aria-describedby="mobile-message-menu-description"
|
||||||
|
>
|
||||||
|
<div className="p-6 space-y-2">
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-3">
|
||||||
|
Quick Reactions
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl border hover:bg-accent transition-colors">
|
||||||
|
<MessageQuickReaction
|
||||||
|
emoji={defaultEmojis.like}
|
||||||
|
onClick={handleReactionClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl border hover:bg-accent transition-colors">
|
||||||
|
<MessageQuickReaction
|
||||||
|
emoji={defaultEmojis.heart}
|
||||||
|
onClick={handleReactionClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl border hover:bg-accent transition-colors">
|
||||||
|
<MessageQuickReaction
|
||||||
|
emoji={defaultEmojis.check}
|
||||||
|
onClick={handleReactionClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl border hover:bg-accent transition-colors">
|
||||||
|
<MessageReactionCreatePopover
|
||||||
|
onReactionClick={(reaction) => {
|
||||||
|
if (!isPending) {
|
||||||
|
handleReactionClick(reaction);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Section */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{canReplyInThread && (
|
||||||
|
<MenuAction onClick={handleReply}>
|
||||||
|
<MessagesSquare className="size-5 text-muted-foreground" />
|
||||||
|
<span>Reply in thread</span>
|
||||||
|
</MenuAction>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{conversation.canCreateMessage && (
|
||||||
|
<MenuAction onClick={handleReply}>
|
||||||
|
<Reply className="size-5 text-muted-foreground" />
|
||||||
|
<span>Reply</span>
|
||||||
|
</MenuAction>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.canDelete && (
|
||||||
|
<MenuAction
|
||||||
|
onClick={() => {
|
||||||
|
message.openDelete();
|
||||||
|
}}
|
||||||
|
className="text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-5" />
|
||||||
|
<span>Delete message</span>
|
||||||
|
</MenuAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
|
|
||||||
import { LocalMessageNode } from '@colanode/client/types';
|
import { LocalMessageNode } from '@colanode/client/types';
|
||||||
@@ -5,11 +6,18 @@ import { MessageActions } from '@colanode/ui/components/messages/message-actions
|
|||||||
import { MessageAuthorAvatar } from '@colanode/ui/components/messages/message-author-avatar';
|
import { MessageAuthorAvatar } from '@colanode/ui/components/messages/message-author-avatar';
|
||||||
import { MessageAuthorName } from '@colanode/ui/components/messages/message-author-name';
|
import { MessageAuthorName } from '@colanode/ui/components/messages/message-author-name';
|
||||||
import { MessageContent } from '@colanode/ui/components/messages/message-content';
|
import { MessageContent } from '@colanode/ui/components/messages/message-content';
|
||||||
|
import { MessageDeleteDialog } from '@colanode/ui/components/messages/message-delete-dialog';
|
||||||
|
import { MessageMenuMobile } from '@colanode/ui/components/messages/message-menu-mobile';
|
||||||
import { MessageReactionCounts } from '@colanode/ui/components/messages/message-reaction-counts';
|
import { MessageReactionCounts } from '@colanode/ui/components/messages/message-reaction-counts';
|
||||||
import { MessageReference } from '@colanode/ui/components/messages/message-reference';
|
import { MessageReference } from '@colanode/ui/components/messages/message-reference';
|
||||||
import { MessageTime } from '@colanode/ui/components/messages/message-time';
|
import { MessageTime } from '@colanode/ui/components/messages/message-time';
|
||||||
|
import { useConversation } from '@colanode/ui/contexts/conversation';
|
||||||
|
import { MessageContext } from '@colanode/ui/contexts/message';
|
||||||
import { useRadar } from '@colanode/ui/contexts/radar';
|
import { useRadar } from '@colanode/ui/contexts/radar';
|
||||||
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { useIsMobile } from '@colanode/ui/hooks/use-is-mobile';
|
||||||
|
import { useLongPress } from '@colanode/ui/hooks/use-long-press';
|
||||||
|
import { cn } from '@colanode/ui/lib/utils';
|
||||||
|
|
||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
message: LocalMessageNode;
|
message: LocalMessageNode;
|
||||||
@@ -36,48 +44,106 @@ const shouldDisplayAuthor = (
|
|||||||
|
|
||||||
export const Message = ({ message, previousMessage }: MessageProps) => {
|
export const Message = ({ message, previousMessage }: MessageProps) => {
|
||||||
const workspace = useWorkspace();
|
const workspace = useWorkspace();
|
||||||
|
const conversation = useConversation();
|
||||||
|
|
||||||
const radar = useRadar();
|
const radar = useRadar();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const [isLongPressing, setIsLongPressing] = useState(false);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||||
|
|
||||||
const displayAuthor = shouldDisplayAuthor(message, previousMessage);
|
const displayAuthor = shouldDisplayAuthor(message, previousMessage);
|
||||||
|
|
||||||
return (
|
const longPressHandlers = isMobile
|
||||||
<div
|
? useLongPress(
|
||||||
id={`message-${message.id}`}
|
() => {
|
||||||
key={`message-${message.id}`}
|
setIsMobileMenuOpen(true);
|
||||||
className={`group flex flex-row px-1 rounded-sm hover:bg-accent ${
|
},
|
||||||
displayAuthor ? 'mt-2 first:mt-0' : ''
|
{
|
||||||
}`}
|
onStart: () => {
|
||||||
>
|
setIsLongPressing(true);
|
||||||
<div className="mr-2 w-10 pt-1">
|
},
|
||||||
{displayAuthor && <MessageAuthorAvatar message={message} />}
|
onFinish: () => {
|
||||||
</div>
|
setIsLongPressing(false);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setIsLongPressing(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
|
||||||
<div className="relative w-full">
|
return (
|
||||||
{displayAuthor && (
|
<MessageContext.Provider
|
||||||
<div className="flex flex-row items-center gap-0.5">
|
value={{
|
||||||
<MessageAuthorName message={message} />
|
...message,
|
||||||
<MessageTime message={message} />
|
canDelete: conversation.canDeleteMessage(message),
|
||||||
</div>
|
canReplyInThread: false,
|
||||||
|
openDelete: () => {
|
||||||
|
setOpenDeleteDialog(true);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id={`message-${message.id}`}
|
||||||
|
key={`message-${message.id}`}
|
||||||
|
className={cn(
|
||||||
|
'group flex flex-row px-1 rounded-sm transition-colors duration-150',
|
||||||
|
isLongPressing
|
||||||
|
? 'bg-accent-foreground/10 scale-[0.98]'
|
||||||
|
: 'hover:bg-accent',
|
||||||
|
displayAuthor && 'mt-2 first:mt-0'
|
||||||
)}
|
)}
|
||||||
<InView
|
{...longPressHandlers}
|
||||||
rootMargin="50px"
|
>
|
||||||
onChange={(inView) => {
|
<div className="mr-2 w-10 pt-1">
|
||||||
if (inView) {
|
{displayAuthor && <MessageAuthorAvatar message={message} />}
|
||||||
radar.markNodeAsSeen(
|
</div>
|
||||||
workspace.accountId,
|
|
||||||
workspace.id,
|
<div className="relative w-full">
|
||||||
message.id
|
{displayAuthor && (
|
||||||
);
|
<div className="flex flex-row items-center gap-0.5">
|
||||||
}
|
<MessageAuthorName message={message} />
|
||||||
}}
|
<MessageTime message={message} />
|
||||||
>
|
</div>
|
||||||
<MessageActions message={message} />
|
|
||||||
{message.attributes.referenceId && (
|
|
||||||
<MessageReference messageId={message.attributes.referenceId} />
|
|
||||||
)}
|
)}
|
||||||
<MessageContent message={message} />
|
<InView
|
||||||
<MessageReactionCounts message={message} />
|
rootMargin="50px"
|
||||||
</InView>
|
onChange={(inView) => {
|
||||||
|
if (inView) {
|
||||||
|
radar.markNodeAsSeen(
|
||||||
|
workspace.accountId,
|
||||||
|
workspace.id,
|
||||||
|
message.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isMobile && <MessageActions />}
|
||||||
|
{message.attributes.referenceId && (
|
||||||
|
<MessageReference messageId={message.attributes.referenceId} />
|
||||||
|
)}
|
||||||
|
<MessageContent message={message} />
|
||||||
|
<MessageReactionCounts message={message} />
|
||||||
|
</InView>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<MessageMenuMobile
|
||||||
|
message={message}
|
||||||
|
isOpen={isMobileMenuOpen}
|
||||||
|
onOpenChange={setIsMobileMenuOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{openDeleteDialog && (
|
||||||
|
<MessageDeleteDialog
|
||||||
|
id={message.id}
|
||||||
|
open={openDeleteDialog}
|
||||||
|
onOpenChange={setOpenDeleteDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</MessageContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const SpaceCreateDialog = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-xl max-w-xl min-w-xl">
|
<DialogContent className="w-xl max-w-full">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create space</DialogTitle>
|
<DialogTitle>Create space</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { Input } from '@colanode/ui/components/ui/input';
|
import { Input } from '@colanode/ui/components/ui/input';
|
||||||
import { Spinner } from '@colanode/ui/components/ui/spinner';
|
import { Spinner } from '@colanode/ui/components/ui/spinner';
|
||||||
import { Textarea } from '@colanode/ui/components/ui/textarea';
|
import { Textarea } from '@colanode/ui/components/ui/textarea';
|
||||||
|
import { useIsMobile } from '@colanode/ui/hooks/use-is-mobile';
|
||||||
import { cn } from '@colanode/ui/lib/utils';
|
import { cn } from '@colanode/ui/lib/utils';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -47,6 +48,7 @@ export const SpaceForm = ({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: SpaceFormProps) => {
|
}: SpaceFormProps) => {
|
||||||
const id = useRef(generateId(IdType.Space));
|
const id = useRef(generateId(IdType.Space));
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const form = useForm<formSchemaType>({
|
const form = useForm<formSchemaType>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -63,19 +65,24 @@ export const SpaceForm = ({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form className="flex flex-col" onSubmit={form.handleSubmit(onSubmit)}>
|
<form className="flex flex-col" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<div className="flex flex-row gap-1">
|
<div className={cn('flex gap-1', isMobile ? 'flex-col' : 'flex-row')}>
|
||||||
<AvatarPopover
|
<AvatarPopover
|
||||||
onPick={(avatar) => {
|
onPick={(avatar) => {
|
||||||
form.setValue('avatar', avatar);
|
form.setValue('avatar', avatar);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="size-40 pt-3">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pt-3',
|
||||||
|
isMobile ? 'flex justify-center pb-4' : 'size-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="group relative cursor-pointer">
|
<div className="group relative cursor-pointer">
|
||||||
<Avatar
|
<Avatar
|
||||||
id={id.current}
|
id={id.current}
|
||||||
name={name.length > 0 ? name : 'New space'}
|
name={name.length > 0 ? name : 'New space'}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
className="size-32"
|
className={isMobile ? 'size-24' : 'size-32'}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -89,7 +96,12 @@ export const SpaceForm = ({
|
|||||||
</div>
|
</div>
|
||||||
</AvatarPopover>
|
</AvatarPopover>
|
||||||
|
|
||||||
<div className="flex-grow space-y-4 py-2 pb-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'space-y-4 py-2 pb-4',
|
||||||
|
isMobile ? 'w-full' : 'flex-grow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ function SheetContent({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
side = 'right',
|
side = 'right',
|
||||||
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
@@ -70,10 +72,12 @@ function SheetContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
{showCloseButton && (
|
||||||
<XIcon className="size-4" />
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
<span className="sr-only">Close</span>
|
<XIcon className="size-4" />
|
||||||
</SheetPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
);
|
);
|
||||||
|
|||||||
15
packages/ui/src/contexts/message.tsx
Normal file
15
packages/ui/src/contexts/message.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
import { LocalMessageNode } from '@colanode/client/types';
|
||||||
|
|
||||||
|
interface MessageContext extends LocalMessageNode {
|
||||||
|
canDelete: boolean;
|
||||||
|
canReplyInThread: boolean;
|
||||||
|
openDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageContext = createContext<MessageContext>(
|
||||||
|
{} as MessageContext
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useMessage = () => useContext(MessageContext);
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useApp } from '@colanode/ui/contexts/app';
|
||||||
|
|
||||||
const mobileDeviceRegex =
|
const mobileDeviceRegex =
|
||||||
/Android|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i;
|
/Android|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i;
|
||||||
|
|
||||||
export const useIsMobile = (): boolean => {
|
export const useIsMobile = (): boolean => {
|
||||||
|
const app = useApp();
|
||||||
|
if (app.type === 'mobile') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return mobileDeviceRegex.test(navigator.userAgent);
|
return mobileDeviceRegex.test(navigator.userAgent);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
92
packages/ui/src/hooks/use-long-press.tsx
Normal file
92
packages/ui/src/hooks/use-long-press.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useRef, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface UseLongPressOptions {
|
||||||
|
threshold?: number;
|
||||||
|
onStart?: (event: MouseEvent | TouchEvent) => void;
|
||||||
|
onFinish?: (event: MouseEvent | TouchEvent) => void;
|
||||||
|
onCancel?: (event: MouseEvent | TouchEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLongPressCallback {
|
||||||
|
(event: MouseEvent | TouchEvent): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRelevantEvent = (event: MouseEvent | TouchEvent): boolean => {
|
||||||
|
return (
|
||||||
|
event.type === 'mousedown' ||
|
||||||
|
event.type === 'touchstart' ||
|
||||||
|
event.type === 'mouseup' ||
|
||||||
|
event.type === 'touchend' ||
|
||||||
|
event.type === 'mouseleave'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLongPress = (
|
||||||
|
callback: UseLongPressCallback,
|
||||||
|
options: UseLongPressOptions = {}
|
||||||
|
) => {
|
||||||
|
const { threshold = 400, onStart, onFinish, onCancel } = options;
|
||||||
|
const isLongPressActive = useRef<boolean>(false);
|
||||||
|
const isPressed = useRef<boolean>(false);
|
||||||
|
const timerId = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const start = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if (!isRelevantEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onStart) {
|
||||||
|
onStart(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPressed.current = true;
|
||||||
|
timerId.current = setTimeout(() => {
|
||||||
|
isLongPressActive.current = true;
|
||||||
|
callback(event);
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish(event);
|
||||||
|
}
|
||||||
|
}, threshold);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if (!isRelevantEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLongPressActive.current) {
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish(event);
|
||||||
|
}
|
||||||
|
} else if (isPressed.current) {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLongPressActive.current = false;
|
||||||
|
isPressed.current = false;
|
||||||
|
|
||||||
|
if (timerId.current) {
|
||||||
|
window.clearTimeout(timerId.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mouseHandlers = {
|
||||||
|
onMouseDown: start,
|
||||||
|
onMouseUp: cancel,
|
||||||
|
onMouseLeave: cancel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchHandlers = {
|
||||||
|
onTouchStart: start,
|
||||||
|
onTouchEnd: cancel,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mouseHandlers,
|
||||||
|
...touchHandlers,
|
||||||
|
};
|
||||||
|
}, [callback, threshold, onCancel, onFinish, onStart]);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user