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>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Colanode</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { useIsMobile } from '@colanode/ui/hooks/use-is-mobile';
|
||||
import { cn } from '@colanode/ui/lib/utils';
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
title: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SidebarHeader = ({ title, actions }: SidebarHeaderProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<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">
|
||||
{title}
|
||||
</p>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,8 @@ export const SidebarMobile = () => {
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
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"
|
||||
>
|
||||
<Sidebar />
|
||||
|
||||
@@ -116,7 +116,7 @@ export const Conversation = ({
|
||||
>
|
||||
<div ref={bottomRef} className="h-4"></div>
|
||||
</InView>
|
||||
<div className="sticky bottom-0 bg-background">
|
||||
<div className="sticky bottom-0 bg-background pb-4 pt-2">
|
||||
<MessageCreate ref={messageCreateRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { MessagesSquare, Reply } from 'lucide-react';
|
||||
import { MessagesSquare, Reply, Trash2 } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
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 { MessageReactionCreatePopover } from '@colanode/ui/components/messages/message-reaction-create-popover';
|
||||
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';
|
||||
@@ -19,18 +18,12 @@ const MessageAction = ({ children }: { children: React.ReactNode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface MessageActionsProps {
|
||||
message: LocalMessageNode;
|
||||
}
|
||||
|
||||
export const MessageActions = ({ message }: MessageActionsProps) => {
|
||||
export const MessageActions = () => {
|
||||
const message = useMessage();
|
||||
const workspace = useWorkspace();
|
||||
const conversation = useConversation();
|
||||
const { mutate, isPending } = useMutation();
|
||||
|
||||
const canDelete = conversation.canDeleteMessage(message);
|
||||
const canReplyInThread = false;
|
||||
|
||||
const handleReactionClick = useCallback(
|
||||
(reaction: string) => {
|
||||
if (isPending) {
|
||||
@@ -75,7 +68,7 @@ export const MessageActions = ({ message }: MessageActionsProps) => {
|
||||
/>
|
||||
</MessageAction>
|
||||
<div className="mx-1 h-6 w-[1px] bg-border" />
|
||||
{canReplyInThread && (
|
||||
{message.canReplyInThread && (
|
||||
<MessageAction>
|
||||
<MessagesSquare className="size-4 cursor-pointer" />
|
||||
</MessageAction>
|
||||
@@ -113,9 +106,14 @@ export const MessageActions = ({ message }: MessageActionsProps) => {
|
||||
/>
|
||||
</MessageAction>
|
||||
)}
|
||||
{canDelete && (
|
||||
{message.canDelete && (
|
||||
<MessageAction>
|
||||
<MessageDeleteButton id={message.id} />
|
||||
<Trash2
|
||||
className="size-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
message.openDelete();
|
||||
}}
|
||||
/>
|
||||
</MessageAction>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
@@ -116,81 +116,78 @@ export const MessageCreate = forwardRef<MessageCreateRefProps>((_, ref) => {
|
||||
}, [messageEditorRef]);
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div className="flex flex-col">
|
||||
{conversation.canCreateMessage && replyTo && (
|
||||
<MessageReplyBanner
|
||||
message={replyTo}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isPending || !conversation.canCreateMessage}
|
||||
className="cursor-pointer hover:bg-accent"
|
||||
>
|
||||
<span>
|
||||
<Plus size={20} />
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={true}>
|
||||
<div className="flex flex-row items-center gap-2 text-sm">
|
||||
<Search className="size-4" />
|
||||
<span>Browse</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleUploadClick}>
|
||||
<div className="flex cursor-pointer flex-row items-center gap-2 text-sm">
|
||||
<Upload className="size-4" />
|
||||
<span>Upload</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="max-h-72 flex-grow overflow-y-auto">
|
||||
{conversation.canCreateMessage ? (
|
||||
<MessageEditor
|
||||
key={conversation.id}
|
||||
ref={messageEditorRef}
|
||||
userId={workspace.userId}
|
||||
accountId={workspace.accountId}
|
||||
workspaceId={workspace.id}
|
||||
conversationId={conversation.id}
|
||||
rootId={conversation.rootId}
|
||||
onChange={setContent}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : (
|
||||
<p className="m-0 px-0 py-1 text-muted-foreground">
|
||||
You don't have permission to create messages in this
|
||||
conversation
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
{isPending ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className={`${
|
||||
conversation.canCreateMessage && hasContent
|
||||
? 'cursor-pointer text-blue-600'
|
||||
: 'cursor-default text-muted-foreground'
|
||||
}`}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<Send size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{conversation.canCreateMessage && replyTo && (
|
||||
<MessageReplyBanner
|
||||
message={replyTo}
|
||||
onCancel={() => setReplyTo(null)}
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isPending || !conversation.canCreateMessage}
|
||||
className="cursor-pointer hover:bg-accent"
|
||||
>
|
||||
<span>
|
||||
<Plus size={20} />
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={true}>
|
||||
<div className="flex flex-row items-center gap-2 text-sm">
|
||||
<Search className="size-4" />
|
||||
<span>Browse</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleUploadClick}>
|
||||
<div className="flex cursor-pointer flex-row items-center gap-2 text-sm">
|
||||
<Upload className="size-4" />
|
||||
<span>Upload</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="max-h-72 flex-grow overflow-y-auto">
|
||||
{conversation.canCreateMessage ? (
|
||||
<MessageEditor
|
||||
key={conversation.id}
|
||||
ref={messageEditorRef}
|
||||
userId={workspace.userId}
|
||||
accountId={workspace.accountId}
|
||||
workspaceId={workspace.id}
|
||||
conversationId={conversation.id}
|
||||
rootId={conversation.rootId}
|
||||
onChange={setContent}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : (
|
||||
<p className="m-0 px-0 py-1 text-muted-foreground">
|
||||
You don't have permission to create messages in this
|
||||
conversation
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
{isPending ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className={`${
|
||||
conversation.canCreateMessage && hasContent
|
||||
? 'cursor-pointer text-blue-600'
|
||||
: 'cursor-default text-muted-foreground'
|
||||
}`}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<Send size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-3 min-h-3 items-center text-xs text-muted-foreground"></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 { 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 { MessageAuthorName } from '@colanode/ui/components/messages/message-author-name';
|
||||
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 { MessageReference } from '@colanode/ui/components/messages/message-reference';
|
||||
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 { 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 {
|
||||
message: LocalMessageNode;
|
||||
@@ -36,48 +44,106 @@ const shouldDisplayAuthor = (
|
||||
|
||||
export const Message = ({ message, previousMessage }: MessageProps) => {
|
||||
const workspace = useWorkspace();
|
||||
const conversation = useConversation();
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`message-${message.id}`}
|
||||
key={`message-${message.id}`}
|
||||
className={`group flex flex-row px-1 rounded-sm hover:bg-accent ${
|
||||
displayAuthor ? 'mt-2 first:mt-0' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="mr-2 w-10 pt-1">
|
||||
{displayAuthor && <MessageAuthorAvatar message={message} />}
|
||||
</div>
|
||||
const longPressHandlers = isMobile
|
||||
? useLongPress(
|
||||
() => {
|
||||
setIsMobileMenuOpen(true);
|
||||
},
|
||||
{
|
||||
onStart: () => {
|
||||
setIsLongPressing(true);
|
||||
},
|
||||
onFinish: () => {
|
||||
setIsLongPressing(false);
|
||||
},
|
||||
onCancel: () => {
|
||||
setIsLongPressing(false);
|
||||
},
|
||||
}
|
||||
)
|
||||
: {};
|
||||
|
||||
<div className="relative w-full">
|
||||
{displayAuthor && (
|
||||
<div className="flex flex-row items-center gap-0.5">
|
||||
<MessageAuthorName message={message} />
|
||||
<MessageTime message={message} />
|
||||
</div>
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
...message,
|
||||
canDelete: conversation.canDeleteMessage(message),
|
||||
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
|
||||
rootMargin="50px"
|
||||
onChange={(inView) => {
|
||||
if (inView) {
|
||||
radar.markNodeAsSeen(
|
||||
workspace.accountId,
|
||||
workspace.id,
|
||||
message.id
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MessageActions message={message} />
|
||||
{message.attributes.referenceId && (
|
||||
<MessageReference messageId={message.attributes.referenceId} />
|
||||
{...longPressHandlers}
|
||||
>
|
||||
<div className="mr-2 w-10 pt-1">
|
||||
{displayAuthor && <MessageAuthorAvatar message={message} />}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full">
|
||||
{displayAuthor && (
|
||||
<div className="flex flex-row items-center gap-0.5">
|
||||
<MessageAuthorName message={message} />
|
||||
<MessageTime message={message} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent message={message} />
|
||||
<MessageReactionCounts message={message} />
|
||||
</InView>
|
||||
<InView
|
||||
rootMargin="50px"
|
||||
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>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SpaceCreateDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-xl max-w-xl min-w-xl">
|
||||
<DialogContent className="w-xl max-w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create space</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Input } from '@colanode/ui/components/ui/input';
|
||||
import { Spinner } from '@colanode/ui/components/ui/spinner';
|
||||
import { Textarea } from '@colanode/ui/components/ui/textarea';
|
||||
import { useIsMobile } from '@colanode/ui/hooks/use-is-mobile';
|
||||
import { cn } from '@colanode/ui/lib/utils';
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -47,6 +48,7 @@ export const SpaceForm = ({
|
||||
readOnly = false,
|
||||
}: SpaceFormProps) => {
|
||||
const id = useRef(generateId(IdType.Space));
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const form = useForm<formSchemaType>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -63,19 +65,24 @@ export const SpaceForm = ({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<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
|
||||
onPick={(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">
|
||||
<Avatar
|
||||
id={id.current}
|
||||
name={name.length > 0 ? name : 'New space'}
|
||||
avatar={avatar}
|
||||
className="size-32"
|
||||
className={isMobile ? 'size-24' : 'size-32'}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -89,7 +96,12 @@ export const SpaceForm = ({
|
||||
</div>
|
||||
</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
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
||||
@@ -46,9 +46,11 @@ function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -70,10 +72,12 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{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">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{showCloseButton && (
|
||||
<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">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</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 { useApp } from '@colanode/ui/contexts/app';
|
||||
|
||||
const mobileDeviceRegex =
|
||||
/Android|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i;
|
||||
|
||||
export const useIsMobile = (): boolean => {
|
||||
const app = useApp();
|
||||
if (app.type === 'mobile') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return useMemo(() => {
|
||||
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