Improve ui for mobile

This commit is contained in:
Hakan Shehu
2025-09-25 22:44:08 +02:00
parent da45a91555
commit bfe16389cc
16 changed files with 589 additions and 214 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;t have permission to create messages in this
You don&apos;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>
); );
}); });

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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);
}, []); }, []);

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