From 7d44085a8ecd1d642477b0fb1cd988e32ed0d032 Mon Sep 17 00:00:00 2001 From: Ylber Gashi <58399076+ylber-gashi@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:45:14 +0100 Subject: [PATCH] Improve mobile reliability and rendering (#340) --- apps/mobile/.eslintrc.json | 8 + apps/mobile/app/(app)/(settings)/about.tsx | 2 +- apps/mobile/app/(app)/(settings)/account.tsx | 1 - .../mobile/app/(app)/(settings)/workspace.tsx | 1 - .../app/(app)/(spaces)/file/[fileId].tsx | 2 +- .../app/(app)/(spaces)/page/[pageId].tsx | 8 +- apps/mobile/app/(auth)/index.tsx | 2 +- apps/mobile/package.json | 2 +- .../conversation/conversation-screen.tsx | 4 +- .../components/messages/block-renderer.tsx | 565 +++++++++++++++++- .../src/components/messages/message-input.tsx | 2 +- .../src/components/messages/message-item.tsx | 2 +- .../components/nodes/rename-node-sheet.tsx | 2 +- apps/mobile/src/components/ui/button.tsx | 2 +- apps/mobile/src/contexts/workspace.tsx | 2 +- apps/mobile/src/hooks/use-live-query.ts | 1 - apps/mobile/src/hooks/use-mutation.ts | 1 - apps/mobile/src/hooks/use-node-list-query.ts | 3 +- apps/mobile/src/hooks/use-node-query.ts | 1 - apps/mobile/src/hooks/use-query.ts | 1 - apps/mobile/src/lib/message-utils.ts | 2 +- apps/mobile/src/lib/query-client.ts | 2 +- apps/mobile/src/lib/radar-utils.ts | 2 +- apps/mobile/tsconfig.json | 7 +- .../client/src/databases/workspace/schema.ts | 1 + .../mutations/spaces/space-child-reorder.ts | 2 +- packages/client/src/jobs/file-download.ts | 4 +- .../client/src/jobs/local-file-download.ts | 7 +- .../src/services/workspaces/file-service.ts | 55 +- packages/crdt/src/index.ts | 7 +- 30 files changed, 634 insertions(+), 67 deletions(-) create mode 100644 apps/mobile/.eslintrc.json diff --git a/apps/mobile/.eslintrc.json b/apps/mobile/.eslintrc.json new file mode 100644 index 00000000..0029e481 --- /dev/null +++ b/apps/mobile/.eslintrc.json @@ -0,0 +1,8 @@ +{ + "extends": ["../../.eslintrc.json"], + "rules": { + "import/namespace": "off", + "import/no-named-as-default": "off", + "import/no-named-as-default-member": "off" + } +} diff --git a/apps/mobile/app/(app)/(settings)/about.tsx b/apps/mobile/app/(app)/(settings)/about.tsx index 978fd8f9..b1945269 100644 --- a/apps/mobile/app/(app)/(settings)/about.tsx +++ b/apps/mobile/app/(app)/(settings)/about.tsx @@ -1,7 +1,7 @@ import Feather from '@expo/vector-icons/Feather'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; -import { Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SvgUri } from 'react-native-svg'; diff --git a/apps/mobile/app/(app)/(settings)/account.tsx b/apps/mobile/app/(app)/(settings)/account.tsx index f5a0ad4e..923b778d 100644 --- a/apps/mobile/app/(app)/(settings)/account.tsx +++ b/apps/mobile/app/(app)/(settings)/account.tsx @@ -4,7 +4,6 @@ import { Alert, KeyboardAvoidingView, Platform, - Pressable, ScrollView, StyleSheet, Text, diff --git a/apps/mobile/app/(app)/(settings)/workspace.tsx b/apps/mobile/app/(app)/(settings)/workspace.tsx index ad53a50c..9aa97459 100644 --- a/apps/mobile/app/(app)/(settings)/workspace.tsx +++ b/apps/mobile/app/(app)/(settings)/workspace.tsx @@ -4,7 +4,6 @@ import { Alert, KeyboardAvoidingView, Platform, - Pressable, ScrollView, StyleSheet, Text, diff --git a/apps/mobile/app/(app)/(spaces)/file/[fileId].tsx b/apps/mobile/app/(app)/(spaces)/file/[fileId].tsx index 66008454..be13d48d 100644 --- a/apps/mobile/app/(app)/(spaces)/file/[fileId].tsx +++ b/apps/mobile/app/(app)/(spaces)/file/[fileId].tsx @@ -16,8 +16,8 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { LocalFileNode } from '@colanode/client/types/nodes'; -import { BackButton } from '@colanode/mobile/components/ui/back-button'; import { LoadingScreen } from '@colanode/mobile/components/loading-screen'; +import { BackButton } from '@colanode/mobile/components/ui/back-button'; import { useAppService } from '@colanode/mobile/contexts/app-service'; import { useTheme } from '@colanode/mobile/contexts/theme'; import { useWorkspace } from '@colanode/mobile/contexts/workspace'; diff --git a/apps/mobile/app/(app)/(spaces)/page/[pageId].tsx b/apps/mobile/app/(app)/(spaces)/page/[pageId].tsx index dfa799d5..0ddd48a9 100644 --- a/apps/mobile/app/(app)/(spaces)/page/[pageId].tsx +++ b/apps/mobile/app/(app)/(spaces)/page/[pageId].tsx @@ -3,13 +3,13 @@ import { useState } from 'react'; import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Block } from '@colanode/core'; import { mapBlocksToContents } from '@colanode/client/lib'; import { LocalPageNode } from '@colanode/client/types/nodes'; -import { BackButton } from '@colanode/mobile/components/ui/back-button'; +import { RichTextContent } from '@colanode/core'; import { LoadingScreen } from '@colanode/mobile/components/loading-screen'; import { BlockRenderer } from '@colanode/mobile/components/messages/block-renderer'; import { RenameNodeSheet } from '@colanode/mobile/components/nodes/rename-node-sheet'; +import { BackButton } from '@colanode/mobile/components/ui/back-button'; import { useTheme } from '@colanode/mobile/contexts/theme'; import { useWorkspace } from '@colanode/mobile/contexts/workspace'; import { useLiveQuery } from '@colanode/mobile/hooks/use-live-query'; @@ -37,9 +37,9 @@ export default function PageScreen() { let jsonContent = null; if (document?.content) { - const richText = document.content as any; + const richText = document.content as RichTextContent; if (richText.blocks) { - const blocks = Object.values(richText.blocks) as Block[]; + const blocks = Object.values(richText.blocks); const contents = mapBlocksToContents(pageId!, blocks); if (contents.length > 0) { jsonContent = { type: 'doc' as const, content: contents }; diff --git a/apps/mobile/app/(auth)/index.tsx b/apps/mobile/app/(auth)/index.tsx index 6d8e9674..122e5b50 100644 --- a/apps/mobile/app/(auth)/index.tsx +++ b/apps/mobile/app/(auth)/index.tsx @@ -8,8 +8,8 @@ import { View, } from 'react-native'; -import { isColanodeDomain } from '@colanode/core'; import { Server } from '@colanode/client/types'; +import { isColanodeDomain } from '@colanode/core'; import { ServerCard } from '@colanode/mobile/components/auth/server-card'; import { AnimatedLogo } from '@colanode/mobile/components/ui/animated-logo'; import { EmptyState } from '@colanode/mobile/components/ui/empty-state'; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 43467ebe..665a3e41 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -7,7 +7,7 @@ "main": "./index.js", "scripts": { "compile": "tsc --noEmit -p tsconfig.json", - "lint": "eslint . --ext .ts,.tsx,.js --max-warnings 0", + "lint": "eslint app src --ext .ts,.tsx --max-warnings 0", "start": "expo start", "android": "expo run:android", "ios": "expo run:ios" diff --git a/apps/mobile/src/components/conversation/conversation-screen.tsx b/apps/mobile/src/components/conversation/conversation-screen.tsx index d9acfbd7..8a5b5c08 100644 --- a/apps/mobile/src/components/conversation/conversation-screen.tsx +++ b/apps/mobile/src/components/conversation/conversation-screen.tsx @@ -13,6 +13,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { LocalMessageNode, LocalNode } from '@colanode/client/types/nodes'; import { EmojiPicker } from '@colanode/mobile/components/emojis/emoji-picker'; +import { LoadingScreen } from '@colanode/mobile/components/loading-screen'; import { MessageActionSheet } from '@colanode/mobile/components/messages/message-action-sheet'; import { EditTarget, MessageInput } from '@colanode/mobile/components/messages/message-input'; import { @@ -22,14 +23,13 @@ import { import { MessageList } from '@colanode/mobile/components/messages/message-list'; import { RenameNodeSheet } from '@colanode/mobile/components/nodes/rename-node-sheet'; import { BackButton } from '@colanode/mobile/components/ui/back-button'; -import { LoadingScreen } from '@colanode/mobile/components/loading-screen'; -import { getMessageText } from '@colanode/mobile/lib/message-utils'; import { useAppService } from '@colanode/mobile/contexts/app-service'; import { useTheme } from '@colanode/mobile/contexts/theme'; import { useWorkspace } from '@colanode/mobile/contexts/workspace'; import { useLiveQuery } from '@colanode/mobile/hooks/use-live-query'; import { useMutation } from '@colanode/mobile/hooks/use-mutation'; import { useNodeListQuery } from '@colanode/mobile/hooks/use-node-list-query'; +import { getMessageText } from '@colanode/mobile/lib/message-utils'; interface ConversationScreenProps { nodeId: string; diff --git a/apps/mobile/src/components/messages/block-renderer.tsx b/apps/mobile/src/components/messages/block-renderer.tsx index 97f96ae4..1cab3ab1 100644 --- a/apps/mobile/src/components/messages/block-renderer.tsx +++ b/apps/mobile/src/components/messages/block-renderer.tsx @@ -1,13 +1,43 @@ -import { Fragment } from 'react'; -import { Linking, StyleSheet, Text, TextStyle, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import { + ActivityIndicator, + Image, + Linking, + Pressable, + StyleSheet, + Text, + TextStyle, + View, +} from 'react-native'; +import { DownloadStatus, TempFile } from '@colanode/client/types/files'; +import { LocalFileNode, LocalNode } from '@colanode/client/types/nodes'; +import { FileStatus } from '@colanode/core'; +import { FileItem } from '@colanode/mobile/components/files/file-item'; +import { NodeIcon } from '@colanode/mobile/components/nodes/node-icon'; import { useTheme } from '@colanode/mobile/contexts/theme'; +import { useWorkspace } from '@colanode/mobile/contexts/workspace'; +import { useLiveQuery } from '@colanode/mobile/hooks/use-live-query'; +import { useNodeQuery } from '@colanode/mobile/hooks/use-node-query'; +import { getNodeDisplayName, NODE_TYPE_LABELS } from '@colanode/mobile/lib/node-utils'; + +type JSONAttributeValue = + | string + | number + | boolean + | null + | undefined + | JSONAttributeValue[]; + +type JSONAttributes = Record; + +type ReferenceNodeType = 'page' | 'folder' | 'database'; interface JSONContent { type?: string; text?: string; - attrs?: Record; - marks?: Array<{ type: string; attrs?: Record }>; + attrs?: JSONAttributes; + marks?: Array<{ type: string; attrs?: JSONAttributes }>; content?: JSONContent[]; } @@ -19,6 +49,113 @@ interface InlineRendererProps { nodes: JSONContent[]; } +const MARK_COLORS: Record< + string, + { textColor?: string; backgroundColor?: string } +> = { + gray: { + textColor: '#4b5563', + backgroundColor: '#e5e7eb', + }, + orange: { + textColor: '#ea580c', + backgroundColor: '#fed7aa', + }, + yellow: { + textColor: '#ca8a04', + backgroundColor: '#fef08a', + }, + green: { + textColor: '#16a34a', + backgroundColor: '#bbf7d0', + }, + blue: { + textColor: '#2563eb', + backgroundColor: '#bfdbfe', + }, + purple: { + textColor: '#9333ea', + backgroundColor: '#e9d5ff', + }, + pink: { + textColor: '#db2777', + backgroundColor: '#fbcfe8', + }, + red: { + textColor: '#dc2626', + backgroundColor: '#fecaca', + }, +}; + +const getStringAttribute = ( + attrs: JSONAttributes | undefined, + key: string +) => { + const value = attrs?.[key]; + return typeof value === 'string' ? value : undefined; +}; + +const getNumberAttribute = ( + attrs: JSONAttributes | undefined, + key: string +) => { + const value = attrs?.[key]; + return typeof value === 'number' ? value : undefined; +}; + +const getBooleanAttribute = ( + attrs: JSONAttributes | undefined, + key: string +) => { + const value = attrs?.[key]; + return typeof value === 'boolean' ? value : false; +}; + +const getNodeKey = (node: JSONContent, fallback: number) => { + const stringId = getStringAttribute(node.attrs, 'id'); + if (stringId) { + return stringId; + } + + const numericId = getNumberAttribute(node.attrs, 'id'); + if (numericId !== undefined) { + return numericId; + } + + return fallback; +}; + +const isReferenceNodeType = ( + type: string | undefined +): type is ReferenceNodeType => { + return type === 'page' || type === 'folder' || type === 'database'; +}; + +const getReferenceNodeSubtitle = (content: JSONContent) => { + if (content.type === 'database' && getBooleanAttribute(content.attrs, 'inline')) { + return 'Inline database'; + } + + if (!content.type || !isReferenceNodeType(content.type)) { + return 'Reference'; + } + + return NODE_TYPE_LABELS[content.type]; +}; + +const getHeadingLevel = (content: JSONContent) => { + switch (content.type) { + case 'heading1': + return 1; + case 'heading2': + return 2; + case 'heading3': + return 3; + default: + return getNumberAttribute(content.attrs, 'level') ?? 1; + } +}; + const InlineRenderer = ({ nodes }: InlineRendererProps) => { const { colors } = useTheme(); @@ -32,9 +169,11 @@ const InlineRenderer = ({ nodes }: InlineRendererProps) => { return {'\n'}; } if (node.type === 'mention') { + const label = getStringAttribute(node.attrs, 'label'); + const id = getStringAttribute(node.attrs, 'id'); return ( - @{node.attrs?.label ?? node.attrs?.id ?? 'unknown'} + @{label ?? id ?? 'unknown'} ); } @@ -74,9 +213,25 @@ const StyledText = ({ node }: { node: JSONContent }) => { fontSize: 13, }; break; + case 'color': { + const color = getStringAttribute(mark.attrs, 'color'); + const palette = color ? MARK_COLORS[color] : undefined; + if (palette?.textColor) { + style = { ...style, color: palette.textColor }; + } + break; + } + case 'highlight': { + const highlight = getStringAttribute(mark.attrs, 'highlight'); + const palette = highlight ? MARK_COLORS[highlight] : undefined; + if (palette?.backgroundColor) { + style = { ...style, backgroundColor: palette.backgroundColor }; + } + break; + } case 'link': isLink = true; - href = mark.attrs?.href ?? ''; + href = getStringAttribute(mark.attrs, 'href') ?? ''; style = { ...style, color: colors.primaryLight, textDecorationLine: 'underline' as const }; break; } @@ -93,6 +248,280 @@ const StyledText = ({ node }: { node: JSONContent }) => { return {node.text}; }; +const ReferenceNodeBlockRenderer = ({ content }: { content: JSONContent }) => { + const router = useRouter(); + const { colors } = useTheme(); + const { userId } = useWorkspace(); + + if (!isReferenceNodeType(content.type)) { + return null; + } + + const nodeId = getStringAttribute(content.attrs, 'id'); + const { data: node } = useNodeQuery(userId, nodeId, content.type); + + if (!nodeId) { + return null; + } + + const canOpen = content.type !== 'database'; + const name = node ? getNodeDisplayName(node) : 'Untitled'; + const subtitle = getReferenceNodeSubtitle(content); + + return ( + { + if (content.type === 'page') { + router.push({ + pathname: '/(app)/(spaces)/page/[pageId]', + params: { pageId: nodeId }, + }); + } + + if (content.type === 'folder') { + router.push({ + pathname: '/(app)/(spaces)/folder/[folderId]', + params: { folderId: nodeId }, + }); + } + }} + style={({ pressed }) => [ + styles.referenceBlock, + { backgroundColor: colors.surface, borderColor: colors.border }, + canOpen && pressed && styles.referenceBlockPressed, + ]} + > + + + + {name} + + + {subtitle} + + + + ); +}; + +const navigateToFile = (router: ReturnType, fileId: string) => { + router.push({ + pathname: '/(app)/(spaces)/file/[fileId]', + params: { fileId }, + }); +}; + +const ImagePreviewPlaceholder = ({ + fileName, + subtitle, + loading = false, +}: { + fileName: string; + subtitle: string; + loading?: boolean; +}) => { + const { colors } = useTheme(); + + return ( + + + {loading ? ( + + ) : ( + + )} + + {subtitle} + + + + + {fileName} + + + + ); +}; + +const ImageFileBlockRenderer = ({ file }: { file: LocalFileNode }) => { + const router = useRouter(); + const { colors } = useTheme(); + const { userId } = useWorkspace(); + const { data: localFile } = useLiveQuery({ + type: 'local.file.get', + fileId: file.id, + userId, + autoDownload: file.status === FileStatus.Ready, + }); + + const progress = + localFile?.downloadStatus === DownloadStatus.Downloading && + localFile.downloadProgress > 0 + ? ` ${Math.round(localFile.downloadProgress)}%` + : ''; + const fileName = file.name ?? 'Image'; + + let content: React.ReactNode; + + if (localFile?.downloadStatus === DownloadStatus.Completed) { + content = ( + + + + + {fileName} + + + + ); + } else if (file.status !== FileStatus.Ready) { + content = ( + + ); + } else if (localFile?.downloadStatus === DownloadStatus.Failed) { + content = ( + + ); + } else { + content = ( + + ); + } + + return ( + navigateToFile(router, file.id)} + style={({ pressed }) => [ + styles.imageBlock, + pressed && styles.referenceBlockPressed, + ]} + > + {content} + + ); +}; + +const FileBlockRenderer = ({ content }: { content: JSONContent }) => { + const router = useRouter(); + const { colors } = useTheme(); + const { userId } = useWorkspace(); + const fileId = getStringAttribute(content.attrs, 'id'); + const { data: file } = useNodeQuery(userId, fileId, 'file'); + + if (!fileId) { + return null; + } + + if (file?.subtype === 'image') { + return ; + } + + return ( + navigateToFile(router, fileId)} + style={({ pressed }) => [styles.fileBlock, pressed && styles.fileBlockPressed]} + > + {file ? ( + + ) : ( + + {'\u{1F4CE}'} + + Attached file + + + )} + + ); +}; + +const TempFileBlockRenderer = ({ content }: { content: JSONContent }) => { + const { colors } = useTheme(); + const tempFileId = getStringAttribute(content.attrs, 'id'); + const { data: tempFiles } = useLiveQuery({ type: 'temp.file.list' }); + const tempFile = tempFiles?.find( + (candidate: TempFile) => candidate.id === tempFileId + ); + + if (!tempFileId) { + return null; + } + + return ( + + + + + {tempFile?.name ?? 'Uploading file'} + + + {tempFile?.subtype ? `${tempFile.subtype} upload` : 'Pending upload'} + + + + ); +}; + export const BlockRenderer = ({ content }: BlockRendererProps) => { const { colors } = useTheme(); @@ -103,7 +532,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { return ( {content.content?.map((child, i) => ( - + ))} ); @@ -119,8 +548,11 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { ); - case 'heading': { - const level = content.attrs?.level ?? 1; + case 'heading': + case 'heading1': + case 'heading2': + case 'heading3': { + const level = getHeadingLevel(content); const fontSize = level === 1 ? 24 : level === 2 ? 20 : 18; return ( @@ -131,15 +563,26 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { ); } + case 'file': + return ; + + case 'page': + case 'folder': + case 'database': + return ; + + case 'tempFile': + return ; + case 'bulletList': return ( {content.content?.map((child, i) => ( - + {'\u2022'} {child.content?.map((inner, j) => ( - + ))} @@ -151,11 +594,11 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { return ( {content.content?.map((child, i) => ( - + {i + 1}. {child.content?.map((inner, j) => ( - + ))} @@ -167,7 +610,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { return ( {content.content?.map((child, i) => ( - + ))} ); @@ -179,7 +622,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { return ( {content.content?.map((child, i) => ( - + ))} ); @@ -188,7 +631,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { return ( {content.content?.map((child, i) => ( - + ))} ); @@ -204,10 +647,10 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { return ( {content.content?.map((row, i) => ( - + {row.content?.map((cell, j) => ( { ]} > {cell.content?.map((inner, k) => ( - + ))} ))} @@ -230,7 +673,7 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { return ( {content.content?.map((child, i) => ( - + ))} ); @@ -242,14 +685,14 @@ export const BlockRenderer = ({ content }: BlockRendererProps) => { return {'\n'}; default: - // Unsupported block types (file, image, etc.) + // Unsupported block types from the shared editor model. return null; } }; const TaskItemRenderer = ({ content }: { content: JSONContent }) => { const { colors } = useTheme(); - const checked = content.attrs?.checked ?? false; + const checked = getBooleanAttribute(content.attrs, 'checked'); return ( @@ -258,7 +701,7 @@ const TaskItemRenderer = ({ content }: { content: JSONContent }) => { {content.content?.map((inner, j) => ( - + ))} @@ -314,6 +757,82 @@ const styles = StyleSheet.create({ padding: 12, marginVertical: 4, }, + fileBlock: { + marginVertical: 6, + }, + imageBlock: { + marginVertical: 6, + alignSelf: 'stretch', + }, + imagePreviewCard: { + width: '100%', + overflow: 'hidden', + borderRadius: 12, + borderWidth: 1, + }, + imagePreview: { + width: '100%', + height: 240, + }, + imagePreviewStatus: { + width: '100%', + height: 240, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + paddingHorizontal: 24, + }, + imagePreviewStatusText: { + fontSize: 14, + textAlign: 'center', + }, + imagePreviewCaption: { + paddingHorizontal: 12, + paddingVertical: 8, + borderTopWidth: 1, + }, + fileBlockPressed: { + opacity: 0.8, + }, + fileFallback: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderRadius: 8, + padding: 12, + borderWidth: 1, + }, + fileIcon: { + fontSize: 24, + }, + fileLabel: { + fontSize: 14, + fontWeight: '500', + }, + referenceBlock: { + width: '100%', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderRadius: 8, + padding: 12, + borderWidth: 1, + marginVertical: 6, + }, + referenceBlockPressed: { + opacity: 0.8, + }, + referenceContent: { + flex: 1, + gap: 2, + }, + referenceTitle: { + fontSize: 14, + fontWeight: '500', + }, + referenceSubtitle: { + fontSize: 12, + }, hr: { height: 1, marginVertical: 8, diff --git a/apps/mobile/src/components/messages/message-input.tsx b/apps/mobile/src/components/messages/message-input.tsx index e762f2ef..186d8377 100644 --- a/apps/mobile/src/components/messages/message-input.tsx +++ b/apps/mobile/src/components/messages/message-input.tsx @@ -10,13 +10,13 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LocalMessageNode } from '@colanode/client/types/nodes'; import { Block, generateFractionalIndex, generateId, IdType, } from '@colanode/core'; -import { LocalMessageNode } from '@colanode/client/types/nodes'; import { ReplyTarget } from '@colanode/mobile/components/messages/message-item'; import { useTheme } from '@colanode/mobile/contexts/theme'; import { useMutation } from '@colanode/mobile/hooks/use-mutation'; diff --git a/apps/mobile/src/components/messages/message-item.tsx b/apps/mobile/src/components/messages/message-item.tsx index da823d19..63f2fc4b 100644 --- a/apps/mobile/src/components/messages/message-item.tsx +++ b/apps/mobile/src/components/messages/message-item.tsx @@ -1,9 +1,9 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { Block } from '@colanode/core'; import { mapBlocksToContents } from '@colanode/client/lib'; import { LocalMessageNode } from '@colanode/client/types/nodes'; import { User } from '@colanode/client/types/users'; +import { Block } from '@colanode/core'; import { UserAvatar } from '@colanode/mobile/components/avatars/avatar'; import { BlockRenderer } from '@colanode/mobile/components/messages/block-renderer'; import { MessageReactions } from '@colanode/mobile/components/messages/message-reactions'; diff --git a/apps/mobile/src/components/nodes/rename-node-sheet.tsx b/apps/mobile/src/components/nodes/rename-node-sheet.tsx index 09540ed9..b189db29 100644 --- a/apps/mobile/src/components/nodes/rename-node-sheet.tsx +++ b/apps/mobile/src/components/nodes/rename-node-sheet.tsx @@ -7,8 +7,8 @@ import { View, } from 'react-native'; -import { NodeAttributes } from '@colanode/core'; import { LocalNode } from '@colanode/client/types/nodes'; +import { NodeAttributes } from '@colanode/core'; import { BottomSheet } from '@colanode/mobile/components/ui/bottom-sheet'; import { Button } from '@colanode/mobile/components/ui/button'; import { TextInput } from '@colanode/mobile/components/ui/text-input'; diff --git a/apps/mobile/src/components/ui/button.tsx b/apps/mobile/src/components/ui/button.tsx index 8f534abc..51a0d459 100644 --- a/apps/mobile/src/components/ui/button.tsx +++ b/apps/mobile/src/components/ui/button.tsx @@ -7,8 +7,8 @@ import { ViewStyle, } from 'react-native'; -import { ThemeColors } from '@colanode/mobile/lib/colors'; import { useTheme } from '@colanode/mobile/contexts/theme'; +import { ThemeColors } from '@colanode/mobile/lib/colors'; type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'link'; diff --git a/apps/mobile/src/contexts/workspace.tsx b/apps/mobile/src/contexts/workspace.tsx index 315473dc..e79e70e1 100644 --- a/apps/mobile/src/contexts/workspace.tsx +++ b/apps/mobile/src/contexts/workspace.tsx @@ -1,7 +1,7 @@ import { createContext, useContext } from 'react'; -import { WorkspaceRole } from '@colanode/core'; import { Workspace } from '@colanode/client/types/workspaces'; +import { WorkspaceRole } from '@colanode/core'; interface WorkspaceContextValue { userId: string; diff --git a/apps/mobile/src/hooks/use-live-query.ts b/apps/mobile/src/hooks/use-live-query.ts index e2edce12..4a71f850 100644 --- a/apps/mobile/src/hooks/use-live-query.ts +++ b/apps/mobile/src/hooks/use-live-query.ts @@ -4,7 +4,6 @@ import { } from '@tanstack/react-query'; import { QueryInput, QueryMap, buildQueryKey } from '@colanode/client/queries'; - import { useAppService } from '@colanode/mobile/contexts/app-service'; import { MOBILE_WINDOW_ID } from '@colanode/mobile/lib/constants'; diff --git a/apps/mobile/src/hooks/use-mutation.ts b/apps/mobile/src/hooks/use-mutation.ts index 1aa740ce..d6e910b8 100644 --- a/apps/mobile/src/hooks/use-mutation.ts +++ b/apps/mobile/src/hooks/use-mutation.ts @@ -7,7 +7,6 @@ import { MutationInput, MutationMap, } from '@colanode/client/mutations'; - import { useAppService } from '@colanode/mobile/contexts/app-service'; interface MutationOptions { diff --git a/apps/mobile/src/hooks/use-node-list-query.ts b/apps/mobile/src/hooks/use-node-list-query.ts index af8c850e..81c97175 100644 --- a/apps/mobile/src/hooks/use-node-list-query.ts +++ b/apps/mobile/src/hooks/use-node-list-query.ts @@ -2,11 +2,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; import { eventBus } from '@colanode/client/lib'; -import { Event } from '@colanode/client/types'; import { buildQueryKey } from '@colanode/client/queries'; import { NodeListQueryInput } from '@colanode/client/queries/nodes/node-list'; +import { Event } from '@colanode/client/types'; import { LocalNode } from '@colanode/client/types/nodes'; - import { useQuery } from '@colanode/mobile/hooks/use-query'; const getFieldName = (field: Array): string => { diff --git a/apps/mobile/src/hooks/use-node-query.ts b/apps/mobile/src/hooks/use-node-query.ts index 57a078dc..13de5ed3 100644 --- a/apps/mobile/src/hooks/use-node-query.ts +++ b/apps/mobile/src/hooks/use-node-query.ts @@ -1,5 +1,4 @@ import { LocalNode } from '@colanode/client/types/nodes'; - import { useNodeListQuery } from '@colanode/mobile/hooks/use-node-list-query'; export const useNodeQuery = ( diff --git a/apps/mobile/src/hooks/use-query.ts b/apps/mobile/src/hooks/use-query.ts index c8eeab94..67047433 100644 --- a/apps/mobile/src/hooks/use-query.ts +++ b/apps/mobile/src/hooks/use-query.ts @@ -4,7 +4,6 @@ import { } from '@tanstack/react-query'; import { buildQueryKey, QueryInput, QueryMap } from '@colanode/client/queries'; - import { useAppService } from '@colanode/mobile/contexts/app-service'; type UseQueryOptions = Omit< diff --git a/apps/mobile/src/lib/message-utils.ts b/apps/mobile/src/lib/message-utils.ts index e084d70f..68ae48d1 100644 --- a/apps/mobile/src/lib/message-utils.ts +++ b/apps/mobile/src/lib/message-utils.ts @@ -1,5 +1,5 @@ -import { Block } from '@colanode/core'; import { LocalMessageNode } from '@colanode/client/types/nodes'; +import { Block } from '@colanode/core'; export const getMessageText = (message: LocalMessageNode): string => { const content = message.content; diff --git a/apps/mobile/src/lib/query-client.ts b/apps/mobile/src/lib/query-client.ts index 22a68870..1402c003 100644 --- a/apps/mobile/src/lib/query-client.ts +++ b/apps/mobile/src/lib/query-client.ts @@ -1,7 +1,7 @@ import { QueryClient } from '@tanstack/react-query'; -import { eventBus } from '@colanode/client/lib'; import { Mediator } from '@colanode/client/handlers'; +import { eventBus } from '@colanode/client/lib'; import { Event } from '@colanode/client/types'; import { MOBILE_WINDOW_ID } from '@colanode/mobile/lib/constants'; diff --git a/apps/mobile/src/lib/radar-utils.ts b/apps/mobile/src/lib/radar-utils.ts index f0e0acc6..9888c6d1 100644 --- a/apps/mobile/src/lib/radar-utils.ts +++ b/apps/mobile/src/lib/radar-utils.ts @@ -1,6 +1,6 @@ +import { WorkspaceRadarData } from '@colanode/client/types/radars'; import { getIdType, IdType } from '@colanode/core'; -import { WorkspaceRadarData } from '@colanode/client/types/radars'; export const getChatUnreadCount = ( radarData: Record | undefined, diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index c6906358..53b8a145 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -20,10 +20,5 @@ }, "exclude": ["node_modules"], "extends": "expo/tsconfig.base", - "include": ["**/*"], - "references": [ - { "path": "../../packages/core/tsconfig.json" }, - { "path": "../../packages/crdt/tsconfig.json" }, - { "path": "../../packages/client/tsconfig.json" } - ] + "include": ["**/*"] } diff --git a/packages/client/src/databases/workspace/schema.ts b/packages/client/src/databases/workspace/schema.ts index 5de80d4d..6c84b243 100644 --- a/packages/client/src/databases/workspace/schema.ts +++ b/packages/client/src/databases/workspace/schema.ts @@ -268,6 +268,7 @@ interface DownloadTable { id: ColumnType; file_id: ColumnType; version: ColumnType; + type: ColumnType; name: ColumnType; path: ColumnType; size: ColumnType; diff --git a/packages/client/src/handlers/mutations/spaces/space-child-reorder.ts b/packages/client/src/handlers/mutations/spaces/space-child-reorder.ts index 1846cdc4..42ad45da 100644 --- a/packages/client/src/handlers/mutations/spaces/space-child-reorder.ts +++ b/packages/client/src/handlers/mutations/spaces/space-child-reorder.ts @@ -93,7 +93,7 @@ export class SpaceChildReorderMutationHandler return null; } - const sortedById = children.toSorted((a, b) => compareString(a.id, b.id)); + const sortedById = [...children].sort((a, b) => compareString(a.id, b.id)); const indexes: NodeFractionalIndex[] = []; const childrenSettings = attributes.children ?? {}; let lastIndex: string | null = null; diff --git a/packages/client/src/jobs/file-download.ts b/packages/client/src/jobs/file-download.ts index 3a4a4c03..e8cb7681 100644 --- a/packages/client/src/jobs/file-download.ts +++ b/packages/client/src/jobs/file-download.ts @@ -115,8 +115,8 @@ export class FileDownloadJobHandler implements JobHandler { } ); - const writeStream = await this.app.fs.writeStream(download.path); - await response.body?.pipeTo(writeStream); + const bytes = new Uint8Array(await response.arrayBuffer()); + await this.app.fs.writeFile(download.path, bytes); await this.updateDownload(workspace, download.id, { status: DownloadStatus.Completed, diff --git a/packages/client/src/jobs/local-file-download.ts b/packages/client/src/jobs/local-file-download.ts index 6811ba9f..7fe628a6 100644 --- a/packages/client/src/jobs/local-file-download.ts +++ b/packages/client/src/jobs/local-file-download.ts @@ -125,8 +125,8 @@ export class LocalFileDownloadJobHandler } ); - const writeStream = await this.app.fs.writeStream(localFile.path); - await response.body?.pipeTo(writeStream); + const bytes = new Uint8Array(await response.arrayBuffer()); + await this.app.fs.writeFile(localFile.path, bytes); await this.updateLocalFile(workspace, localFile.id, { download_status: DownloadStatus.Completed, @@ -200,7 +200,8 @@ export class LocalFileDownloadJobHandler return; } - const url = await this.app.fs.url(updatedLocalFile.path); + const fileExists = await this.app.fs.exists(updatedLocalFile.path); + const url = fileExists ? await this.app.fs.url(updatedLocalFile.path) : null; eventBus.publish({ type: 'local.file.updated', workspace: { diff --git a/packages/client/src/services/workspaces/file-service.ts b/packages/client/src/services/workspaces/file-service.ts index d6024a4e..3fa03af9 100644 --- a/packages/client/src/services/workspaces/file-service.ts +++ b/packages/client/src/services/workspaces/file-service.ts @@ -32,6 +32,7 @@ import { } from '@colanode/core'; const debug = createDebugger('desktop:service:file'); +const MANUAL_DOWNLOAD_TYPE = 0; export class FileService { private readonly app: AppService; @@ -233,6 +234,7 @@ export class FileService { return null; } + const file = mapNode(node) as LocalFileNode; const updatedLocalFile = await this.workspace.database .updateTable('local_files') .returningAll() @@ -243,15 +245,61 @@ export class FileService { .executeTakeFirst(); if (updatedLocalFile) { - const url = await this.app.fs.url(updatedLocalFile.path); - return mapLocalFile(updatedLocalFile, url); + const fileExists = await this.app.fs.exists(updatedLocalFile.path); + if (fileExists && updatedLocalFile.version === file.version) { + const url = await this.app.fs.url(updatedLocalFile.path); + return mapLocalFile(updatedLocalFile, url); + } + + if (!autoDownload) { + return null; + } + + const refreshedLocalFile = await this.workspace.database + .updateTable('local_files') + .returningAll() + .set({ + version: file.version, + path: this.buildFilePath(fileId, file.extension), + opened_at: new Date().toISOString(), + download_status: DownloadStatus.Pending, + download_progress: 0, + download_completed_at: null, + download_error_code: null, + download_error_message: null, + download_retries: 0, + }) + .where('id', '=', fileId) + .executeTakeFirst(); + + if (!refreshedLocalFile) { + return null; + } + + await this.app.jobs.addJob({ + type: 'local.file.download', + userId: this.workspace.userId, + fileId: fileId, + }); + + const localFile = mapLocalFile(refreshedLocalFile, null); + eventBus.publish({ + type: 'local.file.updated', + workspace: { + workspaceId: this.workspace.workspaceId, + userId: this.workspace.userId, + accountId: this.workspace.accountId, + }, + localFile: localFile, + }); + + return localFile; } if (!autoDownload) { return null; } - const file = mapNode(node) as LocalFileNode; const now = new Date().toISOString(); const createdLocalFile = await this.workspace.database .insertInto('local_files') @@ -329,6 +377,7 @@ export class FileService { id: generateId(IdType.Download), file_id: fileId, version: file.version, + type: MANUAL_DOWNLOAD_TYPE, name: name, path: path, size: file.size, diff --git a/packages/crdt/src/index.ts b/packages/crdt/src/index.ts index 57ebda6e..625aa98e 100644 --- a/packages/crdt/src/index.ts +++ b/packages/crdt/src/index.ts @@ -52,8 +52,9 @@ export class YDoc { schema: z.ZodSchema, object: z.infer ): Uint8Array | null { - if (!schema.safeParse(object).success) { - throw new Error('Invalid object', schema.safeParse(object).error); + const objectParseResult = schema.safeParse(object); + if (!objectParseResult.success) { + throw new Error(`Invalid object: ${objectParseResult.error.message}`); } const objectSchema = this.extractType(schema, object); @@ -74,7 +75,7 @@ export class YDoc { const parseResult = schema.safeParse(objectMap.toJSON()); if (!parseResult.success) { - throw new Error('Invalid object', parseResult.error); + throw new Error(`Invalid object: ${parseResult.error.message}`); } }, ORIGIN);