diff --git a/apps/mobile/index.html b/apps/mobile/index.html index 21677af9..e2316411 100644 --- a/apps/mobile/index.html +++ b/apps/mobile/index.html @@ -2,10 +2,7 @@ - + Colanode diff --git a/packages/ui/src/components/layouts/sidebars/sidebar-header.tsx b/packages/ui/src/components/layouts/sidebars/sidebar-header.tsx index f72b894b..8bc57715 100644 --- a/packages/ui/src/components/layouts/sidebars/sidebar-header.tsx +++ b/packages/ui/src/components/layouts/sidebars/sidebar-header.tsx @@ -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 (

{title}

{actions && ( -
+
{actions}
)} diff --git a/packages/ui/src/components/layouts/sidebars/sidebar-mobile.tsx b/packages/ui/src/components/layouts/sidebars/sidebar-mobile.tsx index 453ccb26..9594564f 100644 --- a/packages/ui/src/components/layouts/sidebars/sidebar-mobile.tsx +++ b/packages/ui/src/components/layouts/sidebars/sidebar-mobile.tsx @@ -33,7 +33,8 @@ export const SidebarMobile = () => { diff --git a/packages/ui/src/components/messages/conversation.tsx b/packages/ui/src/components/messages/conversation.tsx index 992eadc7..add199d8 100644 --- a/packages/ui/src/components/messages/conversation.tsx +++ b/packages/ui/src/components/messages/conversation.tsx @@ -116,7 +116,7 @@ export const Conversation = ({ >
-
+
diff --git a/packages/ui/src/components/messages/message-actions.tsx b/packages/ui/src/components/messages/message-actions.tsx index 6ba13653..ded4872e 100644 --- a/packages/ui/src/components/messages/message-actions.tsx +++ b/packages/ui/src/components/messages/message-actions.tsx @@ -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) => { />
- {canReplyInThread && ( + {message.canReplyInThread && ( @@ -113,9 +106,14 @@ export const MessageActions = ({ message }: MessageActionsProps) => { /> )} - {canDelete && ( + {message.canDelete && ( - + { + message.openDelete(); + }} + /> )} diff --git a/packages/ui/src/components/messages/message-create.tsx b/packages/ui/src/components/messages/message-create.tsx index 69cc7c79..771c8e38 100644 --- a/packages/ui/src/components/messages/message-create.tsx +++ b/packages/ui/src/components/messages/message-create.tsx @@ -116,81 +116,78 @@ export const MessageCreate = forwardRef((_, ref) => { }, [messageEditorRef]); return ( -
-
- {conversation.canCreateMessage && replyTo && ( - setReplyTo(null)} - /> - )} -
-
- - - - - - - - -
- - Browse -
-
- -
- - Upload -
-
-
-
-
-
- {conversation.canCreateMessage ? ( - - ) : ( -

- You don't have permission to create messages in this - conversation -

- )} -
-
- {isPending ? ( - - ) : ( - - )} -
+
+ {conversation.canCreateMessage && replyTo && ( + setReplyTo(null)} + /> + )} +
+
+ + + + + + + + +
+ + Browse +
+
+ +
+ + Upload +
+
+
+
+
+
+ {conversation.canCreateMessage ? ( + + ) : ( +

+ You don't have permission to create messages in this + conversation +

+ )} +
+
+ {isPending ? ( + + ) : ( + + )}
-
); }); diff --git a/packages/ui/src/components/messages/message-delete-button.tsx b/packages/ui/src/components/messages/message-delete-button.tsx deleted file mode 100644 index ba0e7493..00000000 --- a/packages/ui/src/components/messages/message-delete-button.tsx +++ /dev/null @@ -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 ( - - setShowDeleteModal(true)} - /> - - - - - Are you sure you want delete this message? - - - This action cannot be undone. This message will no longer be - accessible by you or others you've shared it with. - - - - Cancel - - - - - - ); -}; diff --git a/packages/ui/src/components/messages/message-delete-dialog.tsx b/packages/ui/src/components/messages/message-delete-dialog.tsx new file mode 100644 index 00000000..7ef2ecad --- /dev/null +++ b/packages/ui/src/components/messages/message-delete-dialog.tsx @@ -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 ( + + + + + Are you sure you want delete this message? + + + This action cannot be undone. This message will no longer be + accessible by you or others you've shared it with. + + + + Cancel + + + + + ); +}; diff --git a/packages/ui/src/components/messages/message-menu-mobile.tsx b/packages/ui/src/components/messages/message-menu-mobile.tsx new file mode 100644 index 00000000..427076b3 --- /dev/null +++ b/packages/ui/src/components/messages/message-menu-mobile.tsx @@ -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 ( + + ); +}; + +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 ( + + + Message Actions + Actions for the selected message + + +
+
+

+ Quick Reactions +

+
+
+ +
+
+ +
+
+ +
+
+ { + if (!isPending) { + handleReactionClick(reaction); + } + }} + /> +
+
+
+ + {/* Actions Section */} +
+ {canReplyInThread && ( + + + Reply in thread + + )} + + {conversation.canCreateMessage && ( + + + Reply + + )} + + {message.canDelete && ( + { + message.openDelete(); + }} + className="text-destructive hover:bg-destructive/10" + > + + Delete message + + )} +
+
+
+
+ ); +}; diff --git a/packages/ui/src/components/messages/message.tsx b/packages/ui/src/components/messages/message.tsx index f64dc31b..ca1d9a5b 100644 --- a/packages/ui/src/components/messages/message.tsx +++ b/packages/ui/src/components/messages/message.tsx @@ -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 ( -
-
- {displayAuthor && } -
+ const longPressHandlers = isMobile + ? useLongPress( + () => { + setIsMobileMenuOpen(true); + }, + { + onStart: () => { + setIsLongPressing(true); + }, + onFinish: () => { + setIsLongPressing(false); + }, + onCancel: () => { + setIsLongPressing(false); + }, + } + ) + : {}; -
- {displayAuthor && ( -
- - -
+ return ( + { + setOpenDeleteDialog(true); + }, + }} + > +
{ - if (inView) { - radar.markNodeAsSeen( - workspace.accountId, - workspace.id, - message.id - ); - } - }} - > - - {message.attributes.referenceId && ( - + {...longPressHandlers} + > +
+ {displayAuthor && } +
+ +
+ {displayAuthor && ( +
+ + +
)} - - - + { + if (inView) { + radar.markNodeAsSeen( + workspace.accountId, + workspace.id, + message.id + ); + } + }} + > + {!isMobile && } + {message.attributes.referenceId && ( + + )} + + + +
+ + {isMobile && ( + + )} + {openDeleteDialog && ( + + )}
-
+ ); }; diff --git a/packages/ui/src/components/spaces/space-create-dialog.tsx b/packages/ui/src/components/spaces/space-create-dialog.tsx index c525609d..fd92c131 100644 --- a/packages/ui/src/components/spaces/space-create-dialog.tsx +++ b/packages/ui/src/components/spaces/space-create-dialog.tsx @@ -25,7 +25,7 @@ export const SpaceCreateDialog = ({ return ( - + Create space diff --git a/packages/ui/src/components/spaces/space-form.tsx b/packages/ui/src/components/spaces/space-form.tsx index e7861627..7fd65a5a 100644 --- a/packages/ui/src/components/spaces/space-form.tsx +++ b/packages/ui/src/components/spaces/space-form.tsx @@ -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({ resolver: zodResolver(formSchema), @@ -63,19 +65,24 @@ export const SpaceForm = ({ return (
-
+
{ form.setValue('avatar', avatar); }} > -
+
0 ? name : 'New space'} avatar={avatar} - className="size-32" + className={isMobile ? 'size-24' : 'size-32'} />
-
+
& { side?: 'top' | 'right' | 'bottom' | 'left'; + showCloseButton?: boolean; }) { return ( @@ -70,10 +72,12 @@ function SheetContent({ {...props} > {children} - - - Close - + {showCloseButton && ( + + + Close + + )} ); diff --git a/packages/ui/src/contexts/message.tsx b/packages/ui/src/contexts/message.tsx new file mode 100644 index 00000000..f3d7f2a3 --- /dev/null +++ b/packages/ui/src/contexts/message.tsx @@ -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( + {} as MessageContext +); + +export const useMessage = () => useContext(MessageContext); diff --git a/packages/ui/src/hooks/use-is-mobile.tsx b/packages/ui/src/hooks/use-is-mobile.tsx index 8b46c563..5cf42295 100644 --- a/packages/ui/src/hooks/use-is-mobile.tsx +++ b/packages/ui/src/hooks/use-is-mobile.tsx @@ -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); }, []); diff --git a/packages/ui/src/hooks/use-long-press.tsx b/packages/ui/src/hooks/use-long-press.tsx new file mode 100644 index 00000000..5ec4309d --- /dev/null +++ b/packages/ui/src/hooks/use-long-press.tsx @@ -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(false); + const isPressed = useRef(false); + const timerId = useRef(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]); +};